UNPKG

@appearhere/bloom

Version:

Appear Here's pattern library and styleguide

466 lines (400 loc) 13.6 kB
import PropTypes from 'prop-types'; import React, { Component } from 'react'; /* eslint-disable camelcase */ import { unstable_renderSubtreeIntoContainer as renderSubtreeIntoContainer, unmountComponentAtNode, } from 'react-dom'; /* eslint-enable camelcase */ import isEqual from 'lodash/fp/isEqual'; import uniqueId from 'lodash/fp/uniqueId'; import flattenDeep from 'lodash/fp/flattenDeep'; import find from 'lodash/fp/find'; import cx from 'classnames'; import lngLatType from '../../utils/propTypeValidations/lngLat'; import minLngLatBounds from '../../utils/geoUtils/minLngLatBounds'; import mapboxgl from '../../utils/mapboxgl/mapboxgl'; import isSingleLevelArray from '../../utils/isSingleLevelArray/isSingleLevelArray'; import nestedArrayDepth from '../../utils/nestedArrayDepth/nestedArrayDepth'; import MarkerContainer from './Markers/MarkerContainer'; import BaseMap from './BaseMap'; import { CLUSTER_RADIUS, CLUSTER_MAX_ZOOM, MARKER_SOURCE, MARKER_LAYER, HIGHLIGHTED_MARKER_LAYER, CLUSTER_LAYER, HIGHLIGHTED_CLUSTER_LAYER, MOVE_TO_MARKER_MAX_LAT_OFFSET, DEFAULT_MARKER_CONFIG, } from '../../constants/mapbox'; import css from './MarkableMap.css'; export default class MarkableMap extends Component { static propTypes = { markers: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]).isRequired, lngLat: lngLatType.isRequired, label: PropTypes.string.isRequired, props: PropTypes.object, }) ), metaMarkers: PropTypes.array, colorStops: PropTypes.arrayOf(PropTypes.array), intensity: PropTypes.number, spread: PropTypes.number, cellDensity: PropTypes.number, MarkerComponent: PropTypes.func.isRequired, GroupMarkerComponent: PropTypes.func.isRequired, autoFit: PropTypes.bool, highlightedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), onMarkerClick: PropTypes.func, }; static defaultProps = { markers: [], metaMarkers: [], autoFit: false, }; constructor(props) { super(props); this.id = uniqueId('map_'); } state = { activeFeature: null, }; componentDidMount() { const { autoFit, markers } = this.props; this.updateMapboxMarkerSource(); if (autoFit) this.fitMarkers(markers); } componentDidUpdate(prevProps, prevState) { const { markers: prevMarkers, metaMarkers: prevMetaMarkers } = prevProps; const { activeFeature: prevActiveFeature } = prevState; const { markers, autoFit, metaMarkers } = this.props; const { activeFeature } = this.state; this.updateMapboxMarkerSource(); if (!isEqual(prevMetaMarkers, metaMarkers)) { this.updateMetaMarkerSource(prevMetaMarkers); } if (!activeFeature || !this.getActiveFeaturedMarker()) { this.unmountActiveMarker(); } else if (!isEqual(activeFeature, prevActiveFeature)) { this.renderMarkerPopup(activeFeature); } if (autoFit) { const markersMoved = markers.some((marker) => { const prevMarker = prevMarkers.find(prev => prev.id === marker.id); return !prevMarker || !isEqual(prevMarker.lngLat, marker.lngLat); }); const markerChange = prevMarkers.length !== markers.length || markersMoved; if (markerChange) this.fitMarkers(markers); } } componentWillUnmount() { this.unmountActiveMarker(); } getMapboxGL = () => this.map.getMapboxGL(); getActiveFeaturedMarker = () => { const { markers } = this.props; const { activeFeature } = this.state; if (activeFeature.properties.cluster) { const clusterSet = JSON.parse(activeFeature.properties.markerids); const clusterMarkerIds = flattenDeep(clusterSet); return find(marker => clusterMarkerIds.indexOf(marker.id) > -1, markers); } if (activeFeature) { return find({ id: activeFeature.properties.id }, markers); } return undefined; }; setCenter = (center) => { this.map.setCenter(center); }; handleMapLoad = () => { const mapbox = this.getMapboxGL(); mapbox.addSource(MARKER_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [], }, cluster: true, clusterRadius: CLUSTER_RADIUS, clusterMaxZoom: CLUSTER_MAX_ZOOM, }); this.mapboxMarkerSource = mapbox.getSource(MARKER_SOURCE); mapbox.addLayer({ id: MARKER_LAYER, type: DEFAULT_MARKER_CONFIG.type, source: MARKER_SOURCE, filter: [ 'all', ['!=', 'active', true], ['!has', 'point_count'], ['!=', 'highlighted', true], ], layout: { 'icon-allow-overlap': true, 'text-allow-overlap': true, 'icon-image': 'pin-{labellen}', 'text-field': '{label}', 'text-font': DEFAULT_MARKER_CONFIG.layout.textFont, 'text-size': DEFAULT_MARKER_CONFIG.layout.textSize, 'icon-offset': [0, -15], 'text-offset': [0, -1.9], 'text-anchor': 'top', }, paint: DEFAULT_MARKER_CONFIG.paint, }); mapbox.addLayer({ id: HIGHLIGHTED_MARKER_LAYER, type: DEFAULT_MARKER_CONFIG.type, source: MARKER_SOURCE, filter: [ 'all', ['!=', 'active', true], ['!has', 'point_count'], ['==', 'highlighted', true], ], layout: { 'icon-allow-overlap': true, 'text-allow-overlap': true, 'icon-image': 'pin-{labellen}-highlight', 'text-field': '{label}', 'text-font': DEFAULT_MARKER_CONFIG.layout.textFont, 'text-size': DEFAULT_MARKER_CONFIG.layout.textSize, 'icon-offset': [0, -15], 'text-offset': [0, -1.9], 'text-anchor': 'top', }, paint: DEFAULT_MARKER_CONFIG.paint, }); mapbox.addLayer({ id: CLUSTER_LAYER, type: DEFAULT_MARKER_CONFIG.type, source: MARKER_SOURCE, filter: [ 'all', ['has', 'point_count'], ['!=', 'highlighted', true], ], layout: { 'icon-image': 'pin-cluster', 'text-field': '{point_count}', 'text-font': DEFAULT_MARKER_CONFIG.layout.textFont, 'text-size': DEFAULT_MARKER_CONFIG.layout.textSize, }, paint: DEFAULT_MARKER_CONFIG.paint, }); mapbox.addLayer({ id: HIGHLIGHTED_CLUSTER_LAYER, type: DEFAULT_MARKER_CONFIG.type, source: MARKER_SOURCE, filter: [ 'all', ['has', 'point_count'], ['==', 'highlighted', true], ], layout: { 'icon-image': 'pin-cluster-highlight', 'text-field': '{point_count}', 'text-font': DEFAULT_MARKER_CONFIG.layout.textFont, 'text-size': DEFAULT_MARKER_CONFIG.layout.textSize, }, paint: DEFAULT_MARKER_CONFIG.paint, }); // When hovering on a marker change the cursor to a pointer mapbox.on('mousemove', (e) => { const features = mapbox.queryRenderedFeatures(e.point, { layers: [MARKER_LAYER, CLUSTER_LAYER], }); mapbox.getCanvas().style.cursor = features.length ? 'pointer' : ''; }); this.updateMapboxMarkerSource(); this.updateMetaMarkerSource(); }; updateMapboxMarkerSource = () => { if (!this.mapboxMarkerSource) return; const { activeFeature } = this.state; const { markers, highlightedId } = this.props; const features = markers.map(marker => ({ type: 'Feature', geometry: { type: 'Point', coordinates: marker.lngLat, }, properties: { id: marker.id, active: activeFeature && marker.id === activeFeature.properties.id, label: marker.label, labellen: marker.label.length, highlighted: marker.id === highlightedId, }, })); this.mapboxMarkerSource.setData({ type: 'FeatureCollection', features, }); }; updateMetaMarkerSource = (prevMetaMarkers) => { const { metaMarkers } = this.props; const mapbox = this.getMapboxGL(); if (prevMetaMarkers) { prevMetaMarkers.forEach((prevMetaMarker) => { const prevMetaMarkerSource = mapbox.getSource(prevMetaMarker.id); const prevMetaMarkerLayer = mapbox.getLayer(prevMetaMarker.id); if ( (prevMetaMarkerSource && prevMetaMarkerLayer) && !metaMarkers.includes(prevMetaMarker) ) { mapbox.removeSource(prevMetaMarker.id); mapbox.removeLayer(prevMetaMarker.id); } }); } metaMarkers.forEach((metaMarker, index) => { const metaMarkerSource = mapbox.getSource(metaMarker.id); const metaMarkerLayer = mapbox.getLayer(metaMarker.id); if (!metaMarkerSource && !metaMarkerLayer) { mapbox.addSource(metaMarker.id, { type: 'geojson', data: metaMarker, }); mapbox.addLayer({ id: metaMarker.id, type: DEFAULT_MARKER_CONFIG.type, source: metaMarker.id, layout: { 'icon-image': `meta-${index + 1}`, 'text-field': metaMarker.label, 'text-font': DEFAULT_MARKER_CONFIG.layout.textFont, 'text-size': DEFAULT_MARKER_CONFIG.layout.textSize, }, paint: DEFAULT_MARKER_CONFIG.paint, }); } }); }; handleMapClick = (e) => { const { originalEvent, point } = e; if (originalEvent.target !== this.getMapboxGL().getCanvas()) return; const markers = this.getMapboxGL().queryRenderedFeatures(point, { layers: [MARKER_LAYER] }); const clusters = this.getMapboxGL().queryRenderedFeatures(point, { layers: [CLUSTER_LAYER] }); if (markers.length > 0) { this.handleMarkerClick(markers[0]); } else if (clusters.length > 0) { this.handleClusterClick(clusters[0]); } else { this.setState({ activeFeature: null }); } }; handleMarkerClick = (marker) => { if (this.props.onMarkerClick) { this.props.onMarkerClick(marker); } this.setState({ activeFeature: marker }); }; handleClusterClick = (cluster) => { const { markers } = this.props; const clusterSet = JSON.parse(cluster.properties.markerids); const unbreakableCluster = () => { // all markers are clustered at the same zoom level const singleZoomCluster = isSingleLevelArray(clusterSet); if (!singleZoomCluster) return false; const zoom = this.getMapboxGL().getZoom(); const clusterZoomLevel = nestedArrayDepth(clusterSet) + Math.ceil(zoom); // the cluster cannot uncluster even at max zoom return clusterZoomLevel >= CLUSTER_MAX_ZOOM; }; if (unbreakableCluster()) { this.setState({ activeFeature: cluster }); } else { const clusterMarkerIds = flattenDeep(clusterSet); const clusteredMarkers = markers.filter(marker => clusterMarkerIds.indexOf(marker.id) !== -1); this.fitMarkers(clusteredMarkers); } }; fitMarkers = (markers) => { if (!markers.length) return; this.map.fitBounds( minLngLatBounds(markers.map(marker => marker.lngLat)), { padding: { top: 20, bottom: 20, left: 50, right: 50 }, offset: [0, 20] }, ); }; easeTo = (lngLat) => { const [lng, lat] = lngLat; const zoom = this.getMapboxGL().getZoom(); const nextLat = lat + ((MOVE_TO_MARKER_MAX_LAT_OFFSET * 2) / Math.pow(2, zoom)); const nextCenter = [lng, nextLat]; this.map.easeTo({ center: nextCenter }); }; unmountActiveMarker = () => { const activeMarker = this.activeMarker; if (!activeMarker) return; unmountComponentAtNode(activeMarker.getElement()); activeMarker.remove(); this.activeMarker = null; }; markerPopupElement = (lngLat) => { if (!this.activeMarker) { this.activeMarker = new mapboxgl.Marker().setLngLat(lngLat).addTo(this.getMapboxGL()); } else { this.activeMarker.setLngLat(lngLat); } const element = this.activeMarker.getElement(); element.className = cx(css.marker, css.markerActive); return element; }; zoomIn = () => { this.map.zoomIn(); }; zoomOut = () => { this.map.zoomOut(); }; renderMarkerPopup = (activeFeature) => { const { MarkerComponent, GroupMarkerComponent, markers } = this.props; const lngLat = activeFeature.geometry.coordinates; this.easeTo(lngLat); const element = this.markerPopupElement(lngLat); if (activeFeature.properties.cluster) { const clusterSet = JSON.parse(activeFeature.properties.markerids); const clusterMarkerIds = flattenDeep(clusterSet); const clusteredMarkers = markers.filter(marker => clusterMarkerIds.indexOf(marker.id) !== -1); renderSubtreeIntoContainer( this, <MarkerContainer key={`${this.id}-activeMarker`} MarkerComponent={GroupMarkerComponent} props={{ group: clusteredMarkers.map(marker => marker.props) }} />, element ); } else { const marker = markers.find(m => m.id === activeFeature.properties.id); renderSubtreeIntoContainer( this, <MarkerContainer key={`${this.id}-activeMarker`} MarkerComponent={MarkerComponent} props={marker.props} />, element ); } }; render() { const { markers: _markers, MarkerComponent: _MarkerComponent, ...rest } = this.props; return ( <BaseMap ref={(c) => { this.map = c; }} onMapLoad={this.handleMapLoad} {...rest} onClick={this.handleMapClick} /> ); } }