Skip to content

Commit

Permalink
Feature/add coloring algorithm (#169)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Oct 27, 2020
1 parent 621c7c7 commit 888d0be
Show file tree
Hide file tree
Showing 18 changed files with 439 additions and 100 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 14 additions & 6 deletions src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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'
Expand Down
125 changes: 122 additions & 3 deletions src/common/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -180,22 +244,24 @@ 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)))
}

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) {
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/Layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}}
/>
Expand Down
29 changes: 28 additions & 1 deletion src/components/Map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -281,13 +294,19 @@ 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 (
<Events
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}
Expand All @@ -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}
/>
)
}
Expand All @@ -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}
/>
)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
29 changes: 17 additions & 12 deletions src/components/Timeline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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}
Expand All @@ -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>
Expand All @@ -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)
}
Expand Down
Loading

0 comments on commit 888d0be

Please sign in to comment.