@vis.gl/react-google-maps
Version:
React components and hooks for the Google Maps JavaScript API
405 lines (352 loc) • 12.3 kB
text/typescript
/**
* useSuperclusterWorker - Web Worker-based clustering hook
*
* This hook provides an interface for running Supercluster in a Web Worker,
* preventing main thread blocking when clustering large datasets (10k+ markers).
*
* @remarks
* Usage requires:
* 1. Install supercluster: `npm install supercluster @types/supercluster`
* 2. Create a worker file in your app (see worker-marker-clustering example)
* 3. Pass the worker URL to this hook
*
* @see {@link https://github.com/visgl/react-google-maps/tree/main/examples/worker-marker-clustering}
*
* @example
* ```tsx
* const workerUrl = new URL('./clustering.worker.ts', import.meta.url);
* const { bbox, zoom } = useMapViewport({ padding: 100 });
* const { clusters, isLoading } = useSuperclusterWorker(
* geojson,
* { radius: 80, maxZoom: 16 },
* { bbox, zoom },
* workerUrl
* );
* ```
*/
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
// ============================================================================
// GeoJSON Types (inline to avoid external dependency)
// ============================================================================
/** GeoJSON Bounding Box [west, south, east, north] */
export type BBox = [number, number, number, number];
/** GeoJSON Point geometry */
export interface PointGeometry {
type: 'Point';
coordinates: [number, number];
}
/** GeoJSON Feature */
export interface GeoFeature<P = Record<string, unknown>> {
type: 'Feature';
id?: string | number;
geometry: PointGeometry;
properties: P;
}
/** GeoJSON FeatureCollection */
export interface GeoFeatureCollection<P = Record<string, unknown>> {
type: 'FeatureCollection';
features: GeoFeature<P>[];
}
// ============================================================================
// Supercluster Types (inline to avoid external dependency)
// ============================================================================
/** Supercluster options */
export interface SuperclusterOptions {
/** Min zoom level to generate clusters */
minZoom?: number;
/** Max zoom level to cluster points */
maxZoom?: number;
/** Minimum points to form a cluster */
minPoints?: number;
/** Cluster radius in pixels */
radius?: number;
/** Tile extent (radius is calculated relative to it) */
extent?: number;
/** Whether to generate numeric ids for clusters */
generateId?: boolean;
}
/** Properties added to cluster features by Supercluster */
export interface ClusterProperties {
cluster: true;
cluster_id: number;
point_count: number;
point_count_abbreviated: string | number;
}
/** A cluster or point feature returned by Supercluster */
export type ClusterFeature<P = Record<string, unknown>> =
| GeoFeature<P>
| GeoFeature<ClusterProperties>;
// ============================================================================
// Worker Message Types
// ============================================================================
type WorkerMessage =
| {type: 'init'; options: SuperclusterOptions}
| {type: 'load'; features: GeoFeature[]}
| {type: 'getClusters'; bbox: BBox; zoom: number; requestId: number}
| {type: 'getLeaves'; clusterId: number; requestId: number; limit?: number}
| {type: 'getChildren'; clusterId: number; requestId: number}
| {type: 'getClusterExpansionZoom'; clusterId: number; requestId: number};
type WorkerResponse =
| {type: 'ready'}
| {type: 'loaded'; count: number}
| {type: 'clusters'; clusters: ClusterFeature[]; requestId: number}
| {type: 'leaves'; leaves: GeoFeature[]; requestId: number}
| {type: 'children'; children: ClusterFeature[]; requestId: number}
| {type: 'expansionZoom'; zoom: number; requestId: number}
| {type: 'error'; message: string; requestId?: number};
// ============================================================================
// Hook Types
// ============================================================================
export interface SuperclusterViewport {
/** Bounding box [west, south, east, north] */
bbox: BBox;
/** Zoom level (will be floored to integer) */
zoom: number;
}
export interface UseSuperclusterWorkerResult<P = Record<string, unknown>> {
/** Current clusters/markers for the viewport */
clusters: ClusterFeature<P>[];
/** True while loading data or calculating clusters */
isLoading: boolean;
/** Error message if worker failed */
error: string | null;
/** Get all leaf features in a cluster */
getLeaves: (clusterId: number, limit?: number) => Promise<GeoFeature<P>[]>;
/** Get immediate children of a cluster */
getChildren: (clusterId: number) => Promise<ClusterFeature<P>[]>;
/** Get zoom level at which a cluster expands */
getClusterExpansionZoom: (clusterId: number) => Promise<number>;
}
// ============================================================================
// Hook Implementation
// ============================================================================
// Check if Web Workers are supported
const supportsWorker = typeof Worker !== 'undefined';
/**
* Hook for running Supercluster in a Web Worker
*
* @param geojson - GeoJSON FeatureCollection with Point features
* @param options - Supercluster configuration options
* @param viewport - Current map viewport (bbox and zoom)
* @param workerUrl - URL to the clustering worker file
* @returns Clustering results and utility functions
*/
export function useSuperclusterWorker<P = Record<string, unknown>>(
geojson: GeoFeatureCollection<P> | null,
options: SuperclusterOptions,
viewport: SuperclusterViewport,
workerUrl: URL | string
): UseSuperclusterWorkerResult<P> {
// Initialize state with environment check
const initialError = useMemo(
() =>
supportsWorker ? null : 'Web Workers not supported in this environment',
[]
);
const [clusters, setClusters] = useState<ClusterFeature<P>[]>([]);
const [isLoading, setIsLoading] = useState(supportsWorker);
const [error, setError] = useState<string | null>(initialError);
const workerRef = useRef<Worker | null>(null);
const requestIdRef = useRef(0);
const pendingRequestsRef = useRef<
Map<
number,
{resolve: (value: unknown) => void; reject: (error: Error) => void}
>
>(new Map());
const isReadyRef = useRef(false);
const dataLoadedRef = useRef(false);
const optionsRef = useRef(options);
const loadingDataRef = useRef(false);
// Update options ref in effect to avoid accessing during render
useEffect(() => {
optionsRef.current = options;
}, [options]);
// Initialize worker
useEffect(() => {
if (!supportsWorker) return;
let worker: Worker;
try {
worker = new Worker(workerUrl, {type: 'module'});
} catch (e) {
// Worker creation can fail synchronously, we need to report this error
// eslint-disable-next-line react-hooks/set-state-in-effect
setError(
`Failed to create worker: ${e instanceof Error ? e.message : 'Unknown error'}`
);
setIsLoading(false);
return;
}
workerRef.current = worker;
// Capture ref values for cleanup
const pendingRequests = pendingRequestsRef.current;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const response = event.data;
switch (response.type) {
case 'ready':
isReadyRef.current = true;
break;
case 'loaded':
dataLoadedRef.current = true;
loadingDataRef.current = false;
break;
case 'clusters':
setClusters(response.clusters as ClusterFeature<P>[]);
setIsLoading(false);
break;
case 'leaves':
case 'children':
case 'expansionZoom': {
const pending = pendingRequests.get(response.requestId);
if (pending) {
pendingRequests.delete(response.requestId);
if (response.type === 'leaves') {
pending.resolve(response.leaves);
} else if (response.type === 'children') {
pending.resolve(response.children);
} else {
pending.resolve(response.zoom);
}
}
break;
}
case 'error':
setError(response.message);
setIsLoading(false);
if (response.requestId !== undefined) {
const pending = pendingRequests.get(response.requestId);
if (pending) {
pendingRequests.delete(response.requestId);
pending.reject(new Error(response.message));
}
}
break;
}
};
worker.onerror = err => {
setError(err.message || 'Worker error');
setIsLoading(false);
};
// Initialize with options
const initMessage: WorkerMessage = {
type: 'init',
options: optionsRef.current
};
worker.postMessage(initMessage);
return () => {
worker.terminate();
workerRef.current = null;
isReadyRef.current = false;
dataLoadedRef.current = false;
pendingRequests.clear();
};
}, [workerUrl]);
// Load data when geojson changes
useEffect(() => {
const worker = workerRef.current;
if (!worker || !geojson) return;
// Mark as loading via ref to avoid effect issues
loadingDataRef.current = true;
dataLoadedRef.current = false;
const loadMessage: WorkerMessage = {
type: 'load',
features: geojson.features as GeoFeature[]
};
worker.postMessage(loadMessage);
}, [geojson]);
// Get clusters when viewport or data changes
useEffect(() => {
const worker = workerRef.current;
if (!worker || !geojson) return;
// Wait a tick to ensure data is loaded
const timeoutId = setTimeout(() => {
const requestId = ++requestIdRef.current;
const message: WorkerMessage = {
type: 'getClusters',
bbox: viewport.bbox,
zoom: Math.floor(viewport.zoom),
requestId
};
worker.postMessage(message);
}, 0);
return () => clearTimeout(timeoutId);
}, [viewport, geojson]);
const getLeaves = useCallback(
(clusterId: number, limit?: number): Promise<GeoFeature<P>[]> => {
return new Promise((resolve, reject) => {
const worker = workerRef.current;
if (!worker) {
reject(new Error('Worker not initialized'));
return;
}
const requestId = ++requestIdRef.current;
pendingRequestsRef.current.set(requestId, {
resolve: resolve as (value: unknown) => void,
reject
});
const message: WorkerMessage = {
type: 'getLeaves',
clusterId,
requestId,
limit
};
worker.postMessage(message);
});
},
[]
);
const getChildren = useCallback(
(clusterId: number): Promise<ClusterFeature<P>[]> => {
return new Promise((resolve, reject) => {
const worker = workerRef.current;
if (!worker) {
reject(new Error('Worker not initialized'));
return;
}
const requestId = ++requestIdRef.current;
pendingRequestsRef.current.set(requestId, {
resolve: resolve as (value: unknown) => void,
reject
});
const message: WorkerMessage = {
type: 'getChildren',
clusterId,
requestId
};
worker.postMessage(message);
});
},
[]
);
const getClusterExpansionZoom = useCallback(
(clusterId: number): Promise<number> => {
return new Promise((resolve, reject) => {
const worker = workerRef.current;
if (!worker) {
reject(new Error('Worker not initialized'));
return;
}
const requestId = ++requestIdRef.current;
pendingRequestsRef.current.set(requestId, {
resolve: resolve as (value: unknown) => void,
reject
});
const message: WorkerMessage = {
type: 'getClusterExpansionZoom',
clusterId,
requestId
};
worker.postMessage(message);
});
},
[]
);
return {
clusters,
isLoading,
error,
getLeaves,
getChildren,
getClusterExpansionZoom
};
}