diff --git a/examples/worker-marker-clustering/README.md b/examples/worker-marker-clustering/README.md new file mode 100644 index 00000000..9cfc49fd --- /dev/null +++ b/examples/worker-marker-clustering/README.md @@ -0,0 +1,79 @@ +# Worker-based Marker Clustering + +This example demonstrates how to use Web Workers for clustering large datasets +(10k-100k+ markers) without blocking the main thread. + +## The Problem + +When using Supercluster on the main thread with large datasets: + +- `clusterer.load()` blocks the UI for several seconds +- Map becomes unresponsive during clustering calculations +- Users experience "frozen" interfaces + +## The Solution + +This example uses the `useSuperclusterWorker` hook which: + +- Runs all Supercluster operations in a Web Worker +- Keeps the main thread free for smooth map interactions +- Provides loading state for UI feedback + +## Key Files + +- `src/clustering.worker.ts` - Web Worker that runs Supercluster +- `src/hooks/use-map-viewport.ts` - Hook to track map viewport bounds and zoom +- `src/hooks/use-supercluster-worker.ts` - Hook for Web Worker-based clustering +- `src/app.tsx` - Main application using the clustering hooks + +## Usage + +```tsx +import {useMapViewport} from './hooks/use-map-viewport'; +import {useSuperclusterWorker} from './hooks/use-supercluster-worker'; + +// Create worker URL (Vite handles bundling) +const workerUrl = new URL('./clustering.worker.ts', import.meta.url); + +function ClusteredMarkers({geojson}) { + const viewport = useMapViewport({padding: 100}); + + const {clusters, isLoading, error} = useSuperclusterWorker( + geojson, + {radius: 120, maxZoom: 16}, + viewport, + workerUrl + ); + + if (isLoading) return ; + + return clusters.map(feature => ); +} +``` + +## Running Locally + +```bash +# Install dependencies +npm install + +# Set your Google Maps API key +export GOOGLE_MAPS_API_KEY=your_key_here + +# Start development server (with local library) +npm run start-local + +# Or start standalone +npm start +``` + +## Performance Comparison + +| Points | Main Thread | Web Worker | +| ------- | ------------ | ---------- | +| 10,000 | ~500ms block | No block | +| 50,000 | ~2s block | No block | +| 100,000 | ~5s block | No block | + +The Web Worker approach keeps the UI responsive regardless of dataset size, +with clustering happening asynchronously in the background. diff --git a/examples/worker-marker-clustering/index.html b/examples/worker-marker-clustering/index.html new file mode 100644 index 00000000..6499040e --- /dev/null +++ b/examples/worker-marker-clustering/index.html @@ -0,0 +1,24 @@ + + + + + + Worker-based Marker Clustering - @vis.gl/react-google-maps + + + + + +
+ + + diff --git a/examples/worker-marker-clustering/package.json b/examples/worker-marker-clustering/package.json new file mode 100644 index 00000000..864a8285 --- /dev/null +++ b/examples/worker-marker-clustering/package.json @@ -0,0 +1,18 @@ +{ + "name": "worker-marker-clustering-example", + "type": "module", + "dependencies": { + "@types/geojson": "^7946.0.14", + "@types/supercluster": "^7.1.3", + "@vis.gl/react-google-maps": "latest", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "supercluster": "^8.0.1", + "vite": "^7.1.7" + }, + "scripts": { + "start": "vite", + "start-local": "vite --config ../vite.config.local.js", + "build": "vite build" + } +} diff --git a/examples/worker-marker-clustering/src/app.tsx b/examples/worker-marker-clustering/src/app.tsx new file mode 100644 index 00000000..3364addc --- /dev/null +++ b/examples/worker-marker-clustering/src/app.tsx @@ -0,0 +1,250 @@ +/** + * Worker-based Marker Clustering Example + * + * This example demonstrates how to use Web Workers for clustering + * large datasets (10k-100k+ markers) without blocking the main thread. + * + * Key features: + * - Clustering runs in a Web Worker + * - Main thread stays responsive during data loading + * - Loading indicator while clustering + * - Performance metrics display + */ + +import React, {useEffect, useState, useCallback} from 'react'; +import {createRoot} from 'react-dom/client'; + +import { + APIProvider, + Map, + useMap, + AdvancedMarker, + InfoWindow +} from '@vis.gl/react-google-maps'; + +import {useMapViewport} from './hooks/use-map-viewport'; +import { + useSuperclusterWorker, + type ClusterFeature, + type ClusterProperties +} from './hooks/use-supercluster-worker'; + +import {ControlPanel} from './control-panel'; +import {generateRandomPoints} from './generate-points'; +import type {FeatureCollection, Point} from 'geojson'; + +// Worker URL - Vite will handle bundling this +const workerUrl = new URL('./clustering.worker.ts', import.meta.url); + +const API_KEY = + globalThis.GOOGLE_MAPS_API_KEY ?? process.env.GOOGLE_MAPS_API_KEY; + +// Supercluster options +// Increased radius from 80 to 120 to reduce the number of markers rendered +const CLUSTER_OPTIONS = { + radius: 120, + maxZoom: 16, + minPoints: 2 +}; + +// Initial map center (San Francisco) +const INITIAL_CENTER = {lat: 37.7749, lng: -122.4194}; +const INITIAL_ZOOM = 10; + +type PointProperties = { + id: string; + name: string; +}; + +const App = () => { + const [pointCount, setPointCount] = useState(10000); + const [geojson, setGeojson] = useState | null>(null); + const [selectedFeature, setSelectedFeature] = + useState | null>(null); + const [selectedMarker, setSelectedMarker] = + useState(null); + + // Generate random points + useEffect(() => { + console.log(`Generating ${pointCount.toLocaleString()} random points...`); + const data = generateRandomPoints(pointCount, INITIAL_CENTER, 0.5); + setGeojson(data); + }, [pointCount]); + + return ( + + + {geojson && ( + { + setSelectedFeature(feature); + setSelectedMarker(marker); + }} + /> + )} + + {selectedFeature && selectedMarker && ( + { + setSelectedFeature(null); + setSelectedMarker(null); + }}> + + + )} + + + + + ); +}; + +type ClusteredMarkersProps = { + geojson: FeatureCollection; + onFeatureClick: ( + feature: ClusterFeature, + marker: google.maps.marker.AdvancedMarkerElement + ) => void; +}; + +const ClusteredMarkers = ({geojson, onFeatureClick}: ClusteredMarkersProps) => { + const map = useMap(); + const viewport = useMapViewport({padding: 100}); + + const {clusters, isLoading, error} = useSuperclusterWorker( + geojson, + CLUSTER_OPTIONS, + viewport, + workerUrl + ); + + // Log performance info + useEffect(() => { + if (!isLoading && clusters.length > 0) { + console.log(`Rendered ${clusters.length} clusters/markers`); + } + }, [isLoading, clusters.length]); + + const handleMarkerClick = useCallback( + ( + feature: ClusterFeature, + marker: google.maps.marker.AdvancedMarkerElement + ) => { + const props = feature.properties as ClusterProperties | PointProperties; + + // If it's a cluster, zoom in + if ('cluster' in props && props.cluster) { + const [lng, lat] = feature.geometry.coordinates; + map?.setCenter({lat, lng}); + map?.setZoom((map.getZoom() || 10) + 2); + return; + } + + // Otherwise, show info + onFeatureClick(feature, marker); + }, + [map, onFeatureClick] + ); + + if (error) { + return
Error: {error}
; + } + + return ( + <> + {isLoading && ( +
+
+
+ Clustering {geojson.features.length.toLocaleString()} points... +
+
+ )} + + {clusters.map(feature => { + const [lng, lat] = feature.geometry.coordinates; + const props = feature.properties; + const isCluster = 'cluster' in props && props.cluster; + + return ( + { + const marker = + e.target as unknown as google.maps.marker.AdvancedMarkerElement; + handleMarkerClick(feature, marker); + }}> + {isCluster ? ( + + ) : ( + + )} + + ); + })} + + ); +}; + +const ClusterMarker = ({count}: {count: number}) => { + const size = Math.min(60, 30 + Math.log10(count) * 15); + + return ( +
+ {count >= 1000 ? `${Math.round(count / 1000)}k` : count} +
+ ); +}; + +const PointMarker = () => { + return
; +}; + +const FeatureInfo = ({feature}: {feature: ClusterFeature}) => { + const props = feature.properties; + + if ('cluster' in props && props.cluster) { + return ( +
+ Cluster +

{props.point_count} points

+
+ ); + } + + return ( +
+ {(props as PointProperties).name} +

ID: {(props as PointProperties).id}

+
+ ); +}; + +const root = createRoot(document.getElementById('app')!); +root.render( + + + +); + +export default App; diff --git a/examples/worker-marker-clustering/src/clustering.worker.ts b/examples/worker-marker-clustering/src/clustering.worker.ts new file mode 100644 index 00000000..43e09bf6 --- /dev/null +++ b/examples/worker-marker-clustering/src/clustering.worker.ts @@ -0,0 +1,164 @@ +/** + * Supercluster Web Worker + * + * This worker runs all clustering computations off the main thread, + * enabling smooth map interactions even with 100k+ markers. + * + * Message Protocol: + * - init: Initialize Supercluster with options + * - load: Load GeoJSON features (CPU-intensive) + * - getClusters: Get clusters for a viewport + * - getLeaves: Get all points in a cluster + * - getChildren: Get immediate children of a cluster + * - getClusterExpansionZoom: Get zoom at which cluster expands + */ + +import Supercluster from 'supercluster'; +import type {Feature, Point, GeoJsonProperties} from 'geojson'; +import type {ClusterProperties} from 'supercluster'; + +// Type definitions for messages +type SuperclusterOptions = { + minZoom?: number; + maxZoom?: number; + minPoints?: number; + radius?: number; + extent?: number; + generateId?: boolean; +}; + +type WorkerMessage = + | {type: 'init'; options: SuperclusterOptions} + | {type: 'load'; features: Feature[]} + | { + type: 'getClusters'; + bbox: [number, number, number, number]; + zoom: number; + requestId: number; + } + | {type: 'getLeaves'; clusterId: number; requestId: number; limit?: number} + | {type: 'getChildren'; clusterId: number; requestId: number} + | {type: 'getClusterExpansionZoom'; clusterId: number; requestId: number}; + +// Clusterer instance +let clusterer: Supercluster | null = null; + +// Handle messages from main thread +self.onmessage = (event: MessageEvent) => { + const message = event.data; + + try { + switch (message.type) { + case 'init': { + clusterer = new Supercluster(message.options); + self.postMessage({type: 'ready'}); + break; + } + + case 'load': { + if (!clusterer) { + self.postMessage({ + type: 'error', + message: 'Clusterer not initialized' + }); + return; + } + + // This is the CPU-intensive operation that would block the main thread + const startTime = performance.now(); + clusterer.load(message.features); + const loadTime = performance.now() - startTime; + + self.postMessage({ + type: 'loaded', + count: message.features.length, + loadTime: Math.round(loadTime) + }); + break; + } + + case 'getClusters': { + if (!clusterer) { + self.postMessage({ + type: 'error', + message: 'Clusterer not initialized', + requestId: message.requestId + }); + return; + } + + const clusters = clusterer.getClusters(message.bbox, message.zoom); + self.postMessage({ + type: 'clusters', + clusters, + requestId: message.requestId + }); + break; + } + + case 'getLeaves': { + if (!clusterer) { + self.postMessage({ + type: 'error', + message: 'Clusterer not initialized', + requestId: message.requestId + }); + return; + } + + const limit = message.limit ?? Infinity; + const leaves = clusterer.getLeaves(message.clusterId, limit); + self.postMessage({ + type: 'leaves', + leaves, + requestId: message.requestId + }); + break; + } + + case 'getChildren': { + if (!clusterer) { + self.postMessage({ + type: 'error', + message: 'Clusterer not initialized', + requestId: message.requestId + }); + return; + } + + const children = clusterer.getChildren(message.clusterId); + self.postMessage({ + type: 'children', + children, + requestId: message.requestId + }); + break; + } + + case 'getClusterExpansionZoom': { + if (!clusterer) { + self.postMessage({ + type: 'error', + message: 'Clusterer not initialized', + requestId: message.requestId + }); + return; + } + + const zoom = clusterer.getClusterExpansionZoom(message.clusterId); + self.postMessage({ + type: 'expansionZoom', + zoom, + requestId: message.requestId + }); + break; + } + } + } catch (error) { + self.postMessage({ + type: 'error', + message: error instanceof Error ? error.message : 'Unknown error', + requestId: 'requestId' in message ? message.requestId : undefined + }); + } +}; diff --git a/examples/worker-marker-clustering/src/control-panel.tsx b/examples/worker-marker-clustering/src/control-panel.tsx new file mode 100644 index 00000000..544fd18d --- /dev/null +++ b/examples/worker-marker-clustering/src/control-panel.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +type ControlPanelProps = { + pointCount: number; + onPointCountChange: (count: number) => void; +}; + +const POINT_OPTIONS = [1000, 5000, 10000, 25000, 50000, 100000]; + +export const ControlPanel = ({ + pointCount, + onPointCountChange +}: ControlPanelProps) => { + return ( +
+

Worker Clustering Demo

+ +
+ + +
+ +
+

+ This example uses a Web Worker to run Supercluster + clustering off the main thread. +

+

+ Try selecting 50k or 100k points - the map stays responsive while + clustering happens in the background! +

+
+ +
+

+ Click a cluster to zoom in +

+

+ Click a point to see details +

+
+
+ ); +}; diff --git a/examples/worker-marker-clustering/src/generate-points.ts b/examples/worker-marker-clustering/src/generate-points.ts new file mode 100644 index 00000000..6c6ffda6 --- /dev/null +++ b/examples/worker-marker-clustering/src/generate-points.ts @@ -0,0 +1,58 @@ +import type {FeatureCollection, Point} from 'geojson'; + +type PointProperties = { + id: string; + name: string; +}; + +/** + * Generate random GeoJSON points around a center location + */ +export function generateRandomPoints( + count: number, + center: {lat: number; lng: number}, + spread: number = 1 +): FeatureCollection { + const features = []; + + for (let i = 0; i < count; i++) { + // Generate random offset using normal distribution for more realistic clustering + const angle = Math.random() * 2 * Math.PI; + const distance = Math.abs(gaussianRandom() * spread); + + const lat = center.lat + distance * Math.cos(angle); + const lng = center.lng + distance * Math.sin(angle); + + features.push({ + type: 'Feature' as const, + id: `point-${i}`, + geometry: { + type: 'Point' as const, + coordinates: [lng, lat] as [number, number] + }, + properties: { + id: `point-${i}`, + name: `Location ${i + 1}` + } + }); + } + + return { + type: 'FeatureCollection', + features + }; +} + +/** + * Generate a random number with approximately normal distribution + * Using Box-Muller transform + */ +function gaussianRandom(): number { + let u = 0; + let v = 0; + + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); +} diff --git a/examples/worker-marker-clustering/src/hooks/use-map-viewport.ts b/examples/worker-marker-clustering/src/hooks/use-map-viewport.ts new file mode 100644 index 00000000..8462ca68 --- /dev/null +++ b/examples/worker-marker-clustering/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 '@vis.gl/react-google-maps'; + +/** 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/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 + }; +}