mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 12:58:35 +03:00
Merge pull request #206 from forensic-architecture/bugfix/modify-filters-to-use-id-not-title
Modify filters logic to use association ID's and not titles
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import moment from "moment";
|
||||
import hash from "object-hash";
|
||||
|
||||
import { ASSOCIATION_MODES } from "./constants";
|
||||
|
||||
let { DATE_FMT, TIME_FMT } = process.env;
|
||||
if (!DATE_FMT) DATE_FMT = "MM/DD/YYYY";
|
||||
if (!TIME_FMT) TIME_FMT = "HH:mm";
|
||||
|
||||
export const language = process.env.store.app.language || "en-US";
|
||||
|
||||
export function getPathLeaf(path) {
|
||||
const splitPath = path.split("/");
|
||||
return splitPath[splitPath.length - 1];
|
||||
}
|
||||
|
||||
export function calcDatetime(date, time) {
|
||||
if (!time) time = "00:00";
|
||||
const dt = moment(`${date} ${time}`, `${DATE_FMT} ${TIME_FMT}`);
|
||||
@@ -92,6 +99,33 @@ export function trimAndEllipse(string, stringNum) {
|
||||
return string;
|
||||
}
|
||||
|
||||
export function aggregateFilterPaths(filters) {
|
||||
function insertPath(
|
||||
children = {},
|
||||
[headOfPath, ...remainder],
|
||||
accumulatedPath
|
||||
) {
|
||||
const childKey = Object.keys(children).find((path) => {
|
||||
const pathLeaf = getPathLeaf(path);
|
||||
return pathLeaf === headOfPath;
|
||||
});
|
||||
accumulatedPath.push(headOfPath);
|
||||
const accumulatedPlusHead = accumulatedPath.join("/");
|
||||
if (!childKey) children[accumulatedPlusHead] = {};
|
||||
if (remainder.length > 0)
|
||||
insertPath(children[accumulatedPlusHead], remainder, accumulatedPath);
|
||||
return children;
|
||||
}
|
||||
|
||||
const allPaths = [];
|
||||
filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths));
|
||||
const aggregatedPaths = allPaths.reduce(
|
||||
(children, path) => insertPath(children, path, []),
|
||||
{}
|
||||
);
|
||||
return aggregatedPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the set of associations, grab a given filter's set of parents,
|
||||
* ie. all the elements in the path array before the idx where the filter is located.
|
||||
@@ -100,71 +134,81 @@ export function trimAndEllipse(string, stringNum) {
|
||||
*
|
||||
* Returns the list of parents: ex. ['Chemical', 'Tear Gas', ...]
|
||||
*/
|
||||
export function getFilterParents(associations, filter) {
|
||||
for (const a of associations) {
|
||||
const { filter_paths: fp } = a;
|
||||
if (a.id === filter) {
|
||||
return fp.slice(0, fp.length - 1);
|
||||
}
|
||||
const filterIndex = fp.indexOf(filter);
|
||||
if (filterIndex === 0) return [];
|
||||
if (filterIndex > 0) return fp.slice(0, filterIndex);
|
||||
}
|
||||
throw new Error("Attempted to get parents of nonexistent filter");
|
||||
export function getFilterAncestors(filter) {
|
||||
const splitFilter = filter.split("/");
|
||||
const ancestors = [];
|
||||
splitFilter.forEach((f, index) => {
|
||||
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
|
||||
ancestors.pop();
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the second to last element in the paths array for a given existing filter.
|
||||
* This is the filter's most immediate ancestor.
|
||||
*/
|
||||
export function getImmediateFilterParent(associations, filter) {
|
||||
const parents = getFilterParents(associations, filter);
|
||||
if (parents.length === 0) return null;
|
||||
return parents[parents.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab a meta filter's siblings, by way of the the `filter_path` hierarcy.
|
||||
*/
|
||||
export function getMetaFilterSiblings(allFilters, filterParent, filterKey) {
|
||||
const idxParent = allFilters
|
||||
.map((f) => {
|
||||
return f.filter_paths.reduceRight((acc, path, idx) => {
|
||||
if (path === filterParent) return f.filter_paths[idx + 1];
|
||||
return acc;
|
||||
}, null);
|
||||
})
|
||||
.filter((metaFilter) => !!metaFilter && metaFilter !== filterKey);
|
||||
return [...new Set(idxParent)];
|
||||
export function getImmediateFilterParent(filter) {
|
||||
const ancestors = getFilterAncestors(filter);
|
||||
return ancestors[ancestors.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs a given filter's siblings: the set of associations that share the same immediate filter parent.
|
||||
*/
|
||||
export function getFilterSiblings(allFilters, filterParent, filterKey) {
|
||||
const isMetaFilter = !allFilters.map((filt) => filt.id).includes(filterKey);
|
||||
|
||||
if (isMetaFilter) {
|
||||
return getMetaFilterSiblings(allFilters, filterParent, filterKey);
|
||||
function findSiblings(filterPathObj, ancestors) {
|
||||
if (ancestors.length === 0 || filterPathObj === {}) return {};
|
||||
const nextAncestor = ancestors.shift();
|
||||
if (Object.keys(filterPathObj).includes(nextAncestor)) {
|
||||
const nextObjToSearch = filterPathObj[nextAncestor];
|
||||
if (ancestors.length === 0) {
|
||||
return nextObjToSearch;
|
||||
} else {
|
||||
return findSiblings(nextObjToSearch, ancestors);
|
||||
}
|
||||
}
|
||||
}
|
||||
const aggregatedFilters = aggregateFilterPaths(allFilters);
|
||||
const ancestors = getFilterAncestors(filterKey);
|
||||
const siblings = findSiblings(aggregatedFilters, ancestors);
|
||||
return Object.keys(siblings).filter((sib) => sib !== filterKey);
|
||||
}
|
||||
|
||||
return allFilters.reduce((acc, val) => {
|
||||
const valParent = getImmediateFilterParent(allFilters, val.id);
|
||||
if (valParent === filterParent && val.id !== filterKey) acc.push(val.id);
|
||||
export function addToColoringSet(coloringSet, filters) {
|
||||
const flattenedColoringSet = coloringSet.flatMap((f) => f);
|
||||
const newColoringSet = filters.filter(
|
||||
(k) => flattenedColoringSet.indexOf(k) === -1
|
||||
);
|
||||
return [...coloringSet, newColoringSet];
|
||||
}
|
||||
|
||||
export function removeFromColoringSet(coloringSet, filters) {
|
||||
const newColoringSets = coloringSet.map((set) =>
|
||||
set.filter((s) => {
|
||||
return !filters.includes(s);
|
||||
})
|
||||
);
|
||||
return newColoringSets.filter((item) => item.length !== 0);
|
||||
}
|
||||
|
||||
export function getEventCategories(event, activeCategories) {
|
||||
const eventCats = event.associations.filter(
|
||||
(a) => a.mode === ASSOCIATION_MODES.CATEGORY
|
||||
);
|
||||
return eventCats.reduce((acc, val) => {
|
||||
const activeCatTitle = activeCategories.find((cat) => cat === val.title);
|
||||
if (activeCatTitle) acc.push(activeCatTitle);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function getEventCategories(event, categories) {
|
||||
const matchedCategories = [];
|
||||
if (event.associations && event.associations.length > 0) {
|
||||
event.associations.reduce((acc, val) => {
|
||||
const foundCategory = categories.find((cat) => cat.title === val);
|
||||
if (foundCategory) acc.push(foundCategory);
|
||||
return acc;
|
||||
}, matchedCategories);
|
||||
}
|
||||
return matchedCategories;
|
||||
export function createFilterPathString(filter) {
|
||||
return filter.mode === ASSOCIATION_MODES.FILTER
|
||||
? filter.filter_paths.join("/")
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,8 +344,9 @@ export function calcClusterSize(pointCount, totalPoints) {
|
||||
/* The larger the cluster size, the higher the count of points that the cluster represents.
|
||||
Just like with opacity, we use a multiplication factor to ensure that clusters with higher point
|
||||
counts appear larger. */
|
||||
const maxSize = totalPoints > 60 ? 40 : 20;
|
||||
return Math.min(maxSize, 10 + (pointCount / totalPoints) * 150);
|
||||
//TO-DO: Convert maxSize into a config var
|
||||
const maxSize = totalPoints > 60 ? 60 : 35;
|
||||
return Math.min(maxSize, 10 + (pointCount / totalPoints) * 100);
|
||||
}
|
||||
|
||||
export function calculateTotalClusterPoints(clusters) {
|
||||
@@ -357,7 +402,7 @@ export function calculateColorPercentages(set, coloringSet) {
|
||||
|
||||
innerSet.forEach((val) => {
|
||||
val.associations.forEach((a) => {
|
||||
const idx = associationMap[a];
|
||||
const idx = associationMap[createFilterPathString(a)];
|
||||
if (!idx && idx !== 0) return;
|
||||
associationCounts[idx] += 1;
|
||||
totalAssociations += 1;
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
trimAndEllipse,
|
||||
getImmediateFilterParent,
|
||||
getFilterSiblings,
|
||||
getFilterParents,
|
||||
getFilterAncestors,
|
||||
addToColoringSet,
|
||||
removeFromColoringSet,
|
||||
} from "../common/utilities.js";
|
||||
|
||||
class Toolbar extends React.Component {
|
||||
@@ -31,32 +33,15 @@ class Toolbar extends React.Component {
|
||||
onSelectFilter(key, matchingKeys) {
|
||||
const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props;
|
||||
|
||||
const parent = getImmediateFilterParent(filters, key);
|
||||
const parent = getImmediateFilterParent(key);
|
||||
const isTurningOff = activeFilters.includes(key);
|
||||
|
||||
if (!isTurningOff) {
|
||||
const flattenedColoringSet = coloringSet.flatMap((f) => f);
|
||||
const newColoringSet = matchingKeys.filter(
|
||||
(k) => flattenedColoringSet.indexOf(k) === -1
|
||||
);
|
||||
|
||||
const updatedColoringSet = [...coloringSet, newColoringSet];
|
||||
|
||||
const updatedColoringSet = addToColoringSet(coloringSet, matchingKeys);
|
||||
if (updatedColoringSet.length <= maxNumOfColors) {
|
||||
this.props.actions.updateColoringSet(updatedColoringSet);
|
||||
}
|
||||
} else {
|
||||
const newColoringSets = coloringSet.map((set) =>
|
||||
set.filter((s) => {
|
||||
return !matchingKeys.includes(s);
|
||||
})
|
||||
);
|
||||
this.props.actions.updateColoringSet(
|
||||
newColoringSets.filter((item) => item.length !== 0)
|
||||
);
|
||||
}
|
||||
|
||||
if (isTurningOff) {
|
||||
if (parent && activeFilters.includes(parent)) {
|
||||
const siblings = getFilterSiblings(filters, parent, key);
|
||||
let siblingsOff = true;
|
||||
@@ -68,12 +53,18 @@ class Toolbar extends React.Component {
|
||||
}
|
||||
|
||||
if (siblingsOff) {
|
||||
const grandparentsOn = getFilterParents(filters, key).filter((filt) =>
|
||||
const grandparentsOn = getFilterAncestors(key).filter((filt) =>
|
||||
activeFilters.includes(filt)
|
||||
);
|
||||
matchingKeys = matchingKeys.concat(grandparentsOn);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedColoringSet = removeFromColoringSet(
|
||||
coloringSet,
|
||||
matchingKeys
|
||||
);
|
||||
this.props.actions.updateColoringSet(updatedColoringSet);
|
||||
}
|
||||
this.props.methods.onSelectFilter(matchingKeys);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function ColoredMarkers({ radius, colorPercentMap, styles, className }) {
|
||||
|
||||
return (
|
||||
<path
|
||||
class={className}
|
||||
className={className}
|
||||
id={`arc_${idx}`}
|
||||
d={arc}
|
||||
style={extraStyles}
|
||||
|
||||
@@ -2,12 +2,15 @@ import React from "react";
|
||||
import Checkbox from "../atoms/Checkbox";
|
||||
import marked from "marked";
|
||||
import copy from "../../common/data/copy.json";
|
||||
import { getFilterIdxFromColorSet } from "../../common/utilities";
|
||||
import {
|
||||
aggregateFilterPaths,
|
||||
getFilterIdxFromColorSet,
|
||||
getPathLeaf,
|
||||
} from "../../common/utilities";
|
||||
|
||||
/** recursively get an array of node keys to toggle */
|
||||
function getFiltersToToggle(filter, activeFilters) {
|
||||
const [key, children] = filter;
|
||||
|
||||
// base case: no children to recurse through
|
||||
if (children === {}) return [key];
|
||||
|
||||
@@ -20,24 +23,6 @@ function getFiltersToToggle(filter, activeFilters) {
|
||||
return childKeys;
|
||||
}
|
||||
|
||||
function aggregatePaths(filters) {
|
||||
function insertPath(children = {}, [headOfPath, ...remainder]) {
|
||||
const childKey = Object.keys(children).find((key) => key === headOfPath);
|
||||
if (!childKey) children[headOfPath] = {};
|
||||
if (remainder.length > 0) insertPath(children[headOfPath], remainder);
|
||||
return children;
|
||||
}
|
||||
|
||||
const allPaths = [];
|
||||
filters.forEach((filterItem) => allPaths.push(filterItem.filter_paths));
|
||||
|
||||
const aggregatedPaths = allPaths.reduce(
|
||||
(children, path) => insertPath(children, path),
|
||||
{}
|
||||
);
|
||||
return aggregatedPaths;
|
||||
}
|
||||
|
||||
function FilterListPanel({
|
||||
filters,
|
||||
activeFilters,
|
||||
@@ -48,6 +33,7 @@ function FilterListPanel({
|
||||
}) {
|
||||
function createNodeComponent(filter, depth) {
|
||||
const [key, children] = filter;
|
||||
const pathLeaf = getPathLeaf(key);
|
||||
const matchingKeys = getFiltersToToggle(filter, activeFilters);
|
||||
const idxFromColorSet = getFilterIdxFromColorSet(key, coloringSet);
|
||||
const assignedColor =
|
||||
@@ -62,12 +48,12 @@ function FilterListPanel({
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key.replace(/ /g, "_")}
|
||||
key={pathLeaf.replace(/ /g, "_")}
|
||||
className="filter-filter"
|
||||
style={{ ...styles }}
|
||||
>
|
||||
<Checkbox
|
||||
label={key}
|
||||
label={pathLeaf}
|
||||
isActive={activeFilters.includes(key)}
|
||||
onClickCheckbox={() => onSelectFilter(key, matchingKeys)}
|
||||
color={assignedColor}
|
||||
@@ -82,7 +68,7 @@ function FilterListPanel({
|
||||
}
|
||||
|
||||
function renderTree(filters) {
|
||||
const aggregatedFilterPaths = aggregatePaths(filters);
|
||||
const aggregatedFilterPaths = aggregateFilterPaths(filters);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -110,19 +110,19 @@ function ClusterEvents({
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
text-anchor="middle"
|
||||
textAnchor="middle"
|
||||
y="3px"
|
||||
style={{ fontWeight: "bold", fill: "black", zIndex: 10000 }}
|
||||
>
|
||||
{txt}
|
||||
</text>
|
||||
<circle
|
||||
class="event-hover"
|
||||
className="event-hover"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r={circleSize + 2}
|
||||
stroke={colors.primaryHighlight}
|
||||
fill-opacity="0.0"
|
||||
fillOpacity="0.0"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -35,12 +35,12 @@ function MapEvents({
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
class="event-hover"
|
||||
className="event-hover"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="10"
|
||||
stroke={colors.primaryHighlight}
|
||||
fill-opacity="0.0"
|
||||
fillOpacity="0.0"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -49,14 +49,13 @@ class Timeline extends React.Component {
|
||||
}
|
||||
|
||||
if (
|
||||
hash(nextProps.domain.categories) !==
|
||||
hash(this.props.domain.categories) ||
|
||||
hash(nextProps.activeCategories) !== hash(this.props.activeCategories) ||
|
||||
hash(nextProps.dimensions) !== hash(this.props.dimensions)
|
||||
) {
|
||||
const { trackHeight, marginTop } = nextProps.dimensions;
|
||||
this.setState({
|
||||
scaleY: this.makeScaleY(
|
||||
nextProps.domain.categories,
|
||||
nextProps.activeCategories,
|
||||
trackHeight,
|
||||
marginTop
|
||||
),
|
||||
@@ -99,6 +98,7 @@ class Timeline extends React.Component {
|
||||
(cat) => !features.GRAPH_NONLOCATED.categories.includes(cat.title)
|
||||
);
|
||||
}
|
||||
|
||||
const extraPadding = 0;
|
||||
const catHeight =
|
||||
categories.length > 2
|
||||
@@ -107,10 +107,9 @@ class Timeline extends React.Component {
|
||||
const catsYpos = categories.map((g, i) => {
|
||||
return (i + 1) * catHeight + marginTop + extraPadding / 2;
|
||||
});
|
||||
const catMap = categories.map((c) => c.title);
|
||||
|
||||
return (cat) => {
|
||||
const idx = catMap.indexOf(cat);
|
||||
const idx = categories.indexOf(cat);
|
||||
return catsYpos[idx];
|
||||
};
|
||||
}
|
||||
@@ -302,13 +301,11 @@ class Timeline extends React.Component {
|
||||
}
|
||||
|
||||
getY(event) {
|
||||
const { features, domain } = this.props;
|
||||
const { features, domain, activeCategories } = this.props;
|
||||
const { USE_CATEGORIES, GRAPH_NONLOCATED } = features;
|
||||
// Categories represent active categories here
|
||||
const { categories } = domain;
|
||||
|
||||
const categoriesExist =
|
||||
USE_CATEGORIES && categories && categories.length > 0;
|
||||
USE_CATEGORIES && activeCategories && activeCategories.length > 0;
|
||||
|
||||
if (!categoriesExist) {
|
||||
return this.state.dims.trackHeight / 2;
|
||||
@@ -351,7 +348,8 @@ class Timeline extends React.Component {
|
||||
const heightStyle = { height: dims.height };
|
||||
const extraStyle = { ...heightStyle, ...foldedStyle };
|
||||
const contentHeight = { height: dims.contentHeight };
|
||||
const { categories } = this.props.domain;
|
||||
const { activeCategories: categories } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
@@ -396,7 +394,7 @@ class Timeline extends React.Component {
|
||||
onDragEnd={() => {
|
||||
this.onDragEnd();
|
||||
}}
|
||||
categories={categories.map((c) => c.title)}
|
||||
categories={categories}
|
||||
features={this.props.features}
|
||||
fallbackLabel={
|
||||
copy[this.props.app.language].timeline
|
||||
@@ -463,14 +461,10 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
dimensions: selectors.selectDimensions(state),
|
||||
isNarrative: !!state.app.associations.narrative,
|
||||
activeCategories: selectors.getActiveCategories(state),
|
||||
domain: {
|
||||
events: selectors.selectStackedEvents(state),
|
||||
projects: selectors.selectProjects(state),
|
||||
categories: ((state) => {
|
||||
const allcats = selectors.getCategories(state);
|
||||
const active = selectors.getActiveCategories(state);
|
||||
return allcats.filter((c) => active.includes(c.title));
|
||||
})(state),
|
||||
narratives: state.domain.narratives,
|
||||
},
|
||||
app: {
|
||||
|
||||
@@ -134,7 +134,7 @@ const TimelineEvents = ({
|
||||
// those timelines: so we create as many event 'shadows' as there are
|
||||
// categories
|
||||
const evShadows = getEventCategories(event, categories).map((cat) => {
|
||||
const y = getY({ ...event, category: cat.title });
|
||||
const y = getY({ ...event, category: cat });
|
||||
|
||||
const colour = event.colour ? event.colour : getCategoryColor(cat.title);
|
||||
const styles = {
|
||||
|
||||
@@ -64,8 +64,9 @@ const TimelineMarkers = ({
|
||||
const isDot =
|
||||
(isLatitude(event.latitude) && isLongitude(event.longitude)) ||
|
||||
(features.GRAPH_NONLOCATED && event.projectOffset !== -1);
|
||||
|
||||
const evShadows = getEventCategories(event, categories).map((cat) =>
|
||||
getEventY({ ...event, category: cat.title })
|
||||
getEventY({ ...event, category: cat })
|
||||
);
|
||||
|
||||
function renderMarkerForEvent(y) {
|
||||
|
||||
@@ -137,6 +137,14 @@ export function validateDomain(domain, features) {
|
||||
// append events with datetime and sort
|
||||
sanitizedDomain.events = sanitizedDomain.events.filter((event, idx) => {
|
||||
event.id = idx;
|
||||
// event.associations comes in as a [association.ids...]; convert to actual association objects
|
||||
event.associations = event.associations.reduce((acc, id) => {
|
||||
const foundAssociation = sanitizedDomain.associations.find(
|
||||
(elem) => elem.id === id
|
||||
);
|
||||
if (foundAssociation) acc.push(foundAssociation);
|
||||
return acc;
|
||||
}, []);
|
||||
// if lat, long come in with commas, replace with decimal format
|
||||
event.latitude = event.latitude.replace(",", ".");
|
||||
event.longitude = event.longitude.replace(",", ".");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
dateMax,
|
||||
isLatitude,
|
||||
isLongitude,
|
||||
createFilterPathString,
|
||||
} from "../common/utilities";
|
||||
import { isTimeRangedIn } from "./helpers";
|
||||
import { ASSOCIATION_MODES } from "../common/constants";
|
||||
@@ -76,14 +77,18 @@ export const selectEvents = createSelector(
|
||||
const isMatchingFilter =
|
||||
(event.associations &&
|
||||
event.associations
|
||||
.map((association) => activeFilters.includes(association))
|
||||
.filter((a) => a.mode === ASSOCIATION_MODES.FILTER)
|
||||
.map((association) =>
|
||||
activeFilters.includes(createFilterPathString(association))
|
||||
)
|
||||
.some((s) => s)) ||
|
||||
activeFilters.length === 0;
|
||||
const isActiveFilter = isMatchingFilter || activeFilters.length === 0;
|
||||
const isActiveCategory =
|
||||
(event.associations &&
|
||||
event.associations
|
||||
.map((association) => activeCategories.includes(association))
|
||||
.filter((a) => a.mode === ASSOCIATION_MODES.CATEGORY)
|
||||
.map((association) => activeCategories.includes(association.title))
|
||||
.some((s) => s)) ||
|
||||
activeCategories.length === 0;
|
||||
let isActiveTime = isTimeRangedIn(event, timeRange);
|
||||
|
||||
Reference in New Issue
Block a user