UNPKG

react-map-gl-supercluster

Version:

> The easiest way to get `react-map-gl` and `supercluster` to work together

253 lines (252 loc) 8.85 kB
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { DEV } from "esm-env"; import Supercluster from "supercluster"; //#region src/cluster-equality.ts function isClustersShallowEqual(clusters1, clusters2) { return clusters1 === clusters2 || clusters1.length === clusters2.length && clusters1.every((feature1, index) => { const feature2 = clusters2[index]; if (feature2 == null) return false; return feature1 === feature2 || feature1.type === feature2.type && feature1.id === feature2.id && isPointGeometryEqual(feature1.geometry, feature2.geometry); }); } function isPointGeometryEqual(a, b) { return a === b || a.type === b.type && isPositionEqual(a.coordinates, b.coordinates); } function isPositionEqual(a, b) { return a === b || a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; } //#endregion //#region src/map-state.ts function getMapState(map) { const mapBounds = map.getBounds(); if (mapBounds == null) return null; return { bounds: mapBounds.toArray().flat(), zoom: Math.round(map.getZoom()) }; } function isMapStateEqual(a, b) { if (a === b) return true; if (a == null || b == null) return false; return a.zoom === b.zoom && isBoundsEqual(a.bounds, b.bounds); } function isBoundsEqual(a, b) { return a === b || a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; } function expandBounds(bounds, padding) { const [west, south, east, north] = bounds; const paddingLng = (east - west) * padding; const paddingLat = (north - south) * padding; return [ west - paddingLng, south - paddingLat, east + paddingLng, north + paddingLat ]; } function containsBounds(outer, inner) { return outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]; } //#endregion //#region src/use-clusters.ts /** * Subscribes to map movements and returns clusters for the current viewport. * * The map is treated as an external store and the whole result is the snapshot: * `clusters` and `supercluster` stay atomic, and the result keeps its identity * (no re-render) while the visible clusters stay shallowly equal. * * With `boundsPadding > 0` clusters are queried for an expanded viewport and * reused while the map keeps moving inside it at the same rounded zoom. */ function useClusters(map, supercluster, boundsPadding = 0) { const subscribe = useCallback((onStoreChange) => { if (map == null) return noop; map.on("move", onStoreChange); return () => { map.off("move", onStoreChange); }; }, [map]); const { getSnapshot, getServerSnapshot } = useMemo(() => createClustersSnapshot(map, supercluster, boundsPadding), [ map, supercluster, boundsPadding ]); return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } function createClustersSnapshot(map, supercluster, boundsPadding) { const emptyResult = { clusters: [], supercluster }; const queriedArea = createQueriedArea(boundsPadding); let mapState = null; let result = emptyResult; const getSnapshot = () => { const nextMapState = map == null ? null : getMapState(map); if (isMapStateEqual(mapState, nextMapState)) return result; mapState = nextMapState; if (nextMapState != null && queriedArea.covers(nextMapState)) return result; let clusters; if (nextMapState == null) { queriedArea.reset(); clusters = []; } else clusters = supercluster.getClusters(queriedArea.update(nextMapState), nextMapState.zoom); if (!isClustersShallowEqual(result.clusters, clusters)) result = { clusters, supercluster }; return result; }; return { getSnapshot, getServerSnapshot: () => emptyResult }; } function createQueriedArea(boundsPadding) { let queried = null; const covers = (mapState) => boundsPadding > 0 && queried != null && queried.zoom === mapState.zoom && containsBounds(queried.bounds, mapState.bounds); const update = (mapState) => { const bounds = boundsPadding > 0 ? expandBounds(mapState.bounds, boundsPadding) : mapState.bounds; queried = { bounds, zoom: mapState.zoom }; return bounds; }; const reset = () => { queried = null; }; return { covers, update, reset }; } function noop() {} //#endregion //#region src/warn-reserved-cluster-key.ts /** * Dev-only. Warns once when input points carry the reserved `cluster` property * key — the types forbid it, but it slips through widened types and makes * `isCluster` misreport such points as clusters. */ function useWarnOnReservedClusterKey(points) { const state = useRef({ lastPoints: null, warned: false }).current; if (state.warned || state.lastPoints === points) return; state.lastPoints = points; if (points.some((point) => "cluster" in point.properties)) { state.warned = true; console.warn("react-map-gl-supercluster: point properties must not define \"cluster\". The key is reserved for generated clusters and makes isCluster misreport such points."); } } //#endregion //#region src/warn-unstable-options.ts const UNSTABLE_FUNCTION_OPTION_WARNING_THRESHOLD = 3; /** * Dev-only. Warns once per option when `map`/`reduce` change reference on several * consecutive renders — each change recreates the `supercluster` index. */ function useWarnOnUnstableFunctionOptions(options) { const stateRef = useRef(null); stateRef.current ?? (stateRef.current = { lastOptions: options, trackMap: createFunctionOptionTracker("map", options.map), trackReduce: createFunctionOptionTracker("reduce", options.reduce) }); const state = stateRef.current; if (state.lastOptions === options) return; state.lastOptions = options; state.trackMap(options.map); state.trackReduce(options.reduce); } function createFunctionOptionTracker(name, initialValue) { let previous = initialValue; let consecutiveChanges = 0; let warned = false; return (value) => { consecutiveChanges = previous === value || typeof previous !== "function" || typeof value !== "function" ? 0 : consecutiveChanges + 1; previous = value; if (warned || consecutiveChanges < UNSTABLE_FUNCTION_OPTION_WARNING_THRESHOLD) return; warned = true; console.warn(`react-map-gl-supercluster: option "${name}" changed between renders. This recreates the Supercluster index. Wrap it in useCallback or define it outside the component.`); }; } //#endregion //#region src/use-supercluster-index.ts /** Builds a loaded `supercluster` index, memoized by shallow points equality and structurally equal options. */ const useSuperclusterIndex = DEV ? useSuperclusterIndexWithWarnings : useSuperclusterIndexImpl; function useSuperclusterIndexWithWarnings(points, options) { useWarnOnReservedClusterKey(points); useWarnOnUnstableFunctionOptions(options); return useSuperclusterIndexImpl(points, options); } function useSuperclusterIndexImpl(outerPoints, outerOptions) { const nextOptions = normalizeOptions(outerOptions); const optionsRef = useRef(nextOptions); if (!isOptionsEqual(optionsRef.current, nextOptions)) optionsRef.current = nextOptions; const options = optionsRef.current; const pointsRef = useRef(outerPoints); if (!isPointsShallowEqual(pointsRef.current, outerPoints)) pointsRef.current = outerPoints; const points = pointsRef.current; return useMemo(() => { const index = new Supercluster(options); index.load(points); return index; }, [points, options]); } function isPointsShallowEqual(a, b) { if (a === b) return true; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false; return true; } function normalizeOptions(options) { const { minZoom = 0, maxZoom = 16, radius = 40, minPoints = 2, extent = 512, nodeSize = 64, generateId = false, map, reduce } = options; return { minZoom, maxZoom, radius, minPoints, extent, nodeSize, generateId, map, reduce }; } function isOptionsEqual(a, b) { return a === b || a.minZoom === b.minZoom && a.maxZoom === b.maxZoom && a.radius === b.radius && a.minPoints === b.minPoints && a.extent === b.extent && a.nodeSize === b.nodeSize && a.generateId === b.generateId && a.map === b.map && a.reduce === b.reduce; } //#endregion //#region src/use-supercluster.ts /** * Creates a renderer-specific `useSupercluster` hook. */ function create(useMap) { return function useSupercluster(points, options = {}) { return useClusters(useResolvedMap(options.mapRef), useSuperclusterIndex(points, options), options.boundsPadding); }; function useResolvedMap(mapRef) { const maps = useMap(); return mapRef ?? maps.current ?? null; } } //#endregion //#region src/is-cluster.ts /** * Narrows a feature returned from `useSupercluster` to a generated cluster. * * @example * ```tsx * clusters.map((feature) => (isCluster(feature) ? renderCluster(feature) : renderPoint(feature))) * ``` */ function isCluster(feature) { return feature.properties.cluster === true; } //#endregion export { create as n, isCluster as t };