Merge pull request #210 from forensic-architecture/feature/add-shapes-filter-panel

Feature/add shapes filter panel
This commit is contained in:
Ebrahem Farooqui
2021-05-24 22:45:50 -07:00
committed by GitHub
24 changed files with 400 additions and 149 deletions

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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,
}}
/>

View File

@@ -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),
};
}

View File

@@ -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>
);

View File

@@ -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>
);
};

View 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;

View 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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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})`}

View File

@@ -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;

View File

@@ -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:

View File

@@ -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:

View 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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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 };
}
}

View File

@@ -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,
},