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