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
JavaScript
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 };