([-180, -90, 180, 90]);
+ const [zoom, setZoom] = useState(0);
+
+ useEffect(() => {
+ if (!map) return;
+
+ const updateViewport = () => {
+ const bounds = map.getBounds();
+ const currentZoom = map.getZoom();
+ const projection = map.getProjection();
+
+ if (!bounds || currentZoom === undefined || !projection) return;
+
+ const sw = bounds.getSouthWest();
+ const ne = bounds.getNorthEast();
+
+ const paddingDegrees = degreesPerPixel(currentZoom) * padding;
+
+ const n = Math.min(90, ne.lat() + paddingDegrees);
+ const s = Math.max(-90, sw.lat() - paddingDegrees);
+
+ const w = sw.lng() - paddingDegrees;
+ const e = ne.lng() + paddingDegrees;
+
+ setBbox([w, s, e, n]);
+ setZoom(currentZoom);
+ };
+
+ // Update on map idle (after pan/zoom completes)
+ const listener = map.addListener('idle', updateViewport);
+
+ // Initial update
+ updateViewport();
+
+ return () => listener.remove();
+ }, [map, padding]);
+
+ return {bbox, zoom};
+}
diff --git a/examples/worker-marker-clustering/src/hooks/use-supercluster-worker.ts b/examples/worker-marker-clustering/src/hooks/use-supercluster-worker.ts
new file mode 100644
index 00000000..13a4f199
--- /dev/null
+++ b/examples/worker-marker-clustering/src/hooks/use-supercluster-worker.ts
@@ -0,0 +1,402 @@
+/**
+ * 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
+ *
+ * @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> {
+ type: 'Feature';
+ id?: string | number;
+ geometry: PointGeometry;
+ properties: P;
+}
+
+/** GeoJSON FeatureCollection */
+export interface GeoFeatureCollection
> {
+ type: 'FeatureCollection';
+ features: GeoFeature
[];
+}
+
+// ============================================================================
+// 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
> =
+ | GeoFeature
+ | GeoFeature;
+
+// ============================================================================
+// 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> {
+ /** Current clusters/markers for the viewport */
+ clusters: ClusterFeature
[];
+ /** 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[]>;
+ /** Get immediate children of a cluster */
+ getChildren: (clusterId: number) => Promise[]>;
+ /** Get zoom level at which a cluster expands */
+ getClusterExpansionZoom: (clusterId: number) => Promise;
+}
+
+// ============================================================================
+// 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>(
+ geojson: GeoFeatureCollection
| null,
+ options: SuperclusterOptions,
+ viewport: SuperclusterViewport,
+ workerUrl: URL | string
+): UseSuperclusterWorkerResult
{
+ // Initialize state with environment check
+ const initialError = useMemo(
+ () =>
+ supportsWorker ? null : 'Web Workers not supported in this environment',
+ []
+ );
+
+ const [clusters, setClusters] = useState[]>([]);
+ const [isLoading, setIsLoading] = useState(supportsWorker);
+ const [error, setError] = useState(initialError);
+
+ const workerRef = useRef(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) => {
+ 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[]);
+ 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[]> => {
+ 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[]> => {
+ 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 => {
+ 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
+ };
+}
diff --git a/examples/worker-marker-clustering/src/style.css b/examples/worker-marker-clustering/src/style.css
new file mode 100644
index 00000000..e186982c
--- /dev/null
+++ b/examples/worker-marker-clustering/src/style.css
@@ -0,0 +1,152 @@
+/* Cluster marker */
+.cluster-marker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border: 3px solid white;
+ border-radius: 50%;
+ color: white;
+ font-weight: bold;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+ cursor: pointer;
+ transition: transform 0.15s ease;
+}
+
+.cluster-marker:hover {
+ transform: scale(1.1);
+}
+
+/* Individual point marker */
+.point-marker {
+ width: 12px;
+ height: 12px;
+ background: #4285f4;
+ border: 2px solid white;
+ border-radius: 50%;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ cursor: pointer;
+ transition: transform 0.15s ease;
+}
+
+.point-marker:hover {
+ transform: scale(1.3);
+}
+
+/* Loading overlay */
+.loading-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(255, 255, 255, 0.95);
+ padding: 24px 32px;
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ z-index: 1000;
+ font-size: 14px;
+ color: #333;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid #e0e0e0;
+ border-top-color: #667eea;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* Control panel */
+.control-panel {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ background: white;
+ padding: 16px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
+ max-width: 280px;
+ z-index: 100;
+}
+
+.control-panel h3 {
+ margin: 0 0 16px 0;
+ font-size: 16px;
+ color: #333;
+}
+
+.control-group {
+ margin-bottom: 16px;
+}
+
+.control-group label {
+ display: block;
+ margin-bottom: 6px;
+ font-size: 13px;
+ color: #666;
+}
+
+.control-group select {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 14px;
+ cursor: pointer;
+}
+
+.control-group select:focus {
+ outline: none;
+ border-color: #667eea;
+}
+
+.control-panel .info {
+ font-size: 12px;
+ color: #666;
+ line-height: 1.5;
+ padding: 12px;
+ background: #f5f5f5;
+ border-radius: 4px;
+ margin-bottom: 12px;
+}
+
+.control-panel .info p {
+ margin: 0 0 8px 0;
+}
+
+.control-panel .info p:last-child {
+ margin-bottom: 0;
+}
+
+.control-panel .instructions {
+ font-size: 12px;
+ color: #888;
+}
+
+.control-panel .instructions p {
+ margin: 4px 0;
+}
+
+/* Error message */
+.error {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: #fee;
+ color: #c00;
+ padding: 16px 24px;
+ border-radius: 8px;
+ border: 1px solid #fcc;
+}
diff --git a/examples/worker-marker-clustering/vite.config.js b/examples/worker-marker-clustering/vite.config.js
new file mode 100644
index 00000000..0c52e24e
--- /dev/null
+++ b/examples/worker-marker-clustering/vite.config.js
@@ -0,0 +1,12 @@
+import {defineConfig} from 'vite';
+
+export default defineConfig({
+ define: {
+ 'process.env.GOOGLE_MAPS_API_KEY': JSON.stringify(
+ process.env.GOOGLE_MAPS_API_KEY
+ )
+ },
+ worker: {
+ format: 'es'
+ }
+});
diff --git a/src/hooks/use-map-viewport.ts b/src/hooks/use-map-viewport.ts
new file mode 100644
index 00000000..6685e1fe
--- /dev/null
+++ b/src/hooks/use-map-viewport.ts
@@ -0,0 +1,92 @@
+/**
+ * useMapViewport - Hook to track map viewport bounds and zoom level
+ *
+ * Returns the current bounding box and zoom level of the map, updating
+ * whenever the map becomes idle after panning or zooming.
+ *
+ * @example
+ * ```tsx
+ * const { bbox, zoom } = useMapViewport({ padding: 100 });
+ * const { clusters } = useSuperclusterWorker(geojson, options, { bbox, zoom }, workerUrl);
+ * ```
+ */
+
+import {useEffect, useState} from 'react';
+import {useMap} from './use-map';
+
+/** Bounding box [west, south, east, north] */
+export type ViewportBBox = [number, number, number, number];
+
+export interface MapViewportOptions {
+ /**
+ * Padding in pixels to extend the bounding box beyond the visible viewport.
+ * Useful for pre-loading markers that are just outside the view.
+ * @default 0
+ */
+ padding?: number;
+}
+
+export interface MapViewport {
+ /** Bounding box [west, south, east, north] */
+ bbox: ViewportBBox;
+ /** Current zoom level */
+ zoom: number;
+}
+
+/**
+ * Calculates degrees per pixel at a given zoom level.
+ * Used to convert pixel padding to geographic distance.
+ */
+function degreesPerPixel(zoomLevel: number): number {
+ // 360° divided by the number of pixels at the zoom-level
+ return 360 / (Math.pow(2, zoomLevel) * 256);
+}
+
+/**
+ * Hook to track map viewport (bounding box and zoom)
+ *
+ * @param options - Configuration options
+ * @returns Current viewport with bbox and zoom
+ */
+export function useMapViewport(options: MapViewportOptions = {}): MapViewport {
+ const {padding = 0} = options;
+ const map = useMap();
+ const [bbox, setBbox] = useState([-180, -90, 180, 90]);
+ const [zoom, setZoom] = useState(0);
+
+ useEffect(() => {
+ if (!map) return;
+
+ const updateViewport = () => {
+ const bounds = map.getBounds();
+ const currentZoom = map.getZoom();
+ const projection = map.getProjection();
+
+ if (!bounds || currentZoom === undefined || !projection) return;
+
+ const sw = bounds.getSouthWest();
+ const ne = bounds.getNorthEast();
+
+ const paddingDegrees = degreesPerPixel(currentZoom) * padding;
+
+ const n = Math.min(90, ne.lat() + paddingDegrees);
+ const s = Math.max(-90, sw.lat() - paddingDegrees);
+
+ const w = sw.lng() - paddingDegrees;
+ const e = ne.lng() + paddingDegrees;
+
+ setBbox([w, s, e, n]);
+ setZoom(currentZoom);
+ };
+
+ // Update on map idle (after pan/zoom completes)
+ const listener = map.addListener('idle', updateViewport);
+
+ // Initial update
+ updateViewport();
+
+ return () => listener.remove();
+ }, [map, padding]);
+
+ return {bbox, zoom};
+}
diff --git a/src/hooks/use-supercluster-worker.ts b/src/hooks/use-supercluster-worker.ts
new file mode 100644
index 00000000..16d605e2
--- /dev/null
+++ b/src/hooks/use-supercluster-worker.ts
@@ -0,0 +1,404 @@
+/**
+ * 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> {
+ type: 'Feature';
+ id?: string | number;
+ geometry: PointGeometry;
+ properties: P;
+}
+
+/** GeoJSON FeatureCollection */
+export interface GeoFeatureCollection
> {
+ type: 'FeatureCollection';
+ features: GeoFeature
[];
+}
+
+// ============================================================================
+// 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
> =
+ | GeoFeature
+ | GeoFeature;
+
+// ============================================================================
+// 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> {
+ /** Current clusters/markers for the viewport */
+ clusters: ClusterFeature
[];
+ /** 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[]>;
+ /** Get immediate children of a cluster */
+ getChildren: (clusterId: number) => Promise[]>;
+ /** Get zoom level at which a cluster expands */
+ getClusterExpansionZoom: (clusterId: number) => Promise;
+}
+
+// ============================================================================
+// 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>(
+ geojson: GeoFeatureCollection
| null,
+ options: SuperclusterOptions,
+ viewport: SuperclusterViewport,
+ workerUrl: URL | string
+): UseSuperclusterWorkerResult
{
+ // Initialize state with environment check
+ const initialError = useMemo(
+ () =>
+ supportsWorker ? null : 'Web Workers not supported in this environment',
+ []
+ );
+
+ const [clusters, setClusters] = useState[]>([]);
+ const [isLoading, setIsLoading] = useState(supportsWorker);
+ const [error, setError] = useState(initialError);
+
+ const workerRef = useRef(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) => {
+ 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[]);
+ 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[]> => {
+ 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[]> => {
+ 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 => {
+ 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
+ };
+}