mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-08 03:18:36 +03:00
Merge pull request #210 from forensic-architecture/feature/add-shapes-filter-panel
Feature/add shapes filter panel
This commit is contained in:
@@ -6,6 +6,7 @@ const EVENT_DATA_URL = urlFromEnv("EVENTS_EXT");
|
||||
const ASSOCIATIONS_URL = urlFromEnv("ASSOCIATIONS_EXT");
|
||||
const SOURCES_URL = urlFromEnv("SOURCES_EXT");
|
||||
const SITES_URL = urlFromEnv("SITES_EXT");
|
||||
const REGIONS_URL = urlFromEnv("REGIONS_EXT");
|
||||
const SHAPES_URL = urlFromEnv("SHAPES_EXT");
|
||||
|
||||
const domainMsg = (domainType) =>
|
||||
@@ -79,6 +80,13 @@ export function fetchDomain() {
|
||||
.catch(() => handleError(domainMsg("sites")));
|
||||
}
|
||||
|
||||
let regionsPromise = Promise.resolve([]);
|
||||
if (features.USE_REGIONS) {
|
||||
regionsPromise = fetch(REGIONS_URL)
|
||||
.then((response) => response.json())
|
||||
.catch(() => handleError(domainMsg("regions")));
|
||||
}
|
||||
|
||||
let shapesPromise = Promise.resolve([]);
|
||||
if (features.USE_SHAPES) {
|
||||
shapesPromise = fetch(SHAPES_URL)
|
||||
@@ -91,6 +99,7 @@ export function fetchDomain() {
|
||||
associationsPromise,
|
||||
sourcesPromise,
|
||||
sitesPromise,
|
||||
regionsPromise,
|
||||
shapesPromise,
|
||||
])
|
||||
.then((response) => {
|
||||
@@ -99,7 +108,8 @@ export function fetchDomain() {
|
||||
associations: response[1],
|
||||
sources: response[2],
|
||||
sites: response[3],
|
||||
shapes: response[4],
|
||||
regions: response[4],
|
||||
shapes: response[5],
|
||||
notifications,
|
||||
};
|
||||
if (
|
||||
@@ -111,6 +121,7 @@ export function fetchDomain() {
|
||||
}
|
||||
dispatch(toggleFetchingDomain());
|
||||
dispatch(setInitialCategories(result.associations));
|
||||
dispatch(setInitialShapes(result.shapes));
|
||||
return result;
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -205,6 +216,14 @@ export function toggleAssociations(association, value, shouldColor) {
|
||||
};
|
||||
}
|
||||
|
||||
export const TOGGLE_SHAPES = "TOGGLE_SHAPES";
|
||||
export function toggleShapes(shape) {
|
||||
return {
|
||||
type: TOGGLE_SHAPES,
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_LOADING = "SET_LOADING";
|
||||
export function setLoading() {
|
||||
return {
|
||||
@@ -227,6 +246,14 @@ export function setInitialCategories(values) {
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_INITIAL_SHAPES = "SET_INITIAL_SHAPES";
|
||||
export function setInitialShapes(values) {
|
||||
return {
|
||||
type: SET_INITIAL_SHAPES,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
export const UPDATE_TIMERANGE = "UPDATE_TIMERANGE";
|
||||
export function updateTimeRange(timerange) {
|
||||
return {
|
||||
|
||||
@@ -4,10 +4,31 @@ export const ASSOCIATION_MODES = {
|
||||
FILTER: "FILTER",
|
||||
};
|
||||
|
||||
export const TIMELINE_ONLY = "TIMELINE_ONLY";
|
||||
export const SHAPE = "SHAPE";
|
||||
|
||||
export const DEFAULT_TAB_ICONS = {
|
||||
CATEGORY: "widgets",
|
||||
NARRATIVE: "timeline",
|
||||
FILTER: "filter_list",
|
||||
SHAPE: "change_history",
|
||||
};
|
||||
|
||||
export const AVAILABLE_SHAPES = {
|
||||
STAR: "STAR",
|
||||
DIAMOND: "DIAMOND",
|
||||
PENTAGON: "PENTAGON",
|
||||
SQUARE: "SQUARE",
|
||||
DOT: "DOT",
|
||||
BAR: "BAR",
|
||||
TRIANGLE: "TRIANGLE",
|
||||
};
|
||||
|
||||
export const POLYGON_CLIP_PATH = {
|
||||
STAR:
|
||||
"polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)",
|
||||
DIAMOND: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
|
||||
PENTAGON: "polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)",
|
||||
TRIANGLE: "polygon(50% 0%, 0% 100%, 100% 100%)",
|
||||
};
|
||||
|
||||
export const DEFAULT_CHECKBOX_COLOR = "#ffffff";
|
||||
|
||||
@@ -145,7 +145,11 @@
|
||||
"categories": "Categories",
|
||||
"categories_label": "Categories",
|
||||
"explore_by_category__title": "Explore events by category",
|
||||
"explore_by_category__description": "‘Categories’ refer to the victims of a given incident.<br><br>If no categories are selected, all datapoints are displayed."
|
||||
"explore_by_category__description": "‘Categories’ refer to the victims of a given incident.<br><br>If no categories are selected, all datapoints are displayed.",
|
||||
"shapes": "Shapes",
|
||||
"shapes_label": "Shapes",
|
||||
"explore_by_shapes__title": "Explore events by shape breakdown",
|
||||
"explore_by_shape__description": "Shapes map to a given type of event that appears on the timeline.<br><br>Select the shape marker to toggle this type of event on / off"
|
||||
},
|
||||
"timeline": {
|
||||
"labels_title": "Testimonies",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from "moment";
|
||||
import hash from "object-hash";
|
||||
|
||||
import { ASSOCIATION_MODES } from "./constants";
|
||||
import { ASSOCIATION_MODES, POLYGON_CLIP_PATH } from "./constants";
|
||||
|
||||
let { DATE_FMT, TIME_FMT } = process.env;
|
||||
if (!DATE_FMT) DATE_FMT = "MM/DD/YYYY";
|
||||
@@ -148,7 +148,7 @@ export function getFilterAncestors(filter) {
|
||||
const accumulatedPath = splitFilter.slice(0, index + 1).join("/");
|
||||
ancestors.push(accumulatedPath);
|
||||
});
|
||||
// // The last element here will be the leaf node aka the filter passed in
|
||||
// The last element here will be the leaf node aka the filter passed in
|
||||
ancestors.pop();
|
||||
return ancestors;
|
||||
}
|
||||
@@ -510,3 +510,23 @@ export function setD3Locale(d3) {
|
||||
d3.timeFormatDefaultLocale(languages[language]);
|
||||
}
|
||||
}
|
||||
|
||||
export function mapStyleByShape(shapes, activeShapes) {
|
||||
const styledShapes = shapes.map((s) => {
|
||||
const { colour, shape, id } = s;
|
||||
const style = {
|
||||
checkboxStyles: {
|
||||
background: activeShapes.includes(id) ? colour : "black",
|
||||
border: "none",
|
||||
clipPath: POLYGON_CLIP_PATH[shape],
|
||||
},
|
||||
containerStyles: {
|
||||
background: colour,
|
||||
clipPath: POLYGON_CLIP_PATH[shape],
|
||||
},
|
||||
};
|
||||
s.styles = style;
|
||||
return s;
|
||||
});
|
||||
return styledShapes;
|
||||
}
|
||||
|
||||
@@ -327,6 +327,7 @@ class Dashboard extends React.Component {
|
||||
actions.toggleAssociations("filters", filters),
|
||||
onCategoryFilter: (categories) =>
|
||||
actions.toggleAssociations("categories", categories),
|
||||
onShapeFilter: actions.toggleShapes,
|
||||
onSelectNarrative: this.setNarrative,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as selectors from "../selectors";
|
||||
import { Tabs, TabPanel } from "react-tabs";
|
||||
import FilterListPanel from "./controls/FilterListPanel";
|
||||
import CategoriesListPanel from "./controls/CategoriesListPanel";
|
||||
import ShapesListPanel from "./controls/ShapesListPanel";
|
||||
import BottomActions from "./controls/BottomActions";
|
||||
import copy from "../common/data/copy.json";
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
addToColoringSet,
|
||||
removeFromColoringSet,
|
||||
} from "../common/utilities.js";
|
||||
import { DEFAULT_TAB_ICONS } from "../common/constants";
|
||||
|
||||
class Toolbar extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -85,17 +85,10 @@ class Toolbar extends React.Component {
|
||||
|
||||
renderToolbarNarrativePanel() {
|
||||
const { panels } = this.props.toolbarCopy;
|
||||
const panelTitle = panels.narratives.label
|
||||
? panels.narratives.label
|
||||
: copy[this.props.language].toolbar.narratives;
|
||||
const panelDescription = panels.narratives.description
|
||||
? panels.narratives.description
|
||||
: copy[this.props.language].toolbar.explore_by_narrative__description;
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<h2>{panelTitle}</h2>
|
||||
<p>{panelDescription}</p>
|
||||
<h2>{panels.narratives.label}</h2>
|
||||
<p>{panels.narratives.description}</p>
|
||||
{this.props.narratives.map((narr) => {
|
||||
return (
|
||||
<div className="panel-action action">
|
||||
@@ -118,13 +111,6 @@ class Toolbar extends React.Component {
|
||||
|
||||
renderToolbarCategoriesPanel() {
|
||||
const { panels } = this.props.toolbarCopy;
|
||||
const panelTitle = panels.categories.label
|
||||
? panels.categories.label
|
||||
: copy[this.props.language].toolbar.categories;
|
||||
const panelDescription = panels.categories.description
|
||||
? panels.categories.description
|
||||
: copy[this.props.language].toolbar.explore_by_category__description;
|
||||
|
||||
if (this.props.features.USE_CATEGORIES) {
|
||||
return (
|
||||
<TabPanel>
|
||||
@@ -133,8 +119,8 @@ class Toolbar extends React.Component {
|
||||
activeCategories={this.props.activeCategories}
|
||||
onCategoryFilter={this.props.methods.onCategoryFilter}
|
||||
language={this.props.language}
|
||||
title={panelTitle}
|
||||
description={panelDescription}
|
||||
title={panels.categories.label}
|
||||
description={panels.categories.description}
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
@@ -143,13 +129,6 @@ class Toolbar extends React.Component {
|
||||
|
||||
renderToolbarFilterPanel() {
|
||||
const { panels } = this.props.toolbarCopy;
|
||||
const panelTitle = panels.filters.label
|
||||
? panels.filters.label
|
||||
: copy[this.props.language].toolbar.filters;
|
||||
const panelDescription = panels.filters.description
|
||||
? panels.filters.description
|
||||
: copy[this.props.language].toolbar.explore_by_filter__description;
|
||||
|
||||
return (
|
||||
<TabPanel>
|
||||
<FilterListPanel
|
||||
@@ -159,13 +138,32 @@ class Toolbar extends React.Component {
|
||||
language={this.props.language}
|
||||
coloringSet={this.props.coloringSet}
|
||||
filterColors={this.props.filterColors}
|
||||
title={panelTitle}
|
||||
description={panelDescription}
|
||||
title={panels.filters.label}
|
||||
description={panels.filters.description}
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
renderToolbarShapePanel() {
|
||||
const { panels } = this.props.toolbarCopy;
|
||||
|
||||
if (this.props.features.USE_SHAPES) {
|
||||
return (
|
||||
<TabPanel>
|
||||
<ShapesListPanel
|
||||
shapes={this.props.shapes}
|
||||
activeShapes={this.props.activeShapes}
|
||||
onShapeFilter={this.props.methods.onShapeFilter}
|
||||
language={this.props.language}
|
||||
title={panels.shapes.label}
|
||||
description={panels.shapes.description}
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderToolbarTab(_selected, label, iconKey) {
|
||||
const isActive = this.state._selected === _selected;
|
||||
const classes = isActive ? "toolbar-tab active" : "toolbar-tab";
|
||||
@@ -196,6 +194,7 @@ class Toolbar extends React.Component {
|
||||
: null}
|
||||
{features.USE_CATEGORIES ? this.renderToolbarCategoriesPanel() : null}
|
||||
{features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null}
|
||||
{features.USE_SHAPES ? this.renderToolbarShapePanel() : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
@@ -228,31 +227,17 @@ class Toolbar extends React.Component {
|
||||
const narrativesExist = narratives && narratives.length !== 0;
|
||||
let title = copy[this.props.language].toolbar.title;
|
||||
if (process.env.display_title) title = process.env.display_title;
|
||||
|
||||
const { panels } = toolbarCopy;
|
||||
const narrativesLabel = copy[this.props.language].toolbar.narratives_label;
|
||||
const filtersLabel = panels.filters.label
|
||||
? panels.filters.label
|
||||
: copy[this.props.language].toolbar.filters_label;
|
||||
const categoriesLabel = panels.categories.label
|
||||
? panels.categories.label
|
||||
: copy[this.props.language].toolbar.categories_label;
|
||||
|
||||
const filterIcon = panels.filters.icon
|
||||
? panels.filters.icon
|
||||
: DEFAULT_TAB_ICONS.FILTER;
|
||||
const categoriesIcon = panels.categories.icon
|
||||
? panels.categories.icon
|
||||
: DEFAULT_TAB_ICONS.CATEGORY;
|
||||
|
||||
const narrativesIdx = 0;
|
||||
const categoriesIdx = narrativesExist ? 1 : 0;
|
||||
const filtersIdx =
|
||||
narrativesExist && features.CATEGORIES_AS_FILTERS
|
||||
narrativesExist && features.USE_CATEGORIES
|
||||
? 2
|
||||
: narrativesExist || features.CATEGORIES_AS_FILTERS
|
||||
: narrativesExist || features.USE_CATEGORIES
|
||||
? 1
|
||||
: 0;
|
||||
const shapesIdx = filtersIdx + 1;
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-header" onClick={this.props.methods.onTitle}>
|
||||
@@ -260,17 +245,32 @@ class Toolbar extends React.Component {
|
||||
</div>
|
||||
<div className="toolbar-tabs">
|
||||
{narrativesExist
|
||||
? this.renderToolbarTab(narrativesIdx, narrativesLabel, "timeline")
|
||||
? this.renderToolbarTab(
|
||||
narrativesIdx,
|
||||
panels.narratives.label,
|
||||
panels.narratives.icon
|
||||
)
|
||||
: null}
|
||||
{features.CATEGORIES_AS_FILTERS
|
||||
{features.USE_CATEGORIES
|
||||
? this.renderToolbarTab(
|
||||
categoriesIdx,
|
||||
categoriesLabel,
|
||||
categoriesIcon
|
||||
panels.categories.label,
|
||||
panels.categories.icon
|
||||
)
|
||||
: null}
|
||||
{features.USE_ASSOCIATIONS
|
||||
? this.renderToolbarTab(filtersIdx, filtersLabel, filterIcon)
|
||||
? this.renderToolbarTab(
|
||||
filtersIdx,
|
||||
panels.filters.label,
|
||||
panels.filters.icon
|
||||
)
|
||||
: null}
|
||||
{features.USE_SHAPES
|
||||
? this.renderToolbarTab(
|
||||
shapesIdx,
|
||||
panels.shapes.label,
|
||||
panels.shapes.icon
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<BottomActions
|
||||
@@ -311,10 +311,12 @@ function mapStateToProps(state) {
|
||||
filters: selectors.getFilters(state),
|
||||
categories: selectors.getCategories(state),
|
||||
narratives: selectors.selectNarratives(state),
|
||||
shapes: selectors.getShapes(state),
|
||||
language: state.app.language,
|
||||
toolbarCopy: state.app.toolbar,
|
||||
activeFilters: selectors.getActiveFilters(state),
|
||||
activeCategories: selectors.getActiveCategories(state),
|
||||
activeShapes: selectors.getActiveShapes(state),
|
||||
viewFilters: state.app.associations.views,
|
||||
narrative: state.app.associations.narrative,
|
||||
sitesShowing: state.app.flags.isShowingSites,
|
||||
@@ -322,6 +324,7 @@ function mapStateToProps(state) {
|
||||
coloringSet: state.app.associations.coloringSet,
|
||||
maxNumOfColors: state.ui.coloring.maxNumOfColors,
|
||||
filterColors: state.ui.coloring.colors,
|
||||
eventRadius: state.ui.eventRadius,
|
||||
features: selectors.getFeatures(state),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import React from "react";
|
||||
import { DEFAULT_CHECKBOX_COLOR } from "../../common/constants";
|
||||
|
||||
const Checkbox = ({ label, isActive, onClickCheckbox, color }) => {
|
||||
const styles = {
|
||||
background: isActive ? color : "none",
|
||||
border: `1px solid ${color}`,
|
||||
const Checkbox = ({ label, isActive, onClickCheckbox, color, styleProps }) => {
|
||||
const checkboxColor = color ? color : DEFAULT_CHECKBOX_COLOR;
|
||||
const baseStyles = {
|
||||
checkboxStyles: {
|
||||
background: isActive ? checkboxColor : "none",
|
||||
border: `1px solid ${checkboxColor}`,
|
||||
},
|
||||
};
|
||||
|
||||
const containerStyles = styleProps ? styleProps.containerStyles : {};
|
||||
const checkboxStyles = styleProps
|
||||
? styleProps.checkboxStyles
|
||||
: baseStyles.checkboxStyles;
|
||||
return (
|
||||
<div className={isActive ? "item active" : "item"}>
|
||||
<span style={{ color: color }}>{label}</span>
|
||||
<button onClick={onClickCheckbox}>
|
||||
<div className="checkbox" style={styles} />
|
||||
<div className="border" style={containerStyles}>
|
||||
<div className="checkbox" style={checkboxStyles} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import marked from "marked";
|
||||
import Checkbox from "../atoms/Checkbox";
|
||||
import PanelTree from "./atoms/PanelTree";
|
||||
import { ASSOCIATION_MODES } from "../../common/constants";
|
||||
|
||||
const CategoriesListPanel = ({
|
||||
categories,
|
||||
@@ -10,28 +11,6 @@ const CategoriesListPanel = ({
|
||||
title,
|
||||
description,
|
||||
}) => {
|
||||
function renderCategoryTree() {
|
||||
return (
|
||||
<div>
|
||||
{categories.map((cat) => {
|
||||
return (
|
||||
<li
|
||||
key={cat.title.replace(/ /g, "_")}
|
||||
className="filter-filter active"
|
||||
style={{ marginLeft: "20px" }}
|
||||
>
|
||||
<Checkbox
|
||||
label={cat.title}
|
||||
isActive={activeCategories.includes(cat.title)}
|
||||
onClickCheckbox={() => onCategoryFilter(cat.title)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="react-innertabpanel">
|
||||
<h2>{title}</h2>
|
||||
@@ -40,7 +19,12 @@ const CategoriesListPanel = ({
|
||||
__html: marked(description),
|
||||
}}
|
||||
/>
|
||||
{renderCategoryTree()}
|
||||
<PanelTree
|
||||
data={categories}
|
||||
activeValues={activeCategories}
|
||||
onSelect={onCategoryFilter}
|
||||
type={ASSOCIATION_MODES.CATEGORY}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
34
src/components/controls/ShapesListPanel.js
Normal file
34
src/components/controls/ShapesListPanel.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import marked from "marked";
|
||||
import PanelTree from "./atoms/PanelTree";
|
||||
import { mapStyleByShape } from "../../common/utilities";
|
||||
import { SHAPE } from "../../common/constants";
|
||||
|
||||
const ShapesListPanel = ({
|
||||
shapes,
|
||||
activeShapes,
|
||||
onShapeFilter,
|
||||
language,
|
||||
title,
|
||||
description,
|
||||
}) => {
|
||||
const styledShapes = mapStyleByShape(shapes, activeShapes);
|
||||
return (
|
||||
<div className="react-innertabpanel">
|
||||
<h2>{title}</h2>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked(description),
|
||||
}}
|
||||
/>
|
||||
<PanelTree
|
||||
data={styledShapes}
|
||||
activeValues={activeShapes}
|
||||
onSelect={onShapeFilter}
|
||||
type={SHAPE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShapesListPanel;
|
||||
30
src/components/controls/atoms/PanelTree.js
Normal file
30
src/components/controls/atoms/PanelTree.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import Checkbox from "../../atoms/Checkbox";
|
||||
import { ASSOCIATION_MODES } from "../../../common/constants";
|
||||
|
||||
const PanelTree = ({ data, activeValues, onSelect, type }) => {
|
||||
// If the parent panel is of type 'CATEGORY': filter on title. If panel is 'SHAPE': filter on id
|
||||
const onSelectionType = type === ASSOCIATION_MODES.CATEGORY ? "title" : "id";
|
||||
return (
|
||||
<div>
|
||||
{data.map((val) => {
|
||||
return (
|
||||
<li
|
||||
key={val.title.replace(/ /g, "_")}
|
||||
className="filter-filter active"
|
||||
style={{ marginLeft: "20px" }}
|
||||
>
|
||||
<Checkbox
|
||||
label={val.title}
|
||||
isActive={activeValues.includes(val[onSelectionType])}
|
||||
onClickCheckbox={() => onSelect(val[onSelectionType])}
|
||||
styleProps={val.styles}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PanelTree;
|
||||
@@ -8,7 +8,7 @@ import { connect } from "react-redux";
|
||||
import * as selectors from "../../../selectors";
|
||||
|
||||
import Sites from "./atoms/Sites";
|
||||
import Shapes from "./atoms/Shapes";
|
||||
import Regions from "./atoms/Regions";
|
||||
import Events from "./atoms/Events";
|
||||
import Clusters from "./atoms/Clusters";
|
||||
import SelectedEvents from "./atoms/SelectedEvents";
|
||||
@@ -324,13 +324,13 @@ class Map extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderShapes() {
|
||||
renderRegions() {
|
||||
return (
|
||||
<Shapes
|
||||
<Regions
|
||||
svg={this.svgRef.current}
|
||||
shapes={this.props.domain.shapes}
|
||||
regions={this.props.domain.regions}
|
||||
projectPoint={this.projectPoint}
|
||||
styles={this.props.ui.shapes}
|
||||
styles={this.props.ui.regions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -481,7 +481,7 @@ class Map extends React.Component {
|
||||
{this.renderTiles()}
|
||||
{this.renderMarkers()}
|
||||
{isShowingSites ? this.renderSites() : null}
|
||||
{this.renderShapes()}
|
||||
{this.renderRegions()}
|
||||
{this.renderNarratives()}
|
||||
{this.renderEvents()}
|
||||
{this.renderClusters()}
|
||||
@@ -510,7 +510,7 @@ function mapStateToProps(state) {
|
||||
narratives: selectors.selectNarratives(state),
|
||||
categories: selectors.getCategories(state),
|
||||
sites: selectors.selectSites(state),
|
||||
shapes: selectors.selectShapes(state),
|
||||
regions: selectors.selectRegions(state),
|
||||
},
|
||||
app: {
|
||||
views: state.app.associations.views,
|
||||
@@ -532,7 +532,7 @@ function mapStateToProps(state) {
|
||||
dom: state.ui.dom,
|
||||
narratives: state.ui.style.narratives,
|
||||
mapSelectedEvents: state.ui.style.selectedEvents,
|
||||
shapes: state.ui.style.shapes,
|
||||
regions: state.ui.style.regions,
|
||||
eventRadius: state.ui.eventRadius,
|
||||
radial: state.ui.style.clusters.radial,
|
||||
filterColors: state.ui.coloring.colors,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
|
||||
function MapShapes({ svg, shapes, projectPoint, styles }) {
|
||||
function renderShape(shape) {
|
||||
function MapRegions({ svg, regions, projectPoint, styles }) {
|
||||
function renderRegion(region) {
|
||||
const lineCoords = [];
|
||||
const points = shape.points.map(projectPoint);
|
||||
const points = region.points.map(projectPoint);
|
||||
|
||||
points.forEach((p1, idx) => {
|
||||
if (idx < shape.points.length - 1) {
|
||||
if (idx < region.points.length - 1) {
|
||||
const p2 = points[idx + 1];
|
||||
lineCoords.push({
|
||||
x1: p1.x,
|
||||
@@ -19,29 +19,29 @@ function MapShapes({ svg, shapes, projectPoint, styles }) {
|
||||
});
|
||||
|
||||
return lineCoords.map((coords) => {
|
||||
const shapeStyles =
|
||||
shape.name in styles ? styles[shape.name] : styles.default;
|
||||
const regionstyles =
|
||||
region.name in styles ? styles[region.name] : styles.default;
|
||||
|
||||
return (
|
||||
<line
|
||||
id={`${shape.name}_style`}
|
||||
id={`${region.name}_style`}
|
||||
markerStart="none"
|
||||
{...coords}
|
||||
style={shapeStyles}
|
||||
style={regionstyles}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!shapes || !shapes.length) return null;
|
||||
if (!regions || !regions.length) return null;
|
||||
|
||||
return (
|
||||
<Portal node={svg}>
|
||||
<g id="shapes-layer" className="narrative">
|
||||
{shapes.map(renderShape)}
|
||||
<g id="regions-layer" className="narrative">
|
||||
{regions.map(renderRegion)}
|
||||
</g>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapShapes;
|
||||
export default MapRegions;
|
||||
@@ -7,7 +7,7 @@ const DatetimePentagon = ({ x, y, r, transform, onSelect, styleProps }) => {
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y - r}
|
||||
y={y}
|
||||
style={styleProps}
|
||||
points={`${x},${y + s} ${x + s},${y} ${x + s},${y - s} ${x - s},${
|
||||
y - s
|
||||
|
||||
@@ -15,7 +15,7 @@ const DatetimeStar = ({
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y - r}
|
||||
y={y}
|
||||
style={styleProps}
|
||||
points={`${x + s},${y - s} ${x - r},${y} ${x + r},${y} ${x - s},${
|
||||
y - s
|
||||
|
||||
@@ -7,7 +7,7 @@ const DatetimeTriangle = ({ x, y, r, transform, onSelect, styleProps }) => {
|
||||
onClick={onSelect}
|
||||
className="event"
|
||||
x={x}
|
||||
y={y - r}
|
||||
y={y}
|
||||
style={styleProps}
|
||||
points={`${x},${y + s} ${x + s},${y - s} ${x - s},${y - s}`}
|
||||
transform={`rotate(180, ${x}, ${y})`}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isLatitude,
|
||||
isLongitude,
|
||||
} from "../../../common/utilities";
|
||||
import { AVAILABLE_SHAPES } from "../../../common/constants";
|
||||
|
||||
function renderDot(event, styles, props) {
|
||||
const colorPercentages = calculateColorPercentages(
|
||||
@@ -156,19 +157,21 @@ const TimelineEvents = ({
|
||||
(isLatitude(event.latitude) && isLongitude(event.longitude)) ||
|
||||
(features.GRAPH_NONLOCATED && event.projectOffset !== -1);
|
||||
|
||||
const { shape: eventShape } = event;
|
||||
|
||||
let renderShape = isDot ? renderDot : renderBar;
|
||||
if (event.shape) {
|
||||
if (event.shape === "bar") {
|
||||
if (eventShape.shape) {
|
||||
if (eventShape.shape === AVAILABLE_SHAPES.BAR) {
|
||||
renderShape = renderBar;
|
||||
} else if (event.shape === "diamond") {
|
||||
} else if (eventShape.shape === AVAILABLE_SHAPES.DIAMOND) {
|
||||
renderShape = renderDiamond;
|
||||
} else if (event.shape === "star") {
|
||||
} else if (eventShape.shape === AVAILABLE_SHAPES.STAR) {
|
||||
renderShape = renderStar;
|
||||
} else if (event.shape === "triangle") {
|
||||
} else if (eventShape.shape === AVAILABLE_SHAPES.TRIANGLE) {
|
||||
renderShape = renderTriangle;
|
||||
} else if (event.shape === "pentagon") {
|
||||
} else if (eventShape.shape === AVAILABLE_SHAPES.PENTAGON) {
|
||||
renderShape = renderPentagon;
|
||||
} else if (event.shape === "square") {
|
||||
} else if (eventShape.shape === AVAILABLE_SHAPES.SQUARE) {
|
||||
renderShape = renderSquare;
|
||||
} else {
|
||||
renderShape = renderDot;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isLatitude,
|
||||
isLongitude,
|
||||
} from "../../../common/utilities";
|
||||
import { AVAILABLE_SHAPES } from "../../../common/constants";
|
||||
|
||||
const TimelineMarkers = ({
|
||||
styles,
|
||||
@@ -72,11 +73,11 @@ const TimelineMarkers = ({
|
||||
function renderMarkerForEvent(y) {
|
||||
switch (event.shape) {
|
||||
case "circle":
|
||||
case "diamond":
|
||||
case "star":
|
||||
case AVAILABLE_SHAPES.DIAMOND:
|
||||
case AVAILABLE_SHAPES.STAR:
|
||||
acc.push(renderCircle(y));
|
||||
break;
|
||||
case "bar":
|
||||
case AVAILABLE_SHAPES.BAR:
|
||||
acc.push(renderBar(y));
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
UPDATE_COLORING_SET,
|
||||
CLEAR_FILTER,
|
||||
TOGGLE_ASSOCIATIONS,
|
||||
TOGGLE_SHAPES,
|
||||
UPDATE_TIMERANGE,
|
||||
UPDATE_DIMENSIONS,
|
||||
UPDATE_NARRATIVE,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
SET_LOADING,
|
||||
SET_NOT_LOADING,
|
||||
SET_INITIAL_CATEGORIES,
|
||||
SET_INITIAL_SHAPES,
|
||||
UPDATE_SEARCH_QUERY,
|
||||
} from "../actions";
|
||||
|
||||
@@ -153,6 +155,21 @@ function toggleAssociations(appState, action) {
|
||||
};
|
||||
}
|
||||
|
||||
function toggleShapes(appState, action) {
|
||||
let newShapes = [...appState.shapes];
|
||||
if (newShapes.includes(action.shape)) {
|
||||
const idx = newShapes.indexOf(action.shape);
|
||||
newShapes.splice(idx, 1);
|
||||
} else {
|
||||
newShapes.push(action.shape);
|
||||
}
|
||||
|
||||
return {
|
||||
...appState,
|
||||
shapes: newShapes,
|
||||
};
|
||||
}
|
||||
|
||||
function clearFilter(appState, action) {
|
||||
return {
|
||||
...appState,
|
||||
@@ -256,6 +273,14 @@ function setInitialCategories(appState, action) {
|
||||
};
|
||||
}
|
||||
|
||||
function setInitialShapes(appState, action) {
|
||||
const shapeIds = action.values.map((sh) => sh.id);
|
||||
return {
|
||||
...appState,
|
||||
shapes: shapeIds,
|
||||
};
|
||||
}
|
||||
|
||||
function updateSearchQuery(appState, action) {
|
||||
return {
|
||||
...appState,
|
||||
@@ -275,6 +300,8 @@ function app(appState = initial.app, action) {
|
||||
return clearFilter(appState, action);
|
||||
case TOGGLE_ASSOCIATIONS:
|
||||
return toggleAssociations(appState, action);
|
||||
case TOGGLE_SHAPES:
|
||||
return toggleShapes(appState, action);
|
||||
case UPDATE_TIMERANGE:
|
||||
return updateTimeRange(appState, action);
|
||||
case UPDATE_DIMENSIONS:
|
||||
@@ -313,6 +340,8 @@ function app(appState = initial.app, action) {
|
||||
return setNotLoading(appState);
|
||||
case SET_INITIAL_CATEGORIES:
|
||||
return setInitialCategories(appState, action);
|
||||
case SET_INITIAL_SHAPES:
|
||||
return setInitialShapes(appState, action);
|
||||
case UPDATE_SEARCH_QUERY:
|
||||
return updateSearchQuery(appState, action);
|
||||
default:
|
||||
|
||||
8
src/reducers/validate/regionSchema.js
Normal file
8
src/reducers/validate/regionSchema.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import Joi from "joi";
|
||||
|
||||
const regionSchema = Joi.object().keys({
|
||||
name: Joi.string().required(),
|
||||
items: Joi.array().required(),
|
||||
});
|
||||
|
||||
export default regionSchema;
|
||||
@@ -1,8 +1,10 @@
|
||||
import Joi from "joi";
|
||||
|
||||
const shapeSchema = Joi.object().keys({
|
||||
name: Joi.string().required(),
|
||||
items: Joi.array().required(),
|
||||
id: Joi.string().allow(""),
|
||||
title: Joi.string().allow(""),
|
||||
shape: Joi.string().allow(""),
|
||||
colour: Joi.string().allow(""),
|
||||
});
|
||||
|
||||
export default shapeSchema;
|
||||
|
||||
@@ -4,6 +4,7 @@ import createEventSchema from "./eventSchema";
|
||||
import siteSchema from "./siteSchema";
|
||||
import associationsSchema from "./associationsSchema";
|
||||
import sourceSchema from "./sourceSchema";
|
||||
import regionSchema from "./regionSchema";
|
||||
import shapeSchema from "./shapeSchema";
|
||||
|
||||
import { calcDatetime, capitalize } from "../../common/utilities";
|
||||
@@ -53,6 +54,7 @@ export function validateDomain(domain, features) {
|
||||
sites: [],
|
||||
associations: [],
|
||||
sources: {},
|
||||
regions: [],
|
||||
shapes: [],
|
||||
notifications: domain ? domain.notifications : null,
|
||||
};
|
||||
@@ -66,6 +68,7 @@ export function validateDomain(domain, features) {
|
||||
sites: [],
|
||||
associations: [],
|
||||
sources: [],
|
||||
regions: [],
|
||||
shapes: [],
|
||||
};
|
||||
|
||||
@@ -114,14 +117,31 @@ export function validateDomain(domain, features) {
|
||||
validateArray(domain.sites, "sites", siteSchema);
|
||||
validateArray(domain.associations, "associations", associationsSchema);
|
||||
validateObject(domain.sources, "sources", sourceSchema);
|
||||
validateObject(domain.shapes, "shapes", shapeSchema);
|
||||
validateArray(domain.regions, "regions", regionSchema);
|
||||
validateArray(domain.shapes, "shapes", shapeSchema);
|
||||
|
||||
// NB: [lat, lon] array is best format for projecting into map
|
||||
sanitizedDomain.shapes = sanitizedDomain.shapes.map((shape) => ({
|
||||
name: shape.name,
|
||||
points: shape.items.map((coords) => coords.replace(/\s/g, "").split(",")),
|
||||
sanitizedDomain.regions = sanitizedDomain.regions.map((region) => ({
|
||||
name: region.name,
|
||||
points: region.items.map((coords) => coords.replace(/\s/g, "").split(",")),
|
||||
}));
|
||||
|
||||
sanitizedDomain.shapes = sanitizedDomain.shapes.reduce((acc, val) => {
|
||||
if (!val.shape) {
|
||||
discardedDomain.shapes.push({
|
||||
...val,
|
||||
error: makeError(
|
||||
"events",
|
||||
val.id,
|
||||
"Invalid event shape. Please specify a shape for this type of event."
|
||||
),
|
||||
});
|
||||
} else {
|
||||
acc.push(val);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const duplicateAssociations = findDuplicateAssociations(domain.associations);
|
||||
// Duplicated associations
|
||||
if (duplicateAssociations.length > 0) {
|
||||
@@ -136,6 +156,7 @@ export function validateDomain(domain, features) {
|
||||
|
||||
// append events with datetime and sort
|
||||
sanitizedDomain.events = sanitizedDomain.events.filter((event, idx) => {
|
||||
let errorMsg = "";
|
||||
event.id = idx;
|
||||
// event.associations comes in as a [association.ids...]; convert to actual association objects
|
||||
event.associations = event.associations.reduce((acc, id) => {
|
||||
@@ -145,19 +166,31 @@ export function validateDomain(domain, features) {
|
||||
if (foundAssociation) acc.push(foundAssociation);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (event.shape) {
|
||||
const relatedShapeObj = sanitizedDomain.shapes.find(
|
||||
(elem) => elem.id === event.shape
|
||||
);
|
||||
if (!relatedShapeObj)
|
||||
errorMsg =
|
||||
"Failed to find related shape. Please verify shape type for event.";
|
||||
else {
|
||||
event.shape = relatedShapeObj;
|
||||
}
|
||||
}
|
||||
// if lat, long come in with commas, replace with decimal format
|
||||
event.latitude = event.latitude.replace(",", ".");
|
||||
event.longitude = event.longitude.replace(",", ".");
|
||||
|
||||
event.datetime = calcDatetime(event.date, event.time);
|
||||
if (!isValidDate(event.datetime)) {
|
||||
if (!isValidDate(event.datetime))
|
||||
errorMsg =
|
||||
"Invalid date. It's been dropped, as otherwise timemap won't work as expected.";
|
||||
|
||||
if (errorMsg) {
|
||||
discardedDomain.events.push({
|
||||
...event,
|
||||
error: makeError(
|
||||
"events",
|
||||
event.id,
|
||||
"Invalid date. It's been dropped, as otherwise timemap won't work as expected."
|
||||
),
|
||||
error: makeError("events", event.id, errorMsg),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -177,6 +210,5 @@ export function validateDomain(domain, features) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizedDomain;
|
||||
}
|
||||
|
||||
@@ -413,14 +413,26 @@
|
||||
text-align: left;
|
||||
float: left;
|
||||
|
||||
.checkbox {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid $offwhite;
|
||||
box-sizing: border-box;
|
||||
.border {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: none;
|
||||
float: left;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
.checkbox {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1px solid $offwhite;
|
||||
box-sizing: border-box;
|
||||
background: none;
|
||||
float: left;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createFilterPathString,
|
||||
} from "../common/utilities";
|
||||
import { isTimeRangedIn } from "./helpers";
|
||||
import { ASSOCIATION_MODES, TIMELINE_ONLY } from "../common/constants";
|
||||
import { ASSOCIATION_MODES, SHAPE } from "../common/constants";
|
||||
|
||||
// Input selectors
|
||||
export const getEvents = (state) => state.domain.events;
|
||||
@@ -24,6 +24,7 @@ export const getActiveNarrative = (state) => state.app.associations.narrative;
|
||||
export const getSelected = (state) => state.app.selected;
|
||||
export const getSites = (state) => state.domain.sites;
|
||||
export const getSources = (state) => state.domain.sources;
|
||||
export const getRegions = (state) => state.domain.regions;
|
||||
export const getShapes = (state) => state.domain.shapes;
|
||||
export const getFilters = (state) =>
|
||||
state.domain.associations.filter(
|
||||
@@ -32,6 +33,7 @@ export const getFilters = (state) =>
|
||||
export const getNotifications = (state) => state.domain.notifications;
|
||||
export const getActiveFilters = (state) => state.app.associations.filters;
|
||||
export const getActiveCategories = (state) => state.app.associations.categories;
|
||||
export const getActiveShapes = (state) => state.app.shapes;
|
||||
export const getTimeRange = (state) => state.app.timeline.range;
|
||||
export const getTimelineDimensions = (state) => state.app.timeline.dimensions;
|
||||
export const selectNarrative = (state) => state.app.associations.narrative;
|
||||
@@ -56,10 +58,10 @@ export const selectSources = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
export const selectShapes = createSelector(
|
||||
[getShapes, getFeatures],
|
||||
(shapes, features) => {
|
||||
if (features.USE_SHAPES) return shapes;
|
||||
export const selectRegions = createSelector(
|
||||
[getRegions, getFeatures],
|
||||
(regions, features) => {
|
||||
if (features.USE_REGIONS) return regions;
|
||||
return [];
|
||||
}
|
||||
);
|
||||
@@ -71,8 +73,22 @@ export const selectShapes = createSelector(
|
||||
* 3. exist in an active category
|
||||
*/
|
||||
export const selectEvents = createSelector(
|
||||
[getEvents, getActiveFilters, getActiveCategories, getTimeRange, getFeatures],
|
||||
(events, activeFilters, activeCategories, timeRange, features) => {
|
||||
[
|
||||
getEvents,
|
||||
getActiveFilters,
|
||||
getActiveCategories,
|
||||
getActiveShapes,
|
||||
getTimeRange,
|
||||
getFeatures,
|
||||
],
|
||||
(
|
||||
events,
|
||||
activeFilters,
|
||||
activeCategories,
|
||||
activeShapes,
|
||||
timeRange,
|
||||
features
|
||||
) => {
|
||||
return events.reduce((acc, event) => {
|
||||
const isMatchingFilter =
|
||||
(event.associations &&
|
||||
@@ -95,8 +111,14 @@ export const selectEvents = createSelector(
|
||||
isActiveTime = features.GRAPH_NONLOCATED
|
||||
? (!event.latitude && !event.longitude) || isActiveTime
|
||||
: isActiveTime;
|
||||
if (isActiveTime && isActiveCategory) {
|
||||
if (event.type === TIMELINE_ONLY || isActiveFilter) {
|
||||
const isActiveShape =
|
||||
event.shape && activeShapes.includes(event.shape.id);
|
||||
if (event.type === SHAPE) {
|
||||
if (isActiveShape && isActiveCategory && isActiveTime) {
|
||||
acc[event.id] = { ...event };
|
||||
}
|
||||
} else {
|
||||
if (isActiveFilter && isActiveCategory && isActiveTime) {
|
||||
acc[event.id] = { ...event };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ const initial = {
|
||||
associations: [],
|
||||
sources: {},
|
||||
sites: [],
|
||||
shapes: [],
|
||||
regions: [],
|
||||
notifications: [],
|
||||
},
|
||||
|
||||
@@ -63,6 +65,7 @@ const initial = {
|
||||
sites: true,
|
||||
},
|
||||
},
|
||||
shapes: [],
|
||||
isMobile: /Mobi/.test(navigator.userAgent),
|
||||
language: "en-US",
|
||||
cluster: {
|
||||
@@ -126,6 +129,12 @@ const initial = {
|
||||
title: copy[language].toolbar.explore_by_narrative__title,
|
||||
description: copy[language].toolbar.explore_by_narrative__description,
|
||||
},
|
||||
shapes: {
|
||||
icon: DEFAULT_TAB_ICONS.SHAPE,
|
||||
label: copy[language].toolbar.shapes_label,
|
||||
title: copy[language].toolbar.explore_by_shape__title,
|
||||
description: copy[language].toolbar.explore_by_shape__description,
|
||||
},
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
@@ -149,7 +158,7 @@ const initial = {
|
||||
strokeWidth: 3,
|
||||
},
|
||||
},
|
||||
shapes: {
|
||||
regions: {
|
||||
default: {
|
||||
stroke: "blue",
|
||||
strokeWidth: 3,
|
||||
@@ -182,7 +191,7 @@ const initial = {
|
||||
USE_ASSOCIATIONS: false,
|
||||
USE_SITES: false,
|
||||
USE_SOURCES: false,
|
||||
USE_SHAPES: false,
|
||||
USE_REGIONS: false,
|
||||
GRAPH_NONLOCATED: false,
|
||||
HIGHLIGHT_GROUPS: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user