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:
Ebrahem Farooqui
2020-10-27 05:33:32 -07:00
committed by GitHub
parent ddbee03f50
commit c454775fcb
18 changed files with 439 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -152,7 +152,7 @@
}
.leaflet-tile {
filter: brightness(110%) invert(100%) grayscale(800%)
filter: brightness(110%) invert(100%) grayscale(800%) contrast(80%);
}
/*

View File

@@ -416,7 +416,6 @@
float: left;
font-size: $normal;
font-family: Helvetica, 'Georgia', 'serif';
color: $midwhite;
overflow: hidden;
}

View File

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