Skip to content

Commit dd007c8

Browse files
docs: add example for marker clustering with web-worker (#891)
1 parent 90acb5e commit dd007c8

13 files changed

Lines changed: 1799 additions & 0 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Worker-based Marker Clustering
2+
3+
This example demonstrates how to use Web Workers for clustering large datasets
4+
(10k-100k+ markers) without blocking the main thread.
5+
6+
## The Problem
7+
8+
When using Supercluster on the main thread with large datasets:
9+
10+
- `clusterer.load()` blocks the UI for several seconds
11+
- Map becomes unresponsive during clustering calculations
12+
- Users experience "frozen" interfaces
13+
14+
## The Solution
15+
16+
This example uses the `useSuperclusterWorker` hook which:
17+
18+
- Runs all Supercluster operations in a Web Worker
19+
- Keeps the main thread free for smooth map interactions
20+
- Provides loading state for UI feedback
21+
22+
## Key Files
23+
24+
- `src/clustering.worker.ts` - Web Worker that runs Supercluster
25+
- `src/hooks/use-map-viewport.ts` - Hook to track map viewport bounds and zoom
26+
- `src/hooks/use-supercluster-worker.ts` - Hook for Web Worker-based clustering
27+
- `src/app.tsx` - Main application using the clustering hooks
28+
29+
## Usage
30+
31+
```tsx
32+
import {useMapViewport} from './hooks/use-map-viewport';
33+
import {useSuperclusterWorker} from './hooks/use-supercluster-worker';
34+
35+
// Create worker URL (Vite handles bundling)
36+
const workerUrl = new URL('./clustering.worker.ts', import.meta.url);
37+
38+
function ClusteredMarkers({geojson}) {
39+
const viewport = useMapViewport({padding: 100});
40+
41+
const {clusters, isLoading, error} = useSuperclusterWorker(
42+
geojson,
43+
{radius: 120, maxZoom: 16},
44+
viewport,
45+
workerUrl
46+
);
47+
48+
if (isLoading) return <LoadingSpinner />;
49+
50+
return clusters.map(feature => <Marker key={feature.id} feature={feature} />);
51+
}
52+
```
53+
54+
## Running Locally
55+
56+
```bash
57+
# Install dependencies
58+
npm install
59+
60+
# Set your Google Maps API key
61+
export GOOGLE_MAPS_API_KEY=your_key_here
62+
63+
# Start development server (with local library)
64+
npm run start-local
65+
66+
# Or start standalone
67+
npm start
68+
```
69+
70+
## Performance Comparison
71+
72+
| Points | Main Thread | Web Worker |
73+
| ------- | ------------ | ---------- |
74+
| 10,000 | ~500ms block | No block |
75+
| 50,000 | ~2s block | No block |
76+
| 100,000 | ~5s block | No block |
77+
78+
The Web Worker approach keeps the UI responsive regardless of dataset size,
79+
with clustering happening asynchronously in the background.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Worker-based Marker Clustering - @vis.gl/react-google-maps</title>
7+
<link rel="stylesheet" href="../examples.css" />
8+
<link rel="stylesheet" href="./src/style.css" />
9+
<style>
10+
body {
11+
margin: 0;
12+
font-family: sans-serif;
13+
}
14+
#app {
15+
width: 100vw;
16+
height: 100vh;
17+
}
18+
</style>
19+
</head>
20+
<body>
21+
<div id="app"></div>
22+
<script type="module" src="./src/app.tsx"></script>
23+
</body>
24+
</html>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "worker-marker-clustering-example",
3+
"type": "module",
4+
"dependencies": {
5+
"@types/geojson": "^7946.0.14",
6+
"@types/supercluster": "^7.1.3",
7+
"@vis.gl/react-google-maps": "latest",
8+
"react": "^19.0.0",
9+
"react-dom": "^19.0.0",
10+
"supercluster": "^8.0.1",
11+
"vite": "^7.1.7"
12+
},
13+
"scripts": {
14+
"start": "vite",
15+
"start-local": "vite --config ../vite.config.local.js",
16+
"build": "vite build"
17+
}
18+
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
/**
2+
* Worker-based Marker Clustering Example
3+
*
4+
* This example demonstrates how to use Web Workers for clustering
5+
* large datasets (10k-100k+ markers) without blocking the main thread.
6+
*
7+
* Key features:
8+
* - Clustering runs in a Web Worker
9+
* - Main thread stays responsive during data loading
10+
* - Loading indicator while clustering
11+
* - Performance metrics display
12+
*/
13+
14+
import React, {useEffect, useState, useCallback} from 'react';
15+
import {createRoot} from 'react-dom/client';
16+
17+
import {
18+
APIProvider,
19+
Map,
20+
useMap,
21+
AdvancedMarker,
22+
InfoWindow
23+
} from '@vis.gl/react-google-maps';
24+
25+
import {useMapViewport} from './hooks/use-map-viewport';
26+
import {
27+
useSuperclusterWorker,
28+
type ClusterFeature,
29+
type ClusterProperties
30+
} from './hooks/use-supercluster-worker';
31+
32+
import {ControlPanel} from './control-panel';
33+
import {generateRandomPoints} from './generate-points';
34+
import type {FeatureCollection, Point} from 'geojson';
35+
36+
// Worker URL - Vite will handle bundling this
37+
const workerUrl = new URL('./clustering.worker.ts', import.meta.url);
38+
39+
const API_KEY =
40+
globalThis.GOOGLE_MAPS_API_KEY ?? process.env.GOOGLE_MAPS_API_KEY;
41+
42+
// Supercluster options
43+
// Increased radius from 80 to 120 to reduce the number of markers rendered
44+
const CLUSTER_OPTIONS = {
45+
radius: 120,
46+
maxZoom: 16,
47+
minPoints: 2
48+
};
49+
50+
// Initial map center (San Francisco)
51+
const INITIAL_CENTER = {lat: 37.7749, lng: -122.4194};
52+
const INITIAL_ZOOM = 10;
53+
54+
type PointProperties = {
55+
id: string;
56+
name: string;
57+
};
58+
59+
const App = () => {
60+
const [pointCount, setPointCount] = useState(10000);
61+
const [geojson, setGeojson] = useState<FeatureCollection<
62+
Point,
63+
PointProperties
64+
> | null>(null);
65+
const [selectedFeature, setSelectedFeature] =
66+
useState<ClusterFeature<PointProperties> | null>(null);
67+
const [selectedMarker, setSelectedMarker] =
68+
useState<google.maps.marker.AdvancedMarkerElement | null>(null);
69+
70+
// Generate random points
71+
useEffect(() => {
72+
console.log(`Generating ${pointCount.toLocaleString()} random points...`);
73+
const data = generateRandomPoints(pointCount, INITIAL_CENTER, 0.5);
74+
setGeojson(data);
75+
}, [pointCount]);
76+
77+
return (
78+
<APIProvider apiKey={API_KEY!}>
79+
<Map
80+
mapId="worker-clustering-demo"
81+
defaultCenter={INITIAL_CENTER}
82+
defaultZoom={INITIAL_ZOOM}
83+
gestureHandling="greedy"
84+
disableDefaultUI>
85+
{geojson && (
86+
<ClusteredMarkers
87+
geojson={geojson}
88+
onFeatureClick={(feature, marker) => {
89+
setSelectedFeature(feature);
90+
setSelectedMarker(marker);
91+
}}
92+
/>
93+
)}
94+
95+
{selectedFeature && selectedMarker && (
96+
<InfoWindow
97+
anchor={selectedMarker}
98+
onCloseClick={() => {
99+
setSelectedFeature(null);
100+
setSelectedMarker(null);
101+
}}>
102+
<FeatureInfo feature={selectedFeature} />
103+
</InfoWindow>
104+
)}
105+
</Map>
106+
107+
<ControlPanel
108+
pointCount={pointCount}
109+
onPointCountChange={setPointCount}
110+
/>
111+
</APIProvider>
112+
);
113+
};
114+
115+
type ClusteredMarkersProps = {
116+
geojson: FeatureCollection<Point, PointProperties>;
117+
onFeatureClick: (
118+
feature: ClusterFeature<PointProperties>,
119+
marker: google.maps.marker.AdvancedMarkerElement
120+
) => void;
121+
};
122+
123+
const ClusteredMarkers = ({geojson, onFeatureClick}: ClusteredMarkersProps) => {
124+
const map = useMap();
125+
const viewport = useMapViewport({padding: 100});
126+
127+
const {clusters, isLoading, error} = useSuperclusterWorker<PointProperties>(
128+
geojson,
129+
CLUSTER_OPTIONS,
130+
viewport,
131+
workerUrl
132+
);
133+
134+
// Log performance info
135+
useEffect(() => {
136+
if (!isLoading && clusters.length > 0) {
137+
console.log(`Rendered ${clusters.length} clusters/markers`);
138+
}
139+
}, [isLoading, clusters.length]);
140+
141+
const handleMarkerClick = useCallback(
142+
(
143+
feature: ClusterFeature<PointProperties>,
144+
marker: google.maps.marker.AdvancedMarkerElement
145+
) => {
146+
const props = feature.properties as ClusterProperties | PointProperties;
147+
148+
// If it's a cluster, zoom in
149+
if ('cluster' in props && props.cluster) {
150+
const [lng, lat] = feature.geometry.coordinates;
151+
map?.setCenter({lat, lng});
152+
map?.setZoom((map.getZoom() || 10) + 2);
153+
return;
154+
}
155+
156+
// Otherwise, show info
157+
onFeatureClick(feature, marker);
158+
},
159+
[map, onFeatureClick]
160+
);
161+
162+
if (error) {
163+
return <div className="error">Error: {error}</div>;
164+
}
165+
166+
return (
167+
<>
168+
{isLoading && (
169+
<div className="loading-overlay">
170+
<div className="loading-spinner" />
171+
<div>
172+
Clustering {geojson.features.length.toLocaleString()} points...
173+
</div>
174+
</div>
175+
)}
176+
177+
{clusters.map(feature => {
178+
const [lng, lat] = feature.geometry.coordinates;
179+
const props = feature.properties;
180+
const isCluster = 'cluster' in props && props.cluster;
181+
182+
return (
183+
<AdvancedMarker
184+
key={feature.id ?? `${lat}-${lng}`}
185+
position={{lat, lng}}
186+
onClick={e => {
187+
const marker =
188+
e.target as unknown as google.maps.marker.AdvancedMarkerElement;
189+
handleMarkerClick(feature, marker);
190+
}}>
191+
{isCluster ? (
192+
<ClusterMarker count={(props as ClusterProperties).point_count} />
193+
) : (
194+
<PointMarker />
195+
)}
196+
</AdvancedMarker>
197+
);
198+
})}
199+
</>
200+
);
201+
};
202+
203+
const ClusterMarker = ({count}: {count: number}) => {
204+
const size = Math.min(60, 30 + Math.log10(count) * 15);
205+
206+
return (
207+
<div
208+
className="cluster-marker"
209+
style={{
210+
width: size,
211+
height: size,
212+
fontSize: size / 3
213+
}}>
214+
{count >= 1000 ? `${Math.round(count / 1000)}k` : count}
215+
</div>
216+
);
217+
};
218+
219+
const PointMarker = () => {
220+
return <div className="point-marker" />;
221+
};
222+
223+
const FeatureInfo = ({feature}: {feature: ClusterFeature<PointProperties>}) => {
224+
const props = feature.properties;
225+
226+
if ('cluster' in props && props.cluster) {
227+
return (
228+
<div>
229+
<strong>Cluster</strong>
230+
<p>{props.point_count} points</p>
231+
</div>
232+
);
233+
}
234+
235+
return (
236+
<div>
237+
<strong>{(props as PointProperties).name}</strong>
238+
<p>ID: {(props as PointProperties).id}</p>
239+
</div>
240+
);
241+
};
242+
243+
const root = createRoot(document.getElementById('app')!);
244+
root.render(
245+
<React.StrictMode>
246+
<App />
247+
</React.StrictMode>
248+
);
249+
250+
export default App;

0 commit comments

Comments
 (0)