/* global L */ import { bindActionCreators } from "redux"; import "leaflet"; import React from "react"; import { Portal } from "react-portal"; import Supercluster from "supercluster"; import { isMobileOnly } from "react-device-detect"; import { connect } from "react-redux"; import * as actions from "../../../actions"; import * as selectors from "../../../selectors"; import Sites from "./atoms/Sites"; import Regions from "./atoms/Regions"; import Events from "./atoms/Events"; import Clusters from "./atoms/Clusters"; import SelectedEvents from "./atoms/SelectedEvents"; import Narratives from "./atoms/Narratives"; import DefsMarkers from "./atoms/DefsMarkers"; import SatelliteOverlayToggle from "./atoms/SatelliteOverlayToggle"; import LoadingOverlay from "../../atoms/Loading"; import { mapClustersToLocations, isIdentical, isLatitude, isLongitude, calculateTotalClusterPoints, calcClusterSize, } from "../../../common/utilities"; // NB: important constants for map, TODO: make statics // Note: Base map is OpenStreetMaps by default; can choose another base map const supportedMapboxMap = ["streets", "satellite"]; const defaultToken = "your_token"; 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.getClusterChildren = this.getClusterChildren.bind(this); this.svgRef = React.createRef(); this.map = null; this.superclusterIndex = null; this.tileLayer = null; this.state = { mapTransformX: 0, mapTransformY: 0, indexLoaded: false, clusters: [], }; this.styleLocation = this.styleLocation.bind(this); } componentDidMount() { if (this.map === null) { this.initializeMap(); this.initializeTileLayer(); } window.dispatchEvent(new Event("resize")); } componentDidUpdate(prevProps) { if (prevProps.ui.tiles !== this.props.ui.tiles && this.map) { this.initializeTileLayer(); } } UNSAFE_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 (!isIdentical(bounds, this.props.app.map.bounds) && bounds !== null) { this.map.fitBounds(bounds); } else { 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; if ( eventPoint !== null && eventPoint.latitude && eventPoint.longitude ) { // this.map.setView([eventPoint.latitude, eventPoint.longitude]) this.map.setView( [eventPoint.latitude, eventPoint.longitude], this.map.getZoom(), { animate: true, pan: { duration: 0.7, }, } ); } } } } getTileUrl(tiles) { if ( supportedMapboxMap.indexOf(this.props.ui.tiles) !== -1 && process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== defaultToken ) { return `http://a.tiles.mapbox.com/v4/mapbox.${tiles}/{z}/{x}/{y}@2x.png?access_token=${process.env.MAPBOX_TOKEN}`; } else if ( process.env.MAPBOX_TOKEN && process.env.MAPBOX_TOKEN !== defaultToken ) { return `https://api.mapbox.com/styles/v1/${tiles}/tiles/256/{z}/{x}/{y}@2x?access_token=${process.env.MAPBOX_TOKEN}`; // `http://a.tiles.mapbox.com/styles/v1/${this.props.ui.tiles}/tiles/{z}/{x}/{y}?access_token=${process.env.MAPBOX_TOKEN}` } else { return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; // "https://api.maptiler.com/maps/bright/256/{z}/{x}/{y}.png?key=" } } /** * Initialize the base tile layer based on the ui state */ initializeTileLayer() { if (!this.map) { return; } const url = this.getTileUrl(this.props.ui.tiles); /** * If a tile layer already exists, we update its url. Otherwise, we create it and add it to the map. */ if (this.tileLayer) { this.tileLayer.setUrl(url); } else { this.tileLayer = L.tileLayer(url); this.tileLayer.addTo(this.map); } } initializeMap() { /** * Creates a Leaflet map */ const { map: mapConfig, cluster: clusterConfig } = this.props.app; const map = L.map(this.props.ui.dom.map) .setView(mapConfig.anchor, mapConfig.startZoom) .setMinZoom(mapConfig.minZoom) .setMaxZoom(mapConfig.maxZoom) .setMaxBounds(mapConfig.maxBounds); // This assumes your map is the constant 'map' map.attributionControl.addAttribution( `© Mapbox © OpenStreetMap Improve this map` ); // Initialize supercluster index this.superclusterIndex = new Supercluster(clusterConfig); map.keyboard.disable(); map.zoomControl.remove(); map.on("moveend", () => { this.alignLayers(); }); map.on("zoomend viewreset", () => { this.map.dragging.enable(); this.map.doubleClickZoom.enable(); this.map.scrollWheelZoom.enable(); this.alignLayers(); this.updateClusters(); }); 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(); }); 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: [] }); } } 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 []; } getSelectedClusters() { const { selected } = this.props.app; const selectedIds = selected.map((sl) => sl.id); if (this.state.clusters && this.state.clusters.length > 0) { return this.state.clusters.reduce((acc, cl) => { if (cl.properties.cluster) { const children = this.getClusterChildren(cl.properties.cluster_id); if (children && children.length > 0) { children.forEach((child) => { const clusterPresent = acc.findIndex((item) => item.id === cl.id) >= 0; if (selectedIds.includes(child.id) && !clusterPresent) { acc.push(cl); } }); } } return acc; }, []); } return []; } alignLayers() { const mapNode = document.querySelector(".leaflet-map-pane"); if (mapNode === null) return { transformX: 0, transformY: 0 }; // We'll get the transform of the leaflet container, // which will let us offset the SVG by the same quantity const transform = window .getComputedStyle(mapNode) .getPropertyValue("transform"); // Offset with leaflet map transform boundaries this.setState({ mapTransformX: +transform.split(",")[4], mapTransformY: +transform.split(",")[5].split(")")[0], }); } projectPoint(location) { const latLng = new L.LatLng(location[0], location[1]); return { x: this.map.latLngToLayerPoint(latLng).x + this.state.mapTransformX, y: this.map.latLngToLayerPoint(latLng).y + this.state.mapTransformY, }; } onClusterSelect({ id, latitude, longitude }) { const expansionZoom = Math.max( this.superclusterIndex.getClusterExpansionZoom(parseInt(id)), this.superclusterIndex.options.minZoom ); const zoomLevelsToSkip = 2; const zoomToFly = Math.max( expansionZoom + zoomLevelsToSkip, this.props.app.cluster.maxZoom ); this.map.dragging.disable(); this.map.doubleClickZoom.disable(); this.map.scrollWheelZoom.disable(); this.map.flyTo(new L.LatLng(latitude, longitude), zoomToFly); } getClientDims() { const boundingClient = document .querySelector(`#${this.props.ui.dom.map}`) .getBoundingClientRect(); return { width: boundingClient.width, height: boundingClient.height, }; } renderTiles() { const pane = this.map.getPanes().overlayPane; const { width, height } = this.getClientDims(); return this.map ? ( ) : null; } renderSites() { return ( ); } renderRegions() { return ( ); } renderNarratives() { const hasNarratives = this.props.domain.narratives.length > 0; return ( ); } /** * Determines additional styles on the for each location. * A location consists of an array of events (see selectors). The function * also has full access to the domain and redux state to derive values if * necessary. The function should return an array, where the value at the * first index is a styles object for the SVG at the location, and the value * at the second index is an optional additional component that renders in * the div. */ styleLocation(location) { return [null, null]; } styleCluster(cluster) { return [null, null]; } 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 ( ); } renderClusters() { const allClusters = this.state.clusters.filter( (cl) => cl.properties.cluster ); return ( ); } renderSelected() { const selectedClusters = this.getSelectedClusters(); const totalMarkers = []; this.props.app.selected.forEach((s) => { const { latitude, longitude } = s; totalMarkers.push({ latitude, longitude, radius: this.props.ui.eventRadius, }); }); const totalClusterPoints = calculateTotalClusterPoints(this.state.clusters); selectedClusters.forEach((cl) => { if (cl.properties.cluster) { const { coordinates } = cl.geometry; totalMarkers.push({ latitude: String(coordinates[1]), longitude: String(coordinates[0]), radius: calcClusterSize( cl.properties.point_count, totalClusterPoints ), }); } }); return ( ); } renderMarkers() { return ( ); } render() { const { isShowingSites, isFetchingDomain } = this.props.app.flags; const checkMobile = isMobileOnly || window.innerWidth < 600; const classes = (this.props.app.narrative ? "map-wrapper narrative-mode" : "map-wrapper") + (checkMobile ? " mobile" : ""); const innerMap = this.map ? ( <> {this.renderTiles()} {this.renderMarkers()} {isShowingSites ? this.renderSites() : null} {this.renderRegions()} {this.renderNarratives()} {this.renderEvents()} {this.renderClusters()} {this.renderSelected()} ) : null; return (
{this.props.features.USE_SATELLITE_OVERLAY_TOGGLE && ( )} {innerMap}
); } } function mapStateToProps(state) { return { domain: { locations: selectors.selectLocations(state), narratives: selectors.selectNarratives(state), categories: selectors.getCategories(state), sites: selectors.selectSites(state), regions: selectors.selectRegions(state), }, app: { views: state.app.associations.views, selected: selectors.selectSelected(state), highlighted: state.app.highlighted, map: state.app.map, cluster: state.app.cluster, 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, }, }, ui: { tiles: selectors.getTiles(state), dom: state.ui.dom, narratives: state.ui.style.narratives, mapSelectedEvents: state.ui.style.selectedEvents, regions: state.ui.style.regions, eventRadius: state.ui.eventRadius, radial: state.ui.style.clusters.radial, filterColors: state.ui.coloring.colors, }, features: selectors.getFeatures(state), }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators(actions, dispatch), }; } export default connect(mapStateToProps, mapDispatchToProps)(Map);