diff --git a/package.json b/package.json
index 0f98cb7..3d753f1 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/common/utilities.js b/src/common/utilities.js
index 989c370..d5c26fc 100644
--- a/src/common/utilities.js
+++ b/src/common/utilities.js
@@ -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
diff --git a/src/components/Layout.js b/src/components/Layout.js
index 8ef21e7..b6ea66f 100644
--- a/src/components/Layout.js
+++ b/src/components/Layout.js
@@ -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 (
-
+
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 (
cl.properties.cluster)
+ return (
+
+ )
+ }
+
renderSelected () {
return (
@@ -250,6 +353,7 @@ class Map extends React.Component {
{this.renderShapes()}
{this.renderNarratives()}
{this.renderEvents()}
+ {this.renderClusters()}
{this.renderSelected()}
) : null
@@ -260,6 +364,11 @@ class Map extends React.Component {
tabIndex='0'
>
+
{innerMap}
)
@@ -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)
}
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index 5a0f5ef..58b0a3d 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -310,7 +310,6 @@ class Timeline extends React.Component {
const extraStyle = { ...heightStyle, ...foldedStyle }
const contentHeight = { height: dims.contentHeight }
const { categories } = this.props.domain
-
return (
(
+
+
+
+
+
+
+)
+
+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 (
+
+ {}
+
+ )
+ }
+
+ 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 = () => (
+
+ {customStyles[1]}
+
+ )
+
+ return (
+ onSelect(e)}
+ >
+ {renderClusterBySize(cluster)}
+ {extraRender ? extraRender() : null}
+
+ )
+ }
+
+ return (
+
+
+ {isRadial ? : null}
+ {clusters.map(renderCluster)}
+
+
+ )
+}
+
+export default ClusterEvents
diff --git a/src/scss/map.scss b/src/scss/map.scss
index c492967..bc33fc4 100644
--- a/src/scss/map.scss
+++ b/src/scss/map.scss
@@ -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;
}
diff --git a/src/selectors/index.js b/src/selectors/index.js
index 98770ab..8990ba1 100644
--- a/src/selectors/index.js
+++ b/src/selectors/index.js
@@ -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]) {
diff --git a/src/store/initial.js b/src/store/initial.js
index 2ed221d..eeeed43 100644
--- a/src/store/initial.js
+++ b/src/store/initial.js
@@ -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: {