Merge pull request #162 from forensic-architecture/feature/add-supercluster-visualization

Feature/add supercluster visualization
This commit is contained in:
Ebrahem Farooqui
2020-10-20 15:31:31 -07:00
committed by GitHub
9 changed files with 300 additions and 25 deletions

View File

@@ -30,6 +30,7 @@
"redux": "^3.6.0",
"redux-thunk": "^2.2.0",
"reselect": "^3.0.1",
"supercluster": "^7.1.0",
"video-react": "^0.13.1"
},
"devDependencies": {

View File

@@ -1,4 +1,5 @@
import moment from 'moment'
import hash from 'object-hash'
let { DATE_FMT, TIME_FMT } = process.env
if (!DATE_FMT) DATE_FMT = 'MM/DD/YYYY'
@@ -86,8 +87,7 @@ export function insetSourceFrom (allSources) {
if (!event.sources) {
sources = []
} else {
sources = event.sources.map(src => {
const id = typeof src === 'object' ? src.id : src
sources = event.sources.map(id => {
return allSources.hasOwnProperty(id) ? allSources[id] : null
})
}
@@ -171,6 +171,10 @@ export function selectTypeFromPathWithPoster (path, poster) {
return { type: typeForPath(path), path, poster }
}
export function isIdentical (obj1, obj2) {
return hash(obj1) === hash(obj2)
}
export function calcOpacity (num) {
/* Events have opacity 0.5 by default, and get added to according to how many
* other events there are in the same render. The idea here is that the
@@ -180,6 +184,36 @@ export function calcOpacity (num) {
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)
}
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)
}
export function isLatitude (lat) {
return !!lat && isFinite(lat) && Math.abs(lat) <= 90
}
export function isLongitude (lng) {
return !!lng && isFinite(lng) && Math.abs(lng) <= 180
}
export function mapClustersToLocations (clusters, locations) {
return clusters.reduce((acc, cl) => {
const foundLocation = locations.find(location => location.label === cl.properties.id)
if (foundLocation) acc.push(foundLocation)
return acc
}, [])
}
export const dateMin = function () {
return Array.prototype.slice.call(arguments).reduce(function (a, b) {
return a < b ? a : b

View File

@@ -239,7 +239,6 @@ class Dashboard extends React.Component {
render () {
const { actions, app, domain, ui, features } = this.props
if (isMobile || window.innerWidth < 600) {
const msg = 'This platform is not suitable for mobile. Please re-visit the site on a device with a larger screen.'
return (
@@ -260,7 +259,7 @@ class Dashboard extends React.Component {
}
return (
<div >
<div>
<Toolbar
isNarrative={!!app.associations.narrative}
methods={{

View File

@@ -1,19 +1,23 @@
/* global L */
import React from 'react'
import { Portal } from 'react-portal'
import Supercluster from 'supercluster'
import { connect } from 'react-redux'
import * as selectors from '../selectors'
import hash from 'object-hash'
import 'leaflet'
import Sites from './presentational/Map/Sites.jsx'
import Shapes from './presentational/Map/Shapes.jsx'
import Events from './presentational/Map/Events.jsx'
import Clusters from './presentational/Map/Clusters.jsx'
import SelectedEvents from './presentational/Map/SelectedEvents.jsx'
import Narratives from './presentational/Map/Narratives'
import DefsMarkers from './presentational/Map/DefsMarkers.jsx'
import LoadingOverlay from '../components/Overlay/Loading'
import { mapClustersToLocations, isIdentical, isLatitude, isLongitude } from '../common/utilities'
// NB: important constants for map, TODO: make statics
const supportedMapboxMap = ['streets', 'satellite']
@@ -23,11 +27,16 @@ class Map extends React.Component {
constructor () {
super()
this.projectPoint = this.projectPoint.bind(this)
this.onClusterSelect = this.onClusterSelect.bind(this)
this.loadClusterData = this.loadClusterData.bind(this)
this.svgRef = React.createRef()
this.map = null
this.superclusterIndex = null
this.state = {
mapTransformX: 0,
mapTransformY: 0
mapTransformY: 0,
indexLoaded: false,
clusters: []
}
this.styleLocation = this.styleLocation.bind(this)
}
@@ -39,13 +48,16 @@ class Map extends React.Component {
}
componentWillReceiveProps (nextProps) {
if (!isIdentical(nextProps.domain.locations, this.props.domain.locations)) {
this.loadClusterData(nextProps.domain.locations)
}
// Set appropriate zoom for narrative
const { bounds } = nextProps.app.map
if (hash(bounds) !== hash(this.props.app.map.bounds) &&
if (!isIdentical(bounds, this.props.app.map.bounds) &&
bounds !== null) {
this.map.fitBounds(bounds)
} else {
if (hash(nextProps.app.selected) !== hash(this.props.app.selected)) {
if (!isIdentical(nextProps.app.selected, this.props.app.selected)) {
// Fly to first of events selected
const eventPoint = (nextProps.app.selected.length > 0) ? nextProps.app.selected[0] : null
@@ -62,17 +74,31 @@ class Map extends React.Component {
}
}
componentDidUpdate (prevState, prevProps) {
if (prevState.domain.locations.length > 0 && this.state.clusters.length === 0) {
this.loadClusterData(prevState.domain.locations)
}
}
initializeMap () {
/**
* Creates a Leaflet map and a tilelayer for the map background
*/
const { map: mapConf } = this.props.app
const { map: mapConfig } = this.props.app
const map =
L.map(this.props.ui.dom.map)
.setView(mapConf.anchor, mapConf.startZoom)
.setMinZoom(mapConf.minZoom)
.setMaxZoom(mapConf.maxZoom)
.setMaxBounds(mapConf.maxBounds)
.setView(mapConfig.anchor, mapConfig.startZoom)
.setMinZoom(mapConfig.minZoom)
.setMaxZoom(mapConfig.maxZoom)
.setMaxBounds(mapConfig.maxBounds)
// Initialize supercluster index
this.superclusterIndex = new Supercluster({
radius: mapConfig.clusterRadius,
maxZoom: mapConfig.maxZoom,
minZoom: mapConfig.minZoom
})
let firstLayer
@@ -94,7 +120,12 @@ class Map extends React.Component {
map.keyboard.disable()
map.zoomControl.remove()
map.on('move zoomend viewreset moveend', () => this.alignLayers())
map.on('moveend', () => {
this.updateClusters()
this.alignLayers()
})
map.on('move zoomend viewreset', () => this.alignLayers())
map.on('zoomstart', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.add('hide') })
map.on('zoomend', () => { if (this.svgRef.current !== null) this.svgRef.current.classList.remove('hide') })
window.addEventListener('resize', () => { this.alignLayers() })
@@ -102,6 +133,51 @@ class Map extends React.Component {
this.map = map
}
getMapDetails () {
const bounds = this.map.getBounds()
const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]
const zoom = this.map.getZoom()
return [bbox, zoom]
}
updateClusters () {
const [bbox, zoom] = this.getMapDetails()
if (this.superclusterIndex && this.state.indexLoaded) {
this.setState({
clusters: this.superclusterIndex.getClusters(bbox, zoom)
})
}
}
loadClusterData (locations) {
if (locations && locations.length > 0 && this.superclusterIndex) {
const convertedLocations = locations.reduce((acc, loc) => {
const { longitude, latitude } = loc
const validCoordinates = isLatitude(latitude) && isLongitude(longitude)
if (validCoordinates) {
const feature = {
type: 'Feature',
properties: {
cluster: false,
id: loc.label
},
geometry: {
type: 'Point',
coordinates: [longitude, latitude]
}
}
acc.push(feature)
}
return acc
}, [])
this.superclusterIndex.load(convertedLocations)
this.setState({ indexLoaded: true })
this.updateClusters()
} else {
this.setState({ clusters: [] })
}
}
alignLayers () {
const mapNode = document.querySelector('.leaflet-map-pane')
if (mapNode === null) return { transformX: 0, transformY: 0 }
@@ -127,6 +203,13 @@ class Map extends React.Component {
}
}
onClusterSelect (e) {
const { id } = e.target
const { longitude, latitude } = e.target.attributes
const expansionZoom = Math.max(this.superclusterIndex.getClusterExpansionZoom(parseInt(id)), this.superclusterIndex.options.minZoom)
this.map.flyTo(new L.LatLng(latitude.value, longitude.value), expansionZoom)
}
getClientDims () {
const boundingClient = document.querySelector(`#${this.props.ui.dom.map}`).getBoundingClientRect()
@@ -202,12 +285,18 @@ class Map extends React.Component {
return [null, null]
}
styleCluster (cluster) {
return [null, null]
}
renderEvents () {
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={this.props.domain.locations}
locations={filteredLocations}
styleLocation={this.styleLocation}
categories={this.props.domain.categories}
projectPoint={this.projectPoint}
@@ -220,6 +309,20 @@ class Map extends React.Component {
)
}
renderClusters () {
const allClusters = this.state.clusters.filter(cl => cl.properties.cluster)
return (
<Clusters
svg={this.svgRef.current}
styleCluster={this.styleCluster}
projectPoint={this.projectPoint}
clusters={allClusters}
isRadial={this.props.ui.radial}
onSelect={this.onClusterSelect}
/>
)
}
renderSelected () {
return (
<SelectedEvents
@@ -240,7 +343,7 @@ class Map extends React.Component {
}
render () {
const { isShowingSites } = this.props.app.flags
const { isShowingSites, isFetchingDomain } = this.props.app.flags
const classes = this.props.app.narrative ? 'map-wrapper narrative-mode' : 'map-wrapper'
const innerMap = this.map ? (
<React.Fragment>
@@ -250,6 +353,7 @@ class Map extends React.Component {
{this.renderShapes()}
{this.renderNarratives()}
{this.renderEvents()}
{this.renderClusters()}
{this.renderSelected()}
</React.Fragment>
) : null
@@ -260,6 +364,11 @@ class Map extends React.Component {
tabIndex='0'
>
<div id={this.props.ui.dom.map} />
<LoadingOverlay
isLoading={this.props.app.loading || isFetchingDomain}
ui={isFetchingDomain}
language={this.props.app.language}
/>
{innerMap}
</div>
)
@@ -280,9 +389,12 @@ function mapStateToProps (state) {
selected: selectors.selectSelected(state),
highlighted: state.app.highlighted,
map: state.app.map,
language: state.app.language,
loading: state.app.loading,
narrative: state.app.associations.narrative,
flags: {
isShowingSites: state.app.flags.isShowingSites
isShowingSites: state.app.flags.isShowingSites,
isFetchingDomain: state.app.flags.isFetchingDomain
}
},
ui: {
@@ -291,7 +403,8 @@ function mapStateToProps (state) {
narratives: state.ui.style.narratives,
mapSelectedEvents: state.ui.style.selectedEvents,
shapes: state.ui.style.shapes,
eventRadius: state.ui.eventRadius
eventRadius: state.ui.eventRadius,
radial: state.ui.style.clusters.radial
},
features: selectors.getFeatures(state)
}

View File

@@ -310,7 +310,6 @@ class Timeline extends React.Component {
const extraStyle = { ...heightStyle, ...foldedStyle }
const contentHeight = { height: dims.contentHeight }
const { categories } = this.props.domain
return (
<div className={classes} style={extraStyle} onKeyDown={this.props.onKeyDown} tabIndex='1'>
<Header

View File

@@ -0,0 +1,111 @@
import React from 'react'
import { Portal } from 'react-portal'
import colors from '../../../common/global.js'
import { calcClusterOpacity, calcClusterSize } from '../../../common/utilities'
const DefsClusters = () => (
<defs>
<radialGradient id='clusterGradient'>
<stop offset='10%' stop-color='red' />
<stop offset='90%' stop-color='transparent' />
</radialGradient>
</defs>
)
function ClusterEvents ({
projectPoint,
styleCluster,
onSelect,
isRadial,
svg,
clusters
}) {
function calculateTotalPoints () {
return clusters.reduce((total, cl) => {
if (cl && cl.properties) {
total += cl.properties.point_count
}
return total
}, 0)
}
function renderClusterBySize (cluster) {
const { point_count: pointCount, cluster_id: clusterId } = cluster.properties
const { coordinates } = cluster.geometry
const [longitude, latitude] = coordinates
const totalPoints = calculateTotalPoints()
const styles = {
fill: isRadial ? "url('#clusterGradient')" : colors.fallbackEventColor,
stroke: colors.darkBackground,
strokeWidth: 0,
fillOpacity: calcClusterOpacity(pointCount, totalPoints)
}
return (
<React.Fragment>
{<circle
class='cluster-event-marker'
id={clusterId}
longitude={longitude}
latitude={latitude}
cx='0'
cy='0'
r={calcClusterSize(pointCount, totalPoints)}
style={styles}
/>}
</React.Fragment>
)
}
function renderCluster (cluster) {
/**
{
geometry: {
coordinates: [longitude, latitude]
},
properties: {
cluster: true|false,
cluster_id: int,
point_count: int,
point_count_abbreviated: int
},
type: "Feature"
}
*/
const { coordinates } = cluster.geometry
const [longitude, latitude] = coordinates
if (!latitude || !longitude) return null
const { x, y } = projectPoint([latitude, longitude])
const customStyles = styleCluster ? styleCluster(cluster) : null
const extraRender = () => (
<React.Fragment>
{customStyles[1]}
</React.Fragment>
)
return (
<g
className={'cluster-event'}
transform={`translate(${x}, ${y})`}
onClick={(e) => onSelect(e)}
>
{renderClusterBySize(cluster)}
{extraRender ? extraRender() : null}
</g>
)
}
return (
<Portal node={svg}>
<g className='cluster-locations'>
{isRadial ? <DefsClusters /> : null}
{clusters.map(renderCluster)}
</g>
</Portal>
)
}
export default ClusterEvents

View File

@@ -175,6 +175,10 @@
cursor: pointer;
}
.cluster-event {
cursor: pointer;
}
.location-event-marker {
pointer-events: all !important;
fill: $event_default;
@@ -185,6 +189,14 @@
}
}
.cluster-event-marker {
pointer-events: all !important;
&.red {
fill: red;
}
}
.narrative-step-arrow {
pointer-events: all !important;
}

View File

@@ -1,5 +1,5 @@
import { createSelector } from 'reselect'
import { insetSourceFrom, dateMin, dateMax } from '../common/utilities'
import { insetSourceFrom, dateMin, dateMax, isLatitude, isLongitude } from '../common/utilities'
import { isTimeRangedIn } from './helpers'
import { ASSOCIATION_MODES } from '../common/constants'
@@ -68,7 +68,6 @@ export const selectEvents = createSelector(
if (isActiveTime && isActiveFilter && isActiveCategory) {
acc[event.id] = { ...event }
}
return acc
}, [])
})
@@ -163,6 +162,9 @@ export const selectLocations = createSelector(
(events) => {
const activeLocations = {}
events.forEach(event => {
const { latitude, longitude } = event
if (!isLatitude(latitude) || !isLongitude(longitude)) return
const location = `${event.location}$_${event.latitude}_${event.longitude}`
if (activeLocations[location]) {

View File

@@ -11,7 +11,7 @@ const initial = {
*/
domain: {
events: [],
locations: [],
categories: [],
associations: [],
sources: {},
sites: [],
@@ -48,10 +48,11 @@ const initial = {
map: {
anchor: [31.356397, 34.784818],
startZoom: 11,
minZoom: 6,
maxZoom: 18,
minZoom: 2,
maxZoom: 16,
bounds: null,
maxBounds: [[180, -180], [-180, 180]]
maxBounds: [[180, -180], [-180, 180]],
clusterRadius: 30
},
timeline: {
dimensions: {
@@ -120,6 +121,9 @@ const initial = {
strokeWidth: 3,
opacity: 0.9
}
},
clusters: {
radial: false
}
},
dom: {