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:
Ebrahem Farooqui
2021-05-11 21:27:19 -07:00
committed by GitHub
11 changed files with 150 additions and 120 deletions

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ function ColoredMarkers({ radius, colorPercentMap, styles, className }) {
return (
<path
class={className}
className={className}
id={`arc_${idx}`}
d={arc}
style={extraStyles}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(",", ".");

View File

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