diff --git a/package.json b/package.json index c209608f..c8ed9639 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eqworks/react-maps", - "version": "0.7.1", + "version": "0.7.2", "description": "React maps", "author": "EQ Inc.", "license": "UNLICENSED", diff --git a/src/components/layers/poi-cluster.js b/src/components/layers/poi-cluster.js index b4c84a5b..25e6421f 100644 --- a/src/components/layers/poi-cluster.js +++ b/src/components/layers/poi-cluster.js @@ -10,6 +10,9 @@ import Supercluster from 'supercluster' import iconMapping from '../icons/cluster.json' import iconAtlas from '../icons/cluster.png' +import { getSuperclusterRadius } from '../../shared/utils' +import { CLUSTER_SIZE_SCALE, SUPERCLUSTER_ZOOM } from '../../constants' + /** * getIconName - sets icon name for clusters * @param { object } d - POI data point @@ -41,13 +44,11 @@ class IconClusterLayer extends CompositeLayer { static defaultProps = { id: 'icon-cluster', getPosition: d => d.geometry.coordinates, - pickable: true, iconAtlas, iconMapping, - sizeScale: 40, - superclusterZoom: 20, - getSuperclusterRadius: (viewportZoom, sizeScale) => - viewportZoom > 15 ? sizeScale / 2 : sizeScale, + sizeScale: CLUSTER_SIZE_SCALE, + superclusterZoom: SUPERCLUSTER_ZOOM, + getSuperclusterRadius, visible: false, } @@ -61,7 +62,7 @@ class IconClusterLayer extends CompositeLayer { if (rebuildIndex) { const index = new Supercluster({ maxZoom: props.superclusterZoom, - radius: props.getSuperclusterRadius(this.context.viewport.zoom, props.sizeScale), + radius: props.getSuperclusterRadius(this.props.zoom), }) index.load( props.data.map(d => ({ @@ -72,7 +73,7 @@ class IconClusterLayer extends CompositeLayer { this.setState({ index }) } - const z = Math.floor(this.context.viewport.zoom) + const z = Math.floor(this.props.zoom) if (rebuildIndex || z !== this.state.z) { this.setState({ data: this.state.index.getClusters([-180, -85, 180, 85], z), @@ -101,7 +102,6 @@ class IconClusterLayer extends CompositeLayer { iconMapping, sizeScale, getPosition, - pickable, visible, ...props } = this.props @@ -116,8 +116,8 @@ class IconClusterLayer extends CompositeLayer { getPosition, getIcon: d => getIconName(d), getSize: getIconSize(), - pickable, visible, + pickable: visible, ...props, }), ) diff --git a/src/components/layers/poi-geojson.js b/src/components/layers/poi-geojson.js index 0ecfec65..00bdb226 100644 --- a/src/components/layers/poi-geojson.js +++ b/src/components/layers/poi-geojson.js @@ -6,7 +6,6 @@ import { TYPE_RADIUS } from '../../constants' const defaultProps = { id: 'geojson-layer', - pickable: true, stroked: true, visible: false, filled: true, @@ -38,7 +37,7 @@ const defaultProps = { * @param { number } param.POIType - POI type * @returns { instanceOf GeoJsonLayer } */ -const POIGeoJson = ({ data, mapProps, POIType, ...props }) => +const POIGeoJson = ({ data, mapProps, POIType, visible, ...props }) => new GeoJsonLayer({ data, ...defaultProps, @@ -63,6 +62,8 @@ const POIGeoJson = ({ data, mapProps, POIType, ...props }) => getLineWidth: () => mapProps.lineWidth, opacity: mapProps.opacity, transitions: data.length === 1 ? { ...defaultProps.transitions } : {}, + visible, + pickable: visible, ...props, }) diff --git a/src/components/layers/poi-icon.js b/src/components/layers/poi-icon.js index 175baeed..dab74338 100644 --- a/src/components/layers/poi-icon.js +++ b/src/components/layers/poi-icon.js @@ -13,7 +13,6 @@ const defaultProps = { getIcon: () => 'marker', getPosition: d => d.geometry.coordinates, getSize: 5, - pickable: true, visible: false, } @@ -22,10 +21,12 @@ const defaultProps = { * @param { object } props - props object for passing data and other attributes to POIIcon * @returns { instanceOf IconLayer} */ -const POIIcon = (props) => +const POIIcon = ({ visible, ...props }) => new IconLayer({ ...defaultProps, sizeScale: props.data.length === 1 ? 12 : (props.data.length < 8 ? 8 : 5), + visible, + pickable: visible, ...props, }) diff --git a/src/components/layers/poi-polygon.js b/src/components/layers/poi-polygon.js index 6134b7e5..08c57995 100644 --- a/src/components/layers/poi-polygon.js +++ b/src/components/layers/poi-polygon.js @@ -4,7 +4,6 @@ import { PolygonLayer } from '@deck.gl/layers' const defaultProps = { id: 'polygon-layer', - pickable: true, stroked: true, filled: true, wireframe: true, @@ -20,7 +19,7 @@ const defaultProps = { * @param { array } param.data - data array * @returns { instanceOf PolygonLayer } */ -const POIPolygon = ({ mapProps, data, ...props }) => +const POIPolygon = ({ mapProps, data, visible, ...props }) => new PolygonLayer({ data, ...defaultProps, @@ -28,6 +27,8 @@ const POIPolygon = ({ mapProps, data, ...props }) => getLineColor: () => mapProps.lineColour, getLineWidth: () => mapProps.lineWidth, opacity: mapProps.opacity, + visible, + pickable: visible, ...props, }) export default POIPolygon diff --git a/src/components/poi-map/index.js b/src/components/poi-map/index.js index ccf9feb1..2cb69178 100644 --- a/src/components/poi-map/index.js +++ b/src/components/poi-map/index.js @@ -14,6 +14,7 @@ import DeckGL from '@deck.gl/react' import { FlyToInterpolator } from '@deck.gl/core' import { StaticMap } from 'react-map-gl' import Geocoder from 'react-map-gl-geocoder' +import { WebMercatorViewport } from '@deck.gl/core' import { FormControlLabel } from '@material-ui/core' import { Switch } from '@eqworks/lumen-ui' @@ -23,14 +24,9 @@ import DrawButtonGroup from './draw-button-group' import MapTooltip from '../tooltip' import tooltipNode from '../tooltip/tooltip-node' -import { - processLayers, - setView, - createCircleFromPointRadius, - getCircleRadiusCentroid, -} from '../../shared/utils' +import { processLayers, isClusterZoomLevel } from './utils' +import { setView, createCircleFromPointRadius, getCircleRadiusCentroid } from '../../shared/utils' import { getCursor, truncate, formatDataPOI } from '../../utils' -import { useResizeObserver } from '../../hooks' import { typographyPropTypes, typographyDefaultProps, @@ -50,13 +46,24 @@ setup(React.createElement) const MapWrapper = styled('div')` ` -const SwitchContainer = styled('div')` +const SwitchContainerCluster = styled('div')` + position: absolute; + margin: 15px; + z-index: 1; + background-color: white; + border-radius: 3px; + padding: 5px; +` + +const SwitchContainerRadius = styled('div')` position: absolute; margin: 15px; + margin-top: ${props => props.clusterswitch ? 60 : 15}px; z-index: 1; background-color: white; border-radius: 3px; padding: 5px; + width: 154px; ` const DrawButtonContainer = styled('div')` @@ -78,7 +85,7 @@ const MapContainer = styled('div', forwardRef)` const INIT_VIEW_STATE = { pitch: 25, bearing: 0, - transitionDuration: 3000, + transitionDuration: 2000, transitionInterpolator: new FlyToInterpolator(), latitude: 52, longitude: -100, @@ -134,10 +141,17 @@ const POIMap = ({ const [allowDrawing, setAllowDrawing] = useState(true) const [onClickPayload, setOnClickPayload] = useState({}) const [hoverInfo, setHoverInfo] = useState(null) + const [zoom, setZoom] = useState(INIT_VIEW_STATE.zoom) + const [viewportBBOX, setViewportBBOX] = useState() const [showRadius, setShowRadius] = useState(false) + const [showClusters, setShowClusters] = useState(false) + const [clusterZoom, setClusterZoom] = useState(false) + // used to block reset of view state when we transition from the cluster to the icon layer + const [layerVisibleData, setLayerVisibleData] = useState() const mapContainerRef = useRef() + const deckRef = useRef() const mapRef = useRef() - const { width, height } = useResizeObserver(mapContainerRef) + const [{ width, height }, setDimensions] = useState({}) // React hook that sets POIType const POIType = useMemo(() => { @@ -178,7 +192,7 @@ const POIMap = ({ if (showRadius) { return ['POIGeoJson', 'POIIcon'] } - if (cluster) { + if (cluster && showClusters && clusterZoom) { return ['POICluster'] } return ['POIIcon'] @@ -191,8 +205,7 @@ const POIMap = ({ return ['POIGeoJson'] } return [] - }, [mode, activePOI, cluster, POIType, createDrawMode, showRadius, showIcon]) - + }, [mode, activePOI, cluster, showClusters, clusterZoom, POIType, createDrawMode, showRadius, showIcon]) // React Hook to handle setting up data for DeckGL layers useEffect(() => { @@ -351,12 +364,11 @@ const POIMap = ({ // FIX: FlyToInterpolator doesn't seem to be trigerred when transitioning from empty map to some data // React Hook to handle setting up viewState based on POIs coordinates and deck map container size useLayoutEffect(() => { - if (((data?.length && mapLayers.length) || - (mapMode === 'emptyMap' && !data?.length && !mapLayers.length)) && - width && height) { + if (((mapMode === 'emptyMap' && !data?.length) || data?.length) && + viewParam && mapMode && width && height) { viewStateDispatch(viewParam[mapMode]) } - }, [data, mapLayers, width, height, viewParam, mapMode]) + }, [data, width, height, viewParam, mapMode]) // React Hook to update viewState for onClick events useEffect(() => { @@ -438,19 +450,46 @@ const POIMap = ({ onHover, mode, POIType, + zoom, selectedFeatureIndexes, } }) } return [] - }, [mapLayers, layerPool, mapProps, data, updatePOI, onClick, onHover, mode, POIType, selectedFeatureIndexes]) + }, [ + mapLayers, + layerPool, + mapProps, + data, + updatePOI, + onClick, + onHover, + mode, + POIType, + zoom, + selectedFeatureIndexes, + ]) const getCurrentCursor = getCursor({ layers }) + // set state for clusterZoom + useEffect(() => { + if (cluster && showClusters && layerVisibleData?.length && viewportBBOX?.length && zoom) { + setClusterZoom(isClusterZoomLevel({ layerVisibleData, viewportBBOX, zoom })) + } + }, [cluster, showClusters, layerVisibleData, viewportBBOX, zoom]) + + // hide radius switch when we have clusters enabled and cluster level zoom + useEffect(() => { + if (cluster && clusterZoom && showClusters) { + setShowRadius(false) + } + }, [cluster, showClusters, clusterZoom]) + /** - * finalTooltipKeys - React hook that returns an object of keys for MapTooltip component - * @returns { object } - object of tooltip keys - * { name, id, metricKeys, metricAliases, nameAccessor, idAccessor, metricAccessor} - */ + * finalTooltipKeys - React hook that returns an object of keys for MapTooltip component + * @returns { object } - object of tooltip keys + * { name, id, metricKeys, metricAliases, nameAccessor, idAccessor, metricAccessor} + */ const finalTooltipKeys = useMemo(() => { const { id, idAccessor, name, nameAccessor } = tooltipKeys let metricKeysArray = tooltipKeys?.metricKeys || ['lon', 'lat'] @@ -465,9 +504,7 @@ const POIMap = ({ } }, [tooltipKeys, dataPropertyAccessor]) - /** - * mapCanRender - conditions to render the map - */ + // mapCanRender - conditions to render the map const mapCanRender = Boolean(useMemo(() => (mapLayers.includes('POIEditDraw') && data[0]?.properties?.poiType === TYPE_POLYGON.code) || (!mapLayers.includes('POIEditDraw') && data.length) || @@ -478,8 +515,23 @@ const POIMap = ({ return ( - {POIType === TYPE_RADIUS.code && !cluster && mode !=='edit' && !mode.startsWith('create-') && ( - + {POIType === TYPE_RADIUS.code && cluster && mapMode === 'display' && data?.length > 1 && ( + + setShowClusters(!showClusters)} + /> + } + label='Show Clusters' + /> + + )} + {POIType === TYPE_RADIUS.code && + ((cluster && showClusters && !clusterZoom) || (cluster && !showClusters) || !cluster) && + mapMode === 'display' && ( + 1 ? 'yes' : undefined}> - + )} {hoverInfo?.object && @@ -523,9 +575,17 @@ const POIMap = ({ )} {mapCanRender && ( { + const { height, width } = deckRef?.current?.deck + setDimensions({ height, width }) + }} + onResize={({ height, width }) => { + setDimensions({ height, width }) + }} /** * USE once nebula.gl fixes selectedFeatureIndex out of range value cases (ie [], null) * onClick for edit mode to select feature for editing @@ -545,6 +605,11 @@ const POIMap = ({ // data[0].properties.isOnMapEditing = false // } // }} + onViewStateChange={o => { + const { viewState } = o + setZoom(viewState.zoom) + setViewportBBOX(new WebMercatorViewport(viewState).getBounds()) + }} onInteractionStateChange={interactionState => { const{ inTransition } = interactionState if (inTransition) { @@ -555,6 +620,9 @@ const POIMap = ({ setHoverInfo(null) }} getCursor={getCurrentCursor} + onAfterRender={() => + setLayerVisibleData(deckRef?.current?.pickObjects({ x: 0, y: 0, width, height })) + } > + layerPool.map(layer => + mapLayers.includes(layer) ? + setLayer({ layer, props, visible: true }) : + setLayer({ layer, props, visible: false }), + ) + +/** + * setLayer - sets a map layer + * @param { object } param + * @param { string } param.layer - name of a layer found in src/components/layers/index.js + * @param { object } param.props - object of layer props + * @param { boolean } param.visible - boolean to be used to set a certain layer visible or not on the map + * @returns { instanceOf } - Deck.gl or Nebula.gl layer + */ +const setLayer = ({ layer, props, visible }) => + layer === 'POICluster' ? + new eqMapLayers[layer]({ ...props, visible }) : + eqMapLayers[layer]({ ...props, visible }) + +/** + * isClusterZoomLevel - determines if we should use cluster layer for the data in the current viewport + * @param { object } param + * @param { string } param.layerVisibleData - layer data displayed in the current viewport + * @param { string } param.viewportBBOX - bounding box coordinates for the current viewport + * @param { string } param.zoom - current map viewport zoom + * @returns { boolean } - boolean indicating whether we should show clusters on the map + */ +export const isClusterZoomLevel = ({ layerVisibleData, viewportBBOX, zoom }) => { + const visiblePOIs = layerVisibleData.reduce((agg, elem) => { + return elem.objects ? [...agg, ...elem.objects] : [...agg, elem.object] + }, []) + + if (visiblePOIs.length) { + const getPosition = d => d.geometry.coordinates + const index = new Supercluster({ + maxZoom: SUPERCLUSTER_ZOOM, + radius: getSuperclusterRadius({ zoom }), + }) + index.load( + visiblePOIs.map(d => ({ + geometry: { coordinates: getPosition(d) }, + properties: d.properties, + })), + ) + const z = Math.floor(zoom) + const clusterData = index.getClusters(viewportBBOX, z) + + return Boolean(clusterData.find(elem => elem?.properties?.cluster)) + } +} diff --git a/src/constants.js b/src/constants.js index 52445901..2922c7a3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -23,3 +23,6 @@ export const SCALES = { 'quantile': scaleQuantile, 'quantize': scaleQuantize, } + +export const CLUSTER_SIZE_SCALE = 40 +export const SUPERCLUSTER_ZOOM = 20 diff --git a/src/hooks/index.js b/src/hooks/index.js index 9817bb05..95a2c75d 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -292,27 +292,3 @@ export const useTimeline = (timestampInit, speedInterval) => { } export { useReport, useFullReport } from './report' - -/** - * useResizeObserver - returns the dimensions of a changing HTML element - * based on: https://github.com/plouc/nivo/blob/7d52c07/packages/core/src/hooks/useMeasure.js & - * https://github.com/EQWorks/snoke-builder-viz/pull/21/files - * @param { object } ref - React ref - * @returns { object } - dimensions { width, height } of an HTML element - */ -export const useResizeObserver = (ref) => { - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) - const observer = useMemo(() => - new ResizeObserver(([entry]) => setDimensions(entry.contentRect)) - ,[]) - - useEffect(() => { - if (ref.current) { - observer.observe(ref.current) - } - - return () => observer.disconnect() - }, [ref, observer]) - - return dimensions -} diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index 09396e53..1741978e 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -1,44 +1,15 @@ import { WebMercatorViewport } from '@deck.gl/core' -import * as eqMapLayers from '../../components/layers' import circle from '@turf/circle' import { point } from '@turf/helpers' import tCentroid from '@turf/centroid' import tBBox from '@turf/bbox' import tDistance from '@turf/distance' -import { SCALES } from '../../constants' +import { SCALES, CLUSTER_SIZE_SCALE } from '../../constants' import { color } from 'd3-color' import { extent } from 'd3-array' -/** - * processLayers - returns layers used by a map - * @param { object } param - * @param { array } param.mapLayers - array of layers to show on map - * @param { array } param.layerPool - array of all layers used by map in general - * @param { object } param.props - layers' props - * @returns { array } - array of Deck.gl and Nebula.gl layers used by a map - */ -export const processLayers = ({ mapLayers, layerPool, props }) => - layerPool.map(layer => - mapLayers.includes(layer) ? - setLayer({ layer, props, visible: true }) : - setLayer({ layer, props, visible: false }), - ) - -/** - * setLayer - sets a map layer - * @param { object } param - * @param { string } param.layer - name of a layer found in src/components/layers/index.js - * @param { object } param.props - object of layer props - * @param { boolean } param.visible - boolean to be used to set a certain layer visible or not on the map - * @returns { instanceOf } - Deck.gl or Nebula.gl layer - */ -const setLayer = ({ layer, props, visible }) => - layer === 'POICluster' ? - new eqMapLayers[layer]({ ...props, visible }) : - eqMapLayers[layer]({ ...props, visible }) - /** * setView - handles calculations of viewState lat, long, and zoom, based on * data coordinates and deck size @@ -297,3 +268,15 @@ export const getArrayGradientFillColors = ({ fillColors, opacity }) => */ export const setLegendOpacity = ({ opacity }) => opacity >= 1 ? 1 : (opacity > 0.6 ? 0.9 : opacity + 0.2) + +/** + * getSuperclusterRadius - determines cluster radius + * @param { object } param + * @param { number } param.zoom - viewstate zoom + * @param { number } param.sizeScale - scale for cluster radius size + * @returns { number } - cluster radius in pixels + */ +export const getSuperclusterRadius = ({ zoom, sizeScale = CLUSTER_SIZE_SCALE }) => + zoom > 15 ? + sizeScale / 2 : + sizeScale diff --git a/stories/poi.stories.js b/stories/poi.stories.js index 42a24976..5c2440fe 100644 --- a/stories/poi.stories.js +++ b/stories/poi.stories.js @@ -39,11 +39,11 @@ POIClusters.args = { cluster: true, ...displayArgs, } -POIClusters.storyName = 'Point POIs - clusters with POICluster layer' +POIClusters.storyName = 'Point POIs - using clusters with POICluster, POIIcon, & POIGeoJson layers' export const RadiiAndIcons = Template.bind({}) RadiiAndIcons.args = { POIData: POIsRadiiTo, ...displayArgs } -RadiiAndIcons.storyName = 'Point POIs - radii & icons with POIIcon & POIGeoJson layers' +RadiiAndIcons.storyName = 'Point POIs - radii & icons with POIIcon & POIGeoJson layers - no clusters' export const PointPOIOne = Template.bind({}) PointPOIOne.args = { POIData: [POIsRadiiTo[0]], ...displayArgs }