mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-08 03:18:36 +03:00
Feature/add coloring algorithm (#169)
* Fixed bug: when all child filters unselected, turn off parent as well * Refactored placement of onSelectFilter to be in Layout; working logic for updating coloring sets * Linting fixes and removal of console logs * Added separate component for colored markers which clusters and events will use; working calculation of color percentages based off of coloringset * Working colors for clusters; need to implement for individual points as well * Adding two new features to select whether to color by association or by category (can't do both) * Working colors for filter list panel; text and checkbox change according to colorset groupings * Working timeline events with coloring algorithm * Handle select acts different on map when we don't render all points and only filter through clusters; can fix this by not filtering before passing in locations to events in map * Removed extraneous prop * Working point count on hover again; numbers were showing up below the colored markers * Linting fixes and minor refactor of calculateColorPercentage for linting to ass * Comments and more linting fixes * add dev command for windows subsystem for linux * return default styles for category toggles * dynamically filter out timelines * calibrate styling * further calibrations * correct contrast * lint Co-authored-by: efarooqui <efarooqui@pandora.com> Co-authored-by: Lachlan Kermode <lachiekermode@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "webpack-dev-server --content-base static --mode development",
|
||||
"dev:wsl": "npm run dev -- --host 0.0.0.0",
|
||||
"build": "NODE_ENV=production webpack --mode production",
|
||||
"test": "ava --verbose",
|
||||
"test-watch": "ava --watch",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { urlFromEnv } from '../common/utilities'
|
||||
// TODO: relegate these URLs entirely to environment variables
|
||||
// const CONFIG_URL = urlFromEnv('CONFIG_EXT')
|
||||
const EVENT_DATA_URL = urlFromEnv('EVENTS_EXT')
|
||||
// const CATEGORY_URL = urlFromEnv('CATEGORIES_EXT')
|
||||
const ASSOCIATIONS_URL = urlFromEnv('ASSOCIATIONS_EXT')
|
||||
const SOURCES_URL = urlFromEnv('SOURCES_EXT')
|
||||
const SITES_URL = urlFromEnv('SITES_EXT')
|
||||
@@ -181,12 +180,13 @@ export function clearFilter (filter) {
|
||||
}
|
||||
}
|
||||
|
||||
export const TOGGLE_FILTER = 'TOGGLE_FILTER'
|
||||
export function toggleFilter (filter, value) {
|
||||
export const TOGGLE_ASSOCIATIONS = 'TOGGLE_ASSOCIATIONS'
|
||||
export function toggleAssociations (association, value, shouldColor) {
|
||||
return {
|
||||
type: TOGGLE_FILTER,
|
||||
filter,
|
||||
value
|
||||
type: TOGGLE_ASSOCIATIONS,
|
||||
association,
|
||||
value,
|
||||
shouldColor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,14 @@ export function updateSource (source) {
|
||||
}
|
||||
}
|
||||
|
||||
export const UPDATE_COLORING_SET = 'UPDATE_COLORING_SET'
|
||||
export function updateColoringSet (coloringSet) {
|
||||
return {
|
||||
type: UPDATE_COLORING_SET,
|
||||
coloringSet
|
||||
}
|
||||
}
|
||||
|
||||
// UI
|
||||
|
||||
export const TOGGLE_SITES = 'TOGGLE_SITES'
|
||||
|
||||
@@ -11,6 +11,28 @@ export function calcDatetime (date, time) {
|
||||
return dt.toDate()
|
||||
}
|
||||
|
||||
export function getCoordinatesForPercent (radius, percent) {
|
||||
const x = radius * Math.cos(2 * Math.PI * percent)
|
||||
const y = radius * Math.sin(2 * Math.PI * percent)
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes the array of percentages: [0.5, 0.5, ...]
|
||||
* and maps it by index to the set of colors ['#fff', '#000', ...]
|
||||
* If there aren't enough colors in the set, it raises an error for the user
|
||||
*
|
||||
* Return value:
|
||||
* ex. {'#fff': 0.5, '#000': 0.5, ...} */
|
||||
export function zipColorsToPercentages (colors, percentages) {
|
||||
if (colors.length < percentages.length) throw new Error('You must declare an appropriate number of filter colors')
|
||||
|
||||
return percentages.reduce((map, percent, idx) => {
|
||||
map[colors[idx]] = percent
|
||||
return map
|
||||
}, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URI params to start with predefined set of
|
||||
* https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
|
||||
@@ -63,6 +85,48 @@ export function trimAndEllipse (string, stringNum) {
|
||||
return string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* If we can't find the filter by the ID, we know its a meta filter, so we look
|
||||
* through every association's given path attribute to find its location.
|
||||
*
|
||||
* Returns the list of parents: ex. ['Chemical', 'Tear Gas', ...]
|
||||
*/
|
||||
export function getFilterParents (associations, filter) {
|
||||
for (let 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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs a given filter's siblings: the set of associations that share the same immediate filter parent.
|
||||
*/
|
||||
export function getFilterSiblings (allFilters, filterParent, filterKey) {
|
||||
return allFilters.reduce((acc, val) => {
|
||||
const valParent = getImmediateFilterParent(allFilters, val.id)
|
||||
if (valParent === filterParent && val.id !== filterKey) acc.push(val.id)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function getEventCategories (event, categories) {
|
||||
const matchedCategories = []
|
||||
if (event.associations && event.associations.length > 0) {
|
||||
@@ -180,7 +244,7 @@ export function calcOpacity (num) {
|
||||
* other events there are in the same render. The idea here is that the
|
||||
* overlaying of events builds up a 'heat map' of the event space, where
|
||||
* darker areas represent more events with proportion */
|
||||
const base = num >= 1 ? 0.6 : 0
|
||||
const base = num >= 1 ? 0.9 : 0
|
||||
return base + (Math.min(0.5, 0.08 * (num - 1)))
|
||||
}
|
||||
|
||||
@@ -188,14 +252,16 @@ export function calcClusterOpacity (pointCount, totalPoints) {
|
||||
/* Clusters represent multiple events within a specific radius. The darker the cluster,
|
||||
the larger the number of underlying events. We use a multiplication factor (50) here as well
|
||||
to ensure that the larger clusters have an appropriately darker shading. */
|
||||
return Math.min(0.85, 0.08 + (pointCount / totalPoints) * 50)
|
||||
const base = 0.5
|
||||
return base + Math.min(0.95, 0.08 + (pointCount / totalPoints) * 50)
|
||||
}
|
||||
|
||||
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. */
|
||||
return Math.min(50, 10 + (pointCount / totalPoints) * 150)
|
||||
const maxSize = totalPoints > 60 ? 40 : 20
|
||||
return Math.min(maxSize, 10 + (pointCount / totalPoints) * 150)
|
||||
}
|
||||
|
||||
export function isLatitude (lat) {
|
||||
@@ -214,6 +280,59 @@ export function mapClustersToLocations (clusters, locations) {
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops through a set of either locations or events
|
||||
* and calculates the proportionate percentage of every given association in relation to the coloring set
|
||||
*/
|
||||
export function calculateColorPercentages (set, coloringSet) {
|
||||
if (coloringSet.length === 0) return [1]
|
||||
const associationMap = {}
|
||||
|
||||
for (const [idx, value] of coloringSet.entries()) {
|
||||
for (let filter of value) {
|
||||
associationMap[filter] = idx
|
||||
}
|
||||
}
|
||||
|
||||
const associationCounts = new Array(coloringSet.length)
|
||||
associationCounts.fill(0)
|
||||
|
||||
let totalAssociations = 0
|
||||
|
||||
set.forEach(item => {
|
||||
let innerSet = 'events' in item ? item.events : item
|
||||
|
||||
if (!Array.isArray(innerSet)) innerSet = [innerSet]
|
||||
|
||||
innerSet.forEach(val => {
|
||||
val.associations.forEach(a => {
|
||||
const idx = associationMap[a]
|
||||
if (!idx && idx !== 0) return
|
||||
associationCounts[idx] += 1
|
||||
totalAssociations += 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (totalAssociations === 0) return [1]
|
||||
|
||||
return associationCounts.map(count => count / totalAssociations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the idx of a given filter in relation to its position in the coloring set
|
||||
*
|
||||
* Example coloringSet = [['Chemical', 'Tear Gas'], ['Procedural', 'Destruction of property']]
|
||||
*/
|
||||
export function getFilterIdxFromColorSet (filter, coloringSet) {
|
||||
let filterIdx = -1
|
||||
coloringSet.map((set, idx) => {
|
||||
const foundIdx = set.indexOf(filter)
|
||||
if (foundIdx !== -1) filterIdx = idx
|
||||
})
|
||||
return filterIdx
|
||||
}
|
||||
|
||||
export const dateMin = function () {
|
||||
return Array.prototype.slice.call(arguments).reduce(function (a, b) {
|
||||
return a < b ? a : b
|
||||
|
||||
@@ -264,8 +264,8 @@ class Dashboard extends React.Component {
|
||||
isNarrative={!!app.associations.narrative}
|
||||
methods={{
|
||||
onTitle: actions.toggleCover,
|
||||
onSelectFilter: filter => actions.toggleFilter('filters', filter),
|
||||
onCategoryFilter: category => actions.toggleFilter('categories', category),
|
||||
onSelectFilter: filters => actions.toggleAssociations('filters', filters),
|
||||
onCategoryFilter: categories => actions.toggleAssociations('categories', categories),
|
||||
onSelectNarrative: this.setNarrative
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,7 @@ class Map extends React.Component {
|
||||
this.projectPoint = this.projectPoint.bind(this)
|
||||
this.onClusterSelect = this.onClusterSelect.bind(this)
|
||||
this.loadClusterData = this.loadClusterData.bind(this)
|
||||
this.getClusterChildren = this.getClusterChildren.bind(this)
|
||||
this.svgRef = React.createRef()
|
||||
this.map = null
|
||||
this.superclusterIndex = null
|
||||
@@ -171,6 +172,18 @@ class Map extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
getClusterChildren (clusterId) {
|
||||
if (this.superclusterIndex) {
|
||||
try {
|
||||
const children = this.superclusterIndex.getLeaves(clusterId, Infinity, 0)
|
||||
return mapClustersToLocations(children, this.props.domain.locations)
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
alignLayers () {
|
||||
const mapNode = document.querySelector('.leaflet-map-pane')
|
||||
if (mapNode === null) return { transformX: 0, transformY: 0 }
|
||||
@@ -281,6 +294,11 @@ class Map extends React.Component {
|
||||
}
|
||||
|
||||
renderEvents () {
|
||||
/*
|
||||
Uncomment below to filter out the locations already present in a cluster.
|
||||
Leaving these lines commented out renders all the locations on the map, regardless of whether or not they are clustered
|
||||
*/
|
||||
|
||||
const individualClusters = this.state.clusters.filter(cl => !cl.properties.cluster)
|
||||
const filteredLocations = mapClustersToLocations(individualClusters, this.props.domain.locations)
|
||||
return (
|
||||
@@ -288,6 +306,7 @@ class Map extends React.Component {
|
||||
svg={this.svgRef.current}
|
||||
events={this.props.domain.events}
|
||||
locations={filteredLocations}
|
||||
// locations={this.props.domain.locations}
|
||||
styleLocation={this.styleLocation}
|
||||
categories={this.props.domain.categories}
|
||||
projectPoint={this.projectPoint}
|
||||
@@ -296,6 +315,9 @@ class Map extends React.Component {
|
||||
onSelect={this.props.methods.onSelect}
|
||||
getCategoryColor={this.props.methods.getCategoryColor}
|
||||
eventRadius={this.props.ui.eventRadius}
|
||||
coloringSet={this.props.app.coloringSet}
|
||||
filterColors={this.props.ui.filterColors}
|
||||
features={this.props.features}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -310,6 +332,9 @@ class Map extends React.Component {
|
||||
clusters={allClusters}
|
||||
isRadial={this.props.ui.radial}
|
||||
onSelect={this.onClusterSelect}
|
||||
coloringSet={this.props.app.coloringSet}
|
||||
getClusterChildren={this.getClusterChildren}
|
||||
filterColors={this.props.ui.filterColors}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -384,6 +409,7 @@ function mapStateToProps (state) {
|
||||
language: state.app.language,
|
||||
loading: state.app.loading,
|
||||
narrative: state.app.associations.narrative,
|
||||
coloringSet: state.app.associations.coloringSet,
|
||||
flags: {
|
||||
isShowingSites: state.app.flags.isShowingSites,
|
||||
isFetchingDomain: state.app.flags.isFetchingDomain
|
||||
@@ -396,7 +422,8 @@ function mapStateToProps (state) {
|
||||
mapSelectedEvents: state.ui.style.selectedEvents,
|
||||
shapes: state.ui.style.shapes,
|
||||
eventRadius: state.ui.eventRadius,
|
||||
radial: state.ui.style.clusters.radial
|
||||
radial: state.ui.style.clusters.radial,
|
||||
filterColors: state.ui.coloring.colors
|
||||
},
|
||||
features: selectors.getFeatures(state)
|
||||
}
|
||||
|
||||
@@ -80,12 +80,10 @@ class Timeline extends React.Component {
|
||||
if (features.GRAPH_NONLOCATED && features.GRAPH_NONLOCATED.categories) {
|
||||
categories = categories.filter(cat => !features.GRAPH_NONLOCATED.categories.includes(cat.id))
|
||||
}
|
||||
const catHeight = trackHeight / (categories.length)
|
||||
const shiftUp = trackHeight / (categories.length) / 3
|
||||
const marginShift = marginTop === 0 ? 0 : marginTop
|
||||
const manualAdjustment = trackHeight <= 60 ? (trackHeight <= 30 ? -8 : -5) : 0
|
||||
const extraPadding = 0
|
||||
const catHeight = categories.length > 2 ? trackHeight / categories.length : trackHeight / (categories.length + 1)
|
||||
const catsYpos = categories.map((g, i) => {
|
||||
return ((i + 1) * catHeight) - shiftUp + marginShift + manualAdjustment
|
||||
return ((i + 1) * catHeight) + marginTop + (extraPadding / 2)
|
||||
})
|
||||
const catMap = categories.map(c => c.id)
|
||||
|
||||
@@ -341,7 +339,7 @@ class Timeline extends React.Component {
|
||||
onDragStart={() => { this.onDragStart() }}
|
||||
onDrag={() => { this.onDrag() }}
|
||||
onDragEnd={() => { this.onDragEnd() }}
|
||||
categories={this.props.app.activeCategories}
|
||||
categories={categories.map(c => c.id)}
|
||||
features={this.props.features}
|
||||
/>
|
||||
<Handles
|
||||
@@ -359,7 +357,7 @@ class Timeline extends React.Component {
|
||||
selected={this.props.app.selected}
|
||||
getEventX={ev => this.getDatetimeX(ev.datetime)}
|
||||
getEventY={this.getY}
|
||||
categories={this.props.domain.categories}
|
||||
categories={categories}
|
||||
transitionDuration={this.state.transitionDuration}
|
||||
styles={this.props.ui.styles}
|
||||
features={this.props.features}
|
||||
@@ -368,7 +366,7 @@ class Timeline extends React.Component {
|
||||
<Events
|
||||
events={this.props.domain.events}
|
||||
projects={this.props.domain.projects}
|
||||
categories={this.props.domain.categories}
|
||||
categories={categories}
|
||||
styleDatetime={this.styleDatetime}
|
||||
narrative={this.props.app.narrative}
|
||||
getDatetimeX={this.getDatetimeX}
|
||||
@@ -387,6 +385,8 @@ class Timeline extends React.Component {
|
||||
setLoading={this.props.actions.setLoading}
|
||||
setNotLoading={this.props.actions.setNotLoading}
|
||||
eventRadius={this.props.ui.eventRadius}
|
||||
filterColors={this.props.ui.filterColors}
|
||||
coloringSet={this.props.app.coloringSet}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -403,20 +403,25 @@ function mapStateToProps (state) {
|
||||
domain: {
|
||||
events: selectors.selectStackedEvents(state),
|
||||
projects: selectors.selectProjects(state),
|
||||
categories: selectors.getCategories(state),
|
||||
categories: (state => {
|
||||
const allcats = selectors.getCategories(state)
|
||||
const active = selectors.getActiveCategories(state)
|
||||
return allcats.filter(c => active.includes(c.id))
|
||||
})(state),
|
||||
narratives: state.domain.narratives
|
||||
},
|
||||
app: {
|
||||
activeCategories: selectors.getActiveCategories(state),
|
||||
selected: state.app.selected,
|
||||
language: state.app.language,
|
||||
timeline: state.app.timeline,
|
||||
narrative: state.app.associations.narrative
|
||||
narrative: state.app.associations.narrative,
|
||||
coloringSet: state.app.associations.coloringSet
|
||||
},
|
||||
ui: {
|
||||
dom: state.ui.dom,
|
||||
styles: state.ui.style.selectedEvents,
|
||||
eventRadius: state.ui.eventRadius
|
||||
eventRadius: state.ui.eventRadius,
|
||||
filterColors: state.ui.coloring.colors
|
||||
},
|
||||
features: selectors.getFeatures(state)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
const TEXT_HEIGHT = 15
|
||||
|
||||
class TimelineAxis extends React.Component {
|
||||
constructor () {
|
||||
super()
|
||||
@@ -27,18 +29,19 @@ class TimelineAxis extends React.Component {
|
||||
fstFmt = '%H:%M'
|
||||
}
|
||||
|
||||
let { marginTop, contentHeight } = this.props.dims
|
||||
if (this.props.scaleX) {
|
||||
this.x0 =
|
||||
d3.axisBottom(this.props.scaleX)
|
||||
.ticks(10)
|
||||
.tickPadding(0)
|
||||
.tickSize(this.props.dims.trackHeight)
|
||||
.tickSize(contentHeight - TEXT_HEIGHT - marginTop)
|
||||
.tickFormat(d3.timeFormat(fstFmt))
|
||||
|
||||
this.x1 =
|
||||
d3.axisBottom(this.props.scaleX)
|
||||
.ticks(10)
|
||||
.tickPadding(this.props.dims.marginTop)
|
||||
.tickPadding(marginTop)
|
||||
.tickSize(0)
|
||||
.tickFormat(d3.timeFormat(sndFmt))
|
||||
|
||||
@@ -59,18 +62,17 @@ class TimelineAxis extends React.Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const PADDING = 20
|
||||
return (
|
||||
<React.Fragment>
|
||||
<g
|
||||
ref={this.xAxis0Ref}
|
||||
transform={`translate(0, ${PADDING})`}
|
||||
transform={`translate(0, ${this.props.dims.marginTop})`}
|
||||
clipPath={`url(#clip)`}
|
||||
className={`axis xAxis`}
|
||||
/>
|
||||
<g
|
||||
ref={this.xAxis1Ref}
|
||||
transform={`translate(0, ${PADDING})`}
|
||||
transform={`translate(0, ${this.props.dims.marginTop})`}
|
||||
clipPath={`url(#clip)`}
|
||||
className={`axis xAxis`}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import Checkbox from '../presentational/Checkbox'
|
||||
import copy from '../../common/data/copy.json'
|
||||
import { getFilterIdxFromColorSet } from '../../common/utilities'
|
||||
import { colors } from '../../common/global'
|
||||
|
||||
/** recursively get an array of node keys to toggle */
|
||||
function childrenToToggle (filter, activeFilters, parentOn) {
|
||||
@@ -38,22 +40,33 @@ function FilterListPanel ({
|
||||
filters,
|
||||
activeFilters,
|
||||
onSelectFilter,
|
||||
language
|
||||
language,
|
||||
coloringSet,
|
||||
filterColors
|
||||
}) {
|
||||
function createNodeComponent (filter, depth) {
|
||||
const [key, children] = filter
|
||||
const matchingKeys = childrenToToggle(filter, activeFilters, activeFilters.includes(key))
|
||||
const idxFromColorSet = getFilterIdxFromColorSet(key, coloringSet)
|
||||
|
||||
const assignedColor = idxFromColorSet !== -1 && activeFilters.includes(key) ? filterColors[idxFromColorSet] : colors.white
|
||||
|
||||
const styles = ({
|
||||
color: assignedColor,
|
||||
marginLeft: `${depth * 20}px`
|
||||
})
|
||||
|
||||
return (
|
||||
<li
|
||||
key={key.replace(/ /g, '_')}
|
||||
className={'filter-filter'}
|
||||
style={{ marginLeft: `${depth * 20}px` }}
|
||||
style={{ ...styles }}
|
||||
>
|
||||
<Checkbox
|
||||
label={key}
|
||||
isActive={activeFilters.includes(key)}
|
||||
onClickCheckbox={() => onSelectFilter(matchingKeys)}
|
||||
onClickCheckbox={() => onSelectFilter(key, matchingKeys)}
|
||||
backgroundColor={assignedColor}
|
||||
/>
|
||||
{Object.keys(children).length > 0
|
||||
? Object.entries(children).map(filter => createNodeComponent(filter, depth + 1))
|
||||
|
||||
@@ -9,11 +9,12 @@ import FilterListPanel from './FilterListPanel'
|
||||
import CategoriesListPanel from './CategoriesListPanel'
|
||||
import BottomActions from './BottomActions'
|
||||
import copy from '../../common/data/copy.json'
|
||||
import { trimAndEllipse } from '../../common/utilities.js'
|
||||
import { trimAndEllipse, getImmediateFilterParent, getFilterSiblings } from '../../common/utilities.js'
|
||||
|
||||
class Toolbar extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
this.onSelectFilter = this.onSelectFilter.bind(this)
|
||||
this.state = { _selected: -1 }
|
||||
}
|
||||
|
||||
@@ -22,6 +23,49 @@ class Toolbar extends React.Component {
|
||||
this.setState({ _selected })
|
||||
}
|
||||
|
||||
onSelectFilter (key, matchingKeys) {
|
||||
const { filters, activeFilters, coloringSet, maxNumOfColors } = this.props
|
||||
|
||||
const parent = getImmediateFilterParent(filters, 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]
|
||||
|
||||
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 (parent) {
|
||||
const parentOn = activeFilters.includes(parent)
|
||||
if (parentOn) {
|
||||
const siblings = getFilterSiblings(filters, parent, key)
|
||||
let siblingsOff = true
|
||||
for (let sibling of siblings) {
|
||||
if (activeFilters.includes(sibling)) {
|
||||
siblingsOff = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (siblingsOff && isTurningOff) {
|
||||
matchingKeys.push(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.props.methods.onSelectFilter(matchingKeys)
|
||||
}
|
||||
|
||||
renderClosePanel () {
|
||||
return (
|
||||
<div className='panel-header' onClick={() => this.selectTab(-1)}>
|
||||
@@ -75,8 +119,10 @@ class Toolbar extends React.Component {
|
||||
<FilterListPanel
|
||||
filters={this.props.filters}
|
||||
activeFilters={this.props.activeFilters}
|
||||
onSelectFilter={this.props.methods.onSelectFilter}
|
||||
onSelectFilter={this.onSelectFilter}
|
||||
language={this.props.language}
|
||||
coloringSet={this.props.coloringSet}
|
||||
filterColors={this.props.filterColors}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
@@ -190,6 +236,9 @@ function mapStateToProps (state) {
|
||||
narrative: state.app.associations.narrative,
|
||||
sitesShowing: state.app.flags.isShowingSites,
|
||||
infoShowing: state.app.flags.isInfopopup,
|
||||
coloringSet: state.app.associations.coloringSet,
|
||||
maxNumOfColors: state.ui.coloring.maxNumOfColors,
|
||||
filterColors: state.ui.coloring.colors,
|
||||
features: selectors.getFeatures(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
export default ({ label, isActive, onClickCheckbox }) => (
|
||||
<div className={(isActive) ? 'item active' : 'item'}>
|
||||
<span onClick={() => onClickCheckbox()}>{label}</span>
|
||||
<button onClick={() => onClickCheckbox()}>
|
||||
<div className='checkbox' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
export default ({ label, isActive, onClickCheckbox, backgroundColor }) => {
|
||||
const styles = ({
|
||||
background: isActive ? backgroundColor : 'none',
|
||||
border: `1px solid ${backgroundColor}`
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={(isActive) ? 'item active' : 'item'}>
|
||||
<span onClick={() => onClickCheckbox()}>{label}</span>
|
||||
<button onClick={() => onClickCheckbox()}>
|
||||
<div className='checkbox' style={styles} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Portal } from 'react-portal'
|
||||
import colors from '../../../common/global.js'
|
||||
import { calcClusterOpacity, calcClusterSize, isLatitude, isLongitude } from '../../../common/utilities'
|
||||
import ColoredMarkers from './ColoredMarkers.jsx'
|
||||
import {
|
||||
calcClusterOpacity,
|
||||
calcClusterSize,
|
||||
isLatitude,
|
||||
isLongitude,
|
||||
calculateColorPercentages,
|
||||
zipColorsToPercentages } from '../../../common/utilities'
|
||||
|
||||
const DefsClusters = () => (
|
||||
<defs>
|
||||
@@ -12,7 +19,7 @@ const DefsClusters = () => (
|
||||
</defs>
|
||||
)
|
||||
|
||||
function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHover, onClick }) {
|
||||
function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHover, onClick, getClusterChildren, coloringSet, filterColors }) {
|
||||
/**
|
||||
{
|
||||
geometry: {
|
||||
@@ -28,6 +35,10 @@ function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHove
|
||||
}
|
||||
*/
|
||||
const { cluster_id: clusterId } = cluster.properties
|
||||
|
||||
const individualChildren = getClusterChildren(clusterId)
|
||||
const colorPercentages = calculateColorPercentages(individualChildren, coloringSet)
|
||||
|
||||
const { coordinates } = cluster.geometry
|
||||
const [longitude, latitude] = coordinates
|
||||
if (!isLatitude(latitude) || !isLongitude(longitude)) return null
|
||||
@@ -42,20 +53,15 @@ function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHove
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<circle
|
||||
class='cluster-event-marker'
|
||||
id={clusterId}
|
||||
longitude={longitude}
|
||||
latitude={latitude}
|
||||
cx='0'
|
||||
cy='0'
|
||||
r={size}
|
||||
style={{
|
||||
<ColoredMarkers
|
||||
radius={size}
|
||||
colorPercentMap={zipColorsToPercentages(filterColors, colorPercentages)}
|
||||
styles={{
|
||||
...styles
|
||||
}}
|
||||
className={'cluster-event-marker'}
|
||||
/>
|
||||
{hovered ? renderHover(cluster) : null}
|
||||
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -63,9 +69,12 @@ function Cluster ({ cluster, size, projectPoint, totalPoints, styles, renderHove
|
||||
function ClusterEvents ({
|
||||
projectPoint,
|
||||
onSelect,
|
||||
getClusterChildren,
|
||||
coloringSet,
|
||||
isRadial,
|
||||
svg,
|
||||
clusters
|
||||
clusters,
|
||||
filterColors
|
||||
}) {
|
||||
const totalPoints = clusters.reduce((total, cl) => {
|
||||
if (cl && cl.properties) {
|
||||
@@ -80,8 +89,18 @@ function ClusterEvents ({
|
||||
strokeWidth: 0
|
||||
}
|
||||
|
||||
function renderHover (txt) {
|
||||
return <text text-anchor='middle' y='-3px' style={{ fontWeight: 'bold', fill: 'white' }}>{txt}</text>
|
||||
function renderHover (txt, circleSize) {
|
||||
return <>
|
||||
<text text-anchor='middle' y='3px' style={{ fontWeight: 'bold', fill: 'black', zIndex: 10000 }}>{txt}</text>
|
||||
<circle
|
||||
class='event-hover'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r={circleSize + 2}
|
||||
stroke={colors.primaryHighlight}
|
||||
fill-opacity='0.0'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -93,7 +112,10 @@ function ClusterEvents ({
|
||||
const clusterSize = calcClusterSize(pointCount, totalPoints)
|
||||
return <Cluster
|
||||
onClick={onSelect}
|
||||
getClusterChildren={getClusterChildren}
|
||||
coloringSet={coloringSet}
|
||||
cluster={c}
|
||||
filterColors={filterColors}
|
||||
size={clusterSize}
|
||||
projectPoint={projectPoint}
|
||||
totalPoints={totalPoints}
|
||||
@@ -101,17 +123,7 @@ function ClusterEvents ({
|
||||
...styles,
|
||||
fillOpacity: calcClusterOpacity(pointCount, totalPoints)
|
||||
}}
|
||||
renderHover={clster => <>
|
||||
<circle
|
||||
class='event-hover'
|
||||
cx='0'
|
||||
cy='0'
|
||||
r={clusterSize + 2}
|
||||
stroke={colors.primaryHighlight}
|
||||
fill-opacity='0.0'
|
||||
/>
|
||||
{renderHover(pointCount)}
|
||||
</>}
|
||||
renderHover={() => renderHover(pointCount, clusterSize)}
|
||||
/>
|
||||
})}
|
||||
</g>
|
||||
|
||||
47
src/components/presentational/Map/ColoredMarkers.jsx
Normal file
47
src/components/presentational/Map/ColoredMarkers.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { getCoordinatesForPercent } from '../../../common/utilities'
|
||||
|
||||
function ColoredMarkers ({ radius, colorPercentMap, styles, className }) {
|
||||
let cumulativeAngleSweep = 0
|
||||
const colors = Object.keys(colorPercentMap)
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{colors.map((color, idx) => {
|
||||
const colorPercent = colorPercentMap[color]
|
||||
|
||||
const [startX, startY] = getCoordinatesForPercent(radius, cumulativeAngleSweep)
|
||||
|
||||
cumulativeAngleSweep += colorPercent
|
||||
|
||||
const [endX, endY] = getCoordinatesForPercent(radius, cumulativeAngleSweep)
|
||||
// if the slices are less than 2, take the long arc
|
||||
const largeArcFlag = (colors.length === 1) || colorPercent > 0.5 ? 1 : 0
|
||||
|
||||
// create an array and join it just for code readability
|
||||
const arc = [
|
||||
`M ${startX} ${startY}`, // Move
|
||||
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc
|
||||
`L 0 0 `, // Line
|
||||
`L ${startX} ${startY} Z` // Line
|
||||
].join(' ')
|
||||
|
||||
const extraStyles = ({
|
||||
...styles,
|
||||
fill: color
|
||||
})
|
||||
|
||||
return (
|
||||
<path
|
||||
class={className}
|
||||
id={`arc_${idx}`}
|
||||
d={arc}
|
||||
style={extraStyles}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColoredMarkers
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { Portal } from 'react-portal'
|
||||
import colors from '../../../common/global.js'
|
||||
import { calcOpacity } from '../../../common/utilities'
|
||||
import ColoredMarkers from './ColoredMarkers.jsx'
|
||||
import { calcOpacity, getCoordinatesForPercent, calculateColorPercentages, zipColorsToPercentages } from '../../../common/utilities'
|
||||
|
||||
function MapEvents ({
|
||||
getCategoryColor,
|
||||
@@ -13,14 +14,11 @@ function MapEvents ({
|
||||
onSelect,
|
||||
svg,
|
||||
locations,
|
||||
eventRadius
|
||||
eventRadius,
|
||||
coloringSet,
|
||||
filterColors,
|
||||
features
|
||||
}) {
|
||||
function getCoordinatesForPercent (radius, percent) {
|
||||
const x = radius * Math.cos(2 * Math.PI * percent)
|
||||
const y = radius * Math.sin(2 * Math.PI * percent)
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
function handleEventSelect (e, location) {
|
||||
const events = e.shiftKey ? selected.concat(location.events) : location.events
|
||||
onSelect(events)
|
||||
@@ -41,6 +39,27 @@ function MapEvents ({
|
||||
)
|
||||
}
|
||||
|
||||
function renderLocationSlicesByAssociation (location) {
|
||||
const colorPercentages = calculateColorPercentages([location], coloringSet)
|
||||
|
||||
let styles = ({
|
||||
stroke: colors.darkBackground,
|
||||
strokeWidth: 0,
|
||||
fillOpacity: narrative ? 1 : calcOpacity(location.events.length)
|
||||
})
|
||||
|
||||
return (
|
||||
<ColoredMarkers
|
||||
radius={eventRadius}
|
||||
colorPercentMap={zipColorsToPercentages(filterColors, colorPercentages)}
|
||||
styles={{
|
||||
...styles
|
||||
}}
|
||||
className={'location-event-marker'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLocationSlicesByCategory (location) {
|
||||
const locCategory = location.events.length > 0 ? location.events[0].category : 'default'
|
||||
const customStyles = styleLocation ? styleLocation(location) : null
|
||||
@@ -142,7 +161,8 @@ function MapEvents ({
|
||||
transform={`translate(${x}, ${y})`}
|
||||
onClick={(e) => handleEventSelect(e, location)}
|
||||
>
|
||||
{renderLocationSlicesByCategory(location)}
|
||||
{features.COLOR_BY_ASSOCIATION ? renderLocationSlicesByAssociation(location) : null}
|
||||
{features.COLOR_BY_CATEGORY ? renderLocationSlicesByCategory(location) : null}
|
||||
{extraRender ? extraRender() : null}
|
||||
{isSelected ? null : renderBorder()}
|
||||
</g>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import React from 'react'
|
||||
import DatetimeDot from './DatetimeDot'
|
||||
import DatetimeBar from './DatetimeBar'
|
||||
import DatetimeSquare from './DatetimeSquare'
|
||||
import DatetimeStar from './DatetimeStar'
|
||||
import Project from './Project'
|
||||
import { calcOpacity, getEventCategories } from '../../../common/utilities'
|
||||
import ColoredMarkers from '../Map/ColoredMarkers.jsx'
|
||||
import { calcOpacity, getEventCategories, zipColorsToPercentages, calculateColorPercentages } from '../../../common/utilities'
|
||||
|
||||
function renderDot (event, styles, props) {
|
||||
return <DatetimeDot
|
||||
onSelect={props.onSelect}
|
||||
category={event.category}
|
||||
events={[event]}
|
||||
x={props.x}
|
||||
y={props.y}
|
||||
r={props.eventRadius}
|
||||
styleProps={styles}
|
||||
/>
|
||||
const colorPercentages = calculateColorPercentages([event], props.coloringSet)
|
||||
return (
|
||||
<g
|
||||
className={'timeline-event'}
|
||||
onClick={props.onSelect}
|
||||
transform={`translate(${props.x}, ${props.y})`}
|
||||
>
|
||||
<ColoredMarkers
|
||||
radius={props.eventRadius}
|
||||
colorPercentMap={zipColorsToPercentages(props.filterColors, colorPercentages)}
|
||||
styles={{
|
||||
...styles
|
||||
}}
|
||||
className={'event'}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function renderBar (event, styles, props) {
|
||||
@@ -72,7 +80,9 @@ const TimelineEvents = ({
|
||||
features,
|
||||
setLoading,
|
||||
setNotLoading,
|
||||
eventRadius
|
||||
eventRadius,
|
||||
filterColors,
|
||||
coloringSet
|
||||
}) => {
|
||||
const narIds = narrative ? narrative.steps.map(s => s.id) : []
|
||||
|
||||
@@ -121,7 +131,9 @@ const TimelineEvents = ({
|
||||
onSelect: () => onSelect(event),
|
||||
dims,
|
||||
highlights: features.HIGHLIGHT_GROUPS ? getHighlights(event.filters[features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup]) : [],
|
||||
features
|
||||
features,
|
||||
filterColors,
|
||||
coloringSet
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { toggleFlagAC } from '../common/utilities'
|
||||
import {
|
||||
UPDATE_HIGHLIGHTED,
|
||||
UPDATE_SELECTED,
|
||||
UPDATE_COLORING_SET,
|
||||
CLEAR_FILTER,
|
||||
TOGGLE_FILTER,
|
||||
TOGGLE_ASSOCIATIONS,
|
||||
UPDATE_TIMERANGE,
|
||||
UPDATE_DIMENSIONS,
|
||||
UPDATE_NARRATIVE,
|
||||
@@ -39,6 +40,16 @@ function updateSelected (appState, action) {
|
||||
})
|
||||
}
|
||||
|
||||
function updateColoringSet (appState, action) {
|
||||
return {
|
||||
...appState,
|
||||
associations: {
|
||||
...appState.associations,
|
||||
coloringSet: action.coloringSet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateNarrative (appState, action) {
|
||||
let minTime = appState.timeline.range[0]
|
||||
let maxTime = appState.timeline.range[1]
|
||||
@@ -111,11 +122,11 @@ function updateNarrativeStepIdx (appState, action) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilter (appState, action) {
|
||||
function toggleAssociations (appState, action) {
|
||||
if (!(action.value instanceof Array)) {
|
||||
action.value = [action.value]
|
||||
}
|
||||
const { filter: associationType } = action
|
||||
const { association: associationType } = action
|
||||
|
||||
let newAssociations = appState.associations[associationType].slice(0)
|
||||
action.value.forEach(vl => {
|
||||
@@ -249,10 +260,12 @@ function app (appState = initial.app, action) {
|
||||
return updateHighlighted(appState, action)
|
||||
case UPDATE_SELECTED:
|
||||
return updateSelected(appState, action)
|
||||
case UPDATE_COLORING_SET:
|
||||
return updateColoringSet(appState, action)
|
||||
case CLEAR_FILTER:
|
||||
return clearFilter(appState, action)
|
||||
case TOGGLE_FILTER:
|
||||
return toggleFilter(appState, action)
|
||||
case TOGGLE_ASSOCIATIONS:
|
||||
return toggleAssociations(appState, action)
|
||||
case UPDATE_TIMERANGE:
|
||||
return updateTimeRange(appState, action)
|
||||
case UPDATE_DIMENSIONS:
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
}
|
||||
|
||||
.leaflet-tile {
|
||||
filter: brightness(110%) invert(100%) grayscale(800%)
|
||||
filter: brightness(110%) invert(100%) grayscale(800%) contrast(80%);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -416,7 +416,6 @@
|
||||
float: left;
|
||||
font-size: $normal;
|
||||
font-family: Helvetica, 'Georgia', 'serif';
|
||||
color: $midwhite;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mergeDeepLeft } from 'ramda'
|
||||
import global from '../common/global'
|
||||
import global, { colors } from '../common/global'
|
||||
|
||||
const initial = {
|
||||
/*
|
||||
@@ -35,6 +35,7 @@ const initial = {
|
||||
selected: [],
|
||||
source: null,
|
||||
associations: {
|
||||
coloringSet: [],
|
||||
filters: [],
|
||||
narrative: null,
|
||||
categories: [],
|
||||
@@ -63,8 +64,8 @@ const initial = {
|
||||
dimensions: {
|
||||
height: 250,
|
||||
width: 0,
|
||||
marginLeft: 100,
|
||||
marginTop: 15,
|
||||
marginLeft: 70,
|
||||
marginTop: 10, // the padding used for the day/month labels inside the timeline
|
||||
marginBottom: 60,
|
||||
contentHeight: 200,
|
||||
width_controls: 100
|
||||
@@ -131,6 +132,10 @@ const initial = {
|
||||
radial: false
|
||||
}
|
||||
},
|
||||
coloring: {
|
||||
maxNumOfColors: 4,
|
||||
colors: Object.values(colors)
|
||||
},
|
||||
dom: {
|
||||
timeline: 'timeline',
|
||||
timeslider: 'timeslider',
|
||||
|
||||
Reference in New Issue
Block a user