Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions examples/worker-marker-clustering/README.md
Original file line number Diff line number Diff line change
@@ -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 <LoadingSpinner />;

return clusters.map(feature => <Marker key={feature.id} feature={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.
24 changes: 24 additions & 0 deletions examples/worker-marker-clustering/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Worker-based Marker Clustering - @vis.gl/react-google-maps</title>
<link rel="stylesheet" href="../examples.css" />
<link rel="stylesheet" href="./src/style.css" />
<style>
body {
margin: 0;
font-family: sans-serif;
}
#app {
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/app.tsx"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/worker-marker-clustering/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
250 changes: 250 additions & 0 deletions examples/worker-marker-clustering/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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<FeatureCollection<
Point,
PointProperties
> | null>(null);
const [selectedFeature, setSelectedFeature] =
useState<ClusterFeature<PointProperties> | null>(null);
const [selectedMarker, setSelectedMarker] =
useState<google.maps.marker.AdvancedMarkerElement | null>(null);

// Generate random points
useEffect(() => {
console.log(`Generating ${pointCount.toLocaleString()} random points...`);
const data = generateRandomPoints(pointCount, INITIAL_CENTER, 0.5);
setGeojson(data);
}, [pointCount]);

return (
<APIProvider apiKey={API_KEY!}>
<Map
mapId="worker-clustering-demo"
defaultCenter={INITIAL_CENTER}
defaultZoom={INITIAL_ZOOM}
gestureHandling="greedy"
disableDefaultUI>
{geojson && (
<ClusteredMarkers
geojson={geojson}
onFeatureClick={(feature, marker) => {
setSelectedFeature(feature);
setSelectedMarker(marker);
}}
/>
)}

{selectedFeature && selectedMarker && (
<InfoWindow
anchor={selectedMarker}
onCloseClick={() => {
setSelectedFeature(null);
setSelectedMarker(null);
}}>
<FeatureInfo feature={selectedFeature} />
</InfoWindow>
)}
</Map>

<ControlPanel
pointCount={pointCount}
onPointCountChange={setPointCount}
/>
</APIProvider>
);
};

type ClusteredMarkersProps = {
geojson: FeatureCollection<Point, PointProperties>;
onFeatureClick: (
feature: ClusterFeature<PointProperties>,
marker: google.maps.marker.AdvancedMarkerElement
) => void;
};

const ClusteredMarkers = ({geojson, onFeatureClick}: ClusteredMarkersProps) => {
const map = useMap();
const viewport = useMapViewport({padding: 100});

const {clusters, isLoading, error} = useSuperclusterWorker<PointProperties>(
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<PointProperties>,
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 <div className="error">Error: {error}</div>;
}

return (
<>
{isLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<div>
Clustering {geojson.features.length.toLocaleString()} points...
</div>
</div>
)}

{clusters.map(feature => {
const [lng, lat] = feature.geometry.coordinates;
const props = feature.properties;
const isCluster = 'cluster' in props && props.cluster;

return (
<AdvancedMarker
key={feature.id ?? `${lat}-${lng}`}
position={{lat, lng}}
onClick={e => {
const marker =
e.target as unknown as google.maps.marker.AdvancedMarkerElement;
handleMarkerClick(feature, marker);
}}>
{isCluster ? (
<ClusterMarker count={(props as ClusterProperties).point_count} />
) : (
<PointMarker />
)}
</AdvancedMarker>
);
})}
</>
);
};

const ClusterMarker = ({count}: {count: number}) => {
const size = Math.min(60, 30 + Math.log10(count) * 15);

return (
<div
className="cluster-marker"
style={{
width: size,
height: size,
fontSize: size / 3
}}>
{count >= 1000 ? `${Math.round(count / 1000)}k` : count}
</div>
);
};

const PointMarker = () => {
return <div className="point-marker" />;
};

const FeatureInfo = ({feature}: {feature: ClusterFeature<PointProperties>}) => {
const props = feature.properties;

if ('cluster' in props && props.cluster) {
return (
<div>
<strong>Cluster</strong>
<p>{props.point_count} points</p>
</div>
);
}

return (
<div>
<strong>{(props as PointProperties).name}</strong>
<p>ID: {(props as PointProperties).id}</p>
</div>
);
};

const root = createRoot(document.getElementById('app')!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

export default App;
Loading