mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 21:08:36 +03:00
Merge pull request #162 from forensic-architecture/feature/add-supercluster-visualization
Feature/add supercluster visualization
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
111
src/components/presentational/Map/Clusters.jsx
Normal file
111
src/components/presentational/Map/Clusters.jsx
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user