@elabs-ai/components-maps 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/dist/index.css +11 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.ts +442 -0
  6. package/dist/index.js +1475 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +66 -0
  9. package/src/index.ts +37 -0
  10. package/src/lib/arc-math.test.ts +41 -0
  11. package/src/lib/arc-math.ts +45 -0
  12. package/src/lib/merge-hover-paint.test.ts +36 -0
  13. package/src/lib/merge-hover-paint.ts +21 -0
  14. package/src/lib/use-token-color.ts +23 -0
  15. package/src/map-arc/index.ts +9 -0
  16. package/src/map-arc/map-arc.stories.tsx +58 -0
  17. package/src/map-arc/map-arc.tsx +294 -0
  18. package/src/map-canvas/index.ts +8 -0
  19. package/src/map-canvas/map-canvas-webgl-fallback.test.tsx +30 -0
  20. package/src/map-canvas/map-canvas.stories.tsx +54 -0
  21. package/src/map-canvas/map-canvas.test.tsx +93 -0
  22. package/src/map-canvas/map-canvas.tsx +349 -0
  23. package/src/map-canvas/map-context.ts +32 -0
  24. package/src/map-canvas/maps.css +15 -0
  25. package/src/map-canvas/use-resolved-basemap-theme.ts +77 -0
  26. package/src/map-cluster-layer/index.ts +1 -0
  27. package/src/map-cluster-layer/map-cluster-layer.stories.tsx +56 -0
  28. package/src/map-cluster-layer/map-cluster-layer.tsx +292 -0
  29. package/src/map-controls/index.ts +1 -0
  30. package/src/map-controls/map-controls.stories.tsx +43 -0
  31. package/src/map-controls/map-controls.test.tsx +56 -0
  32. package/src/map-controls/map-controls.tsx +220 -0
  33. package/src/map-geojson/index.ts +9 -0
  34. package/src/map-geojson/map-geojson.stories.tsx +124 -0
  35. package/src/map-geojson/map-geojson.tsx +274 -0
  36. package/src/map-marker/index.ts +12 -0
  37. package/src/map-marker/map-marker.stories.tsx +71 -0
  38. package/src/map-marker/map-marker.test.tsx +81 -0
  39. package/src/map-marker/map-marker.tsx +373 -0
  40. package/src/map-popup/index.ts +1 -0
  41. package/src/map-popup/map-popup.tsx +113 -0
  42. package/src/map-route/index.ts +1 -0
  43. package/src/map-route/map-route.stories.tsx +56 -0
  44. package/src/map-route/map-route.tsx +143 -0
  45. package/src/test-utils/maplibre-mock.ts +249 -0
@@ -0,0 +1,349 @@
1
+ "use client";
2
+
3
+ import MapLibreGL from "maplibre-gl";
4
+ import "maplibre-gl/dist/maplibre-gl.css";
5
+ import "./maps.css";
6
+ import {
7
+ forwardRef,
8
+ useCallback,
9
+ useEffect,
10
+ useImperativeHandle,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ type ReactNode,
15
+ } from "react";
16
+ import { Spinner, StatePanel } from "@elabs-ai/components-ui";
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+
19
+ import { MapContext, type BasemapTheme } from "./map-context";
20
+ import { useResolvedBasemapTheme } from "./use-resolved-basemap-theme";
21
+
22
+ /**
23
+ * Default basemaps: Carto's free light/dark GL styles. These serve ODbL-licensed
24
+ * OpenStreetMap data, which requires attribution on a public surface — see the
25
+ * `attributionControl` note in the map constructor below.
26
+ */
27
+ const defaultStyles = {
28
+ dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
29
+ light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
30
+ };
31
+
32
+ // A tile-less, dependency-free style with a transparent background. Use it for
33
+ // data visualizations (choropleths, arcs, dot maps) where you draw your own
34
+ // layers and don't need a street basemap: `<MapCanvas blank>`. The transparent
35
+ // background lets the themed container show through.
36
+ const blankMapStyle: MapLibreGL.StyleSpecification = {
37
+ version: 8,
38
+ sources: {},
39
+ layers: [
40
+ {
41
+ id: "background",
42
+ type: "background",
43
+ paint: { "background-color": "transparent" },
44
+ },
45
+ ],
46
+ };
47
+
48
+ /** Map viewport state. */
49
+ export interface MapViewport {
50
+ /** Center coordinates [longitude, latitude]. */
51
+ center: [number, number];
52
+ /** Zoom level. */
53
+ zoom: number;
54
+ /** Bearing (rotation) in degrees. */
55
+ bearing: number;
56
+ /** Pitch (tilt) in degrees. */
57
+ pitch: number;
58
+ }
59
+
60
+ export type MapStyleOption = string | MapLibreGL.StyleSpecification;
61
+
62
+ /** The imperative handle exposed by `<MapCanvas ref>`: the MapLibre map itself. */
63
+ export type MapCanvasRef = MapLibreGL.Map;
64
+
65
+ export type MapCanvasProps = {
66
+ children?: ReactNode;
67
+ /** Additional CSS classes for the map container. */
68
+ className?: string;
69
+ /**
70
+ * Basemap flavor. If not provided, it is derived from the active brand theme
71
+ * (`data-theme` + that theme's own `color-scheme`), then a `dark`/`light` root class,
72
+ * then the OS preference.
73
+ */
74
+ theme?: BasemapTheme;
75
+ /** Custom map styles for light and dark themes. Overrides the default Carto styles. */
76
+ styles?: {
77
+ light?: MapStyleOption;
78
+ dark?: MapStyleOption;
79
+ };
80
+ /**
81
+ * Use a transparent, tile-less basemap instead of the default Carto street
82
+ * basemap — a blank canvas. Used alone it renders nothing; add your own
83
+ * layers on top (`<MapGeoJSON>`, `<MapArc>`, markers, …). Ideal for data
84
+ * visualizations. Ignored when an explicit `styles` prop is provided.
85
+ */
86
+ blank?: boolean;
87
+ /** Map projection type. Use `{ type: "globe" }` for a 3D globe view. */
88
+ projection?: MapLibreGL.ProjectionSpecification;
89
+ /**
90
+ * Controlled viewport. When provided together with `onViewportChange`, the
91
+ * map becomes controlled and the viewport is driven by this prop.
92
+ */
93
+ viewport?: Partial<MapViewport>;
94
+ /**
95
+ * Callback fired continuously as the viewport changes (pan, zoom, rotate,
96
+ * pitch). Use standalone to observe changes, or with `viewport` for
97
+ * controlled mode.
98
+ */
99
+ onViewportChange?: (viewport: MapViewport) => void;
100
+ /** Show a loading overlay on the map (e.g. while the app fetches map data). */
101
+ loading?: boolean;
102
+ } & Omit<MapLibreGL.MapOptions, "container" | "style">;
103
+
104
+ function MapLoadingOverlay() {
105
+ return (
106
+ <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-xs">
107
+ <Spinner label="Loading map" className="size-5" />
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function getViewport(map: MapLibreGL.Map): MapViewport {
113
+ const center = map.getCenter();
114
+ return {
115
+ center: [center.lng, center.lat],
116
+ zoom: map.getZoom(),
117
+ bearing: map.getBearing(),
118
+ pitch: map.getPitch(),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The root map surface — a token/theme-aware MapLibre GL canvas. Compose the
124
+ * other `@elabs-ai/components-maps` components (markers, popups, controls, layers) as
125
+ * children; they reach the map through context (`useMap`).
126
+ *
127
+ * The ref exposes the raw MapLibre `Map` instance for imperative work
128
+ * (`flyTo`, `fitBounds`, …).
129
+ */
130
+ export const MapCanvas = forwardRef<MapCanvasRef, MapCanvasProps>(function MapCanvas(
131
+ {
132
+ children,
133
+ className,
134
+ theme: themeProp,
135
+ styles,
136
+ blank = false,
137
+ projection,
138
+ viewport,
139
+ onViewportChange,
140
+ loading = false,
141
+ ...props
142
+ },
143
+ ref,
144
+ ) {
145
+ const containerRef = useRef<HTMLDivElement>(null);
146
+ const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
147
+ const [initFailed, setInitFailed] = useState(false);
148
+ const [isLoaded, setIsLoaded] = useState(false);
149
+ const [isStyleLoaded, setIsStyleLoaded] = useState(false);
150
+ const currentStyleRef = useRef<MapStyleOption | null>(null);
151
+ const styleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
152
+ const internalUpdateRef = useRef(false);
153
+ const { resolvedTheme, themeKey } = useResolvedBasemapTheme(themeProp);
154
+
155
+ const isControlled = viewport !== undefined && onViewportChange !== undefined;
156
+
157
+ const onViewportChangeRef = useRef(onViewportChange);
158
+ onViewportChangeRef.current = onViewportChange;
159
+
160
+ const mapStyles = useMemo(() => {
161
+ // Explicit styles win. Otherwise `blank` opts into the transparent
162
+ // tile-less basemap; with neither, fall back to the Carto defaults.
163
+ if (styles) {
164
+ return {
165
+ dark: styles.dark ?? defaultStyles.dark,
166
+ light: styles.light ?? defaultStyles.light,
167
+ };
168
+ }
169
+ if (blank) {
170
+ return { dark: blankMapStyle, light: blankMapStyle };
171
+ }
172
+ return defaultStyles;
173
+ }, [styles, blank]);
174
+
175
+ // Expose the map instance to the parent component.
176
+ useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
177
+
178
+ const clearStyleTimeout = useCallback(() => {
179
+ if (styleTimeoutRef.current) {
180
+ clearTimeout(styleTimeoutRef.current);
181
+ styleTimeoutRef.current = null;
182
+ }
183
+ }, []);
184
+
185
+ // Initialize the map.
186
+ useEffect(() => {
187
+ if (!containerRef.current) return;
188
+
189
+ const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
190
+ currentStyleRef.current = initialStyle;
191
+
192
+ let map: MapLibreGL.Map;
193
+ try {
194
+ map = new MapLibreGL.Map({
195
+ container: containerRef.current,
196
+ style: initialStyle,
197
+ renderWorldCopies: false,
198
+ // Attribution control OFF by default — a maintainer decision for internal
199
+ // use, taken deliberately and recorded in CHANGELOG.md + the map-components
200
+ // rule. NOTE THE CONSTRAINT: the default Carto basemap serves OpenStreetMap
201
+ // data, which is ODbL-licensed and requires the credit, and Carto's terms
202
+ // require it too — so a surface that ships PUBLICLY on these tiles must turn
203
+ // it back on with `attributionControl={{ compact: true }}` (it wins through
204
+ // `...props` below), or move to tiles licensed without the requirement via
205
+ // `styles` / `blank`.
206
+ attributionControl: false,
207
+ ...props,
208
+ ...viewport,
209
+ });
210
+ } catch {
211
+ // MapLibre throws at construction when WebGL is unavailable (headless
212
+ // browsers without GPU, remote desktops). Degrade to a quiet panel
213
+ // instead of an unhandled render error.
214
+ setInitFailed(true);
215
+ return;
216
+ }
217
+
218
+ const styleDataHandler = () => {
219
+ clearStyleTimeout();
220
+ // Delay so the style is fully processed before layer operations — avoids
221
+ // race conditions on setStyle without force-updating every layer.
222
+ styleTimeoutRef.current = setTimeout(() => {
223
+ setIsStyleLoaded(true);
224
+ if (projection) {
225
+ map.setProjection(projection);
226
+ }
227
+ }, 100);
228
+ };
229
+ // No-op under the default (`attributionControl: false` above). This exists for
230
+ // the case a consumer turns the control back ON for a public surface: MapLibre
231
+ // paints even a COMPACT control expanded on first render (`<details open>` +
232
+ // `maplibregl-compact-show`), so the credits land as a text slab until someone
233
+ // clicks the toggle. Collapse it to the labelled ⓘ button instead.
234
+ const loadHandler = () => {
235
+ setIsLoaded(true);
236
+ map
237
+ .getContainer()
238
+ .querySelectorAll<HTMLDetailsElement>("details.maplibregl-ctrl-attrib[open]")
239
+ .forEach((el) => {
240
+ el.open = false;
241
+ });
242
+ };
243
+
244
+ // Viewport change handler — skip if triggered by an internal update.
245
+ const handleMove = () => {
246
+ if (internalUpdateRef.current) return;
247
+ onViewportChangeRef.current?.(getViewport(map));
248
+ };
249
+
250
+ map.on("load", loadHandler);
251
+ map.on("styledata", styleDataHandler);
252
+ map.on("move", handleMove);
253
+ setMapInstance(map);
254
+
255
+ return () => {
256
+ clearStyleTimeout();
257
+ map.off("load", loadHandler);
258
+ map.off("styledata", styleDataHandler);
259
+ map.off("move", handleMove);
260
+ map.remove();
261
+ setIsLoaded(false);
262
+ setIsStyleLoaded(false);
263
+ setMapInstance(null);
264
+ };
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: map options are init-time; style/theme/projection changes are synced by the effects below
266
+ }, []);
267
+
268
+ // Sync controlled viewport to the map.
269
+ useEffect(() => {
270
+ if (!mapInstance || !isControlled || !viewport) return;
271
+ if (mapInstance.isMoving()) return;
272
+
273
+ const current = getViewport(mapInstance);
274
+ const next = {
275
+ center: viewport.center ?? current.center,
276
+ zoom: viewport.zoom ?? current.zoom,
277
+ bearing: viewport.bearing ?? current.bearing,
278
+ pitch: viewport.pitch ?? current.pitch,
279
+ };
280
+
281
+ if (
282
+ next.center[0] === current.center[0] &&
283
+ next.center[1] === current.center[1] &&
284
+ next.zoom === current.zoom &&
285
+ next.bearing === current.bearing &&
286
+ next.pitch === current.pitch
287
+ ) {
288
+ return;
289
+ }
290
+
291
+ internalUpdateRef.current = true;
292
+ mapInstance.jumpTo(next);
293
+ internalUpdateRef.current = false;
294
+ }, [mapInstance, isControlled, viewport]);
295
+
296
+ // Swap the basemap style when the theme flips.
297
+ useEffect(() => {
298
+ if (!mapInstance || !resolvedTheme) return;
299
+
300
+ const newStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
301
+
302
+ if (currentStyleRef.current === newStyle) return;
303
+
304
+ clearStyleTimeout();
305
+ currentStyleRef.current = newStyle;
306
+ setIsStyleLoaded(false);
307
+
308
+ mapInstance.setStyle(newStyle, { diff: true });
309
+ }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);
310
+
311
+ // Sync projection when the prop changes after mount.
312
+ useEffect(() => {
313
+ if (!mapInstance || !isStyleLoaded || !projection) return;
314
+ mapInstance.setProjection(projection);
315
+ }, [mapInstance, isStyleLoaded, projection]);
316
+
317
+ const contextValue = useMemo(
318
+ () => ({
319
+ map: mapInstance,
320
+ isLoaded: isLoaded && isStyleLoaded,
321
+ resolvedTheme,
322
+ themeKey,
323
+ }),
324
+ [mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey],
325
+ );
326
+
327
+ if (initFailed) {
328
+ return (
329
+ <div className={cn("relative h-full w-full", className)}>
330
+ <StatePanel
331
+ kind="error"
332
+ title="Map unavailable"
333
+ description="This browser can’t render WebGL maps."
334
+ className="h-full"
335
+ />
336
+ </div>
337
+ );
338
+ }
339
+
340
+ return (
341
+ <MapContext.Provider value={contextValue}>
342
+ <div ref={containerRef} className={cn("relative h-full w-full", className)}>
343
+ {(!isLoaded || loading) && <MapLoadingOverlay />}
344
+ {/* SSR-safe: children render only when the map exists on the client. */}
345
+ {mapInstance && children}
346
+ </div>
347
+ </MapContext.Provider>
348
+ );
349
+ });
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import type MapLibreGL from "maplibre-gl";
4
+ import { createContext, use } from "react";
5
+
6
+ /** Light-or-dark flavor of the active basemap (derived from the brand theme). */
7
+ export type BasemapTheme = "light" | "dark";
8
+
9
+ export interface MapContextValue {
10
+ /** The live MapLibre map instance (`null` until the map has mounted). */
11
+ map: MapLibreGL.Map | null;
12
+ /** True once the map AND its style are fully loaded — gate layer operations on this. */
13
+ isLoaded: boolean;
14
+ /** Which basemap flavor is active. */
15
+ resolvedTheme: BasemapTheme;
16
+ /**
17
+ * Changes whenever the active brand theme changes. Layer components use it
18
+ * as a dependency key to re-resolve semantic token colors for WebGL paint.
19
+ */
20
+ themeKey: string;
21
+ }
22
+
23
+ export const MapContext = createContext<MapContextValue | null>(null);
24
+
25
+ /** Access the map instance + load state from any descendant of `<MapCanvas>`. */
26
+ export function useMap(): MapContextValue {
27
+ const context = use(MapContext);
28
+ if (!context) {
29
+ throw new Error("useMap must be used within a <MapCanvas>");
30
+ }
31
+ return context;
32
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ * MapLibre popup chrome reset — @elabs-ai/components-maps popups paint their own surface
3
+ * (bg-popover, border, shadow), so MapLibre's white bubble + tip must not
4
+ * render underneath. Imported by <MapCanvas> alongside maplibre-gl's CSS.
5
+ */
6
+ .maplibregl-popup-content {
7
+ background: transparent;
8
+ box-shadow: none;
9
+ padding: 0;
10
+ border-radius: 0;
11
+ }
12
+
13
+ .maplibregl-popup-tip {
14
+ display: none;
15
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { resolveThemeIsDark } from "@elabs-ai/components-tokens";
4
+ import { useEffect, useState } from "react";
5
+
6
+ import type { BasemapTheme } from "./map-context";
7
+
8
+ /**
9
+ * Brand theme → basemap flavor. When a `data-theme` is set (the ThemeProvider
10
+ * contract) the theme's own `color-scheme` is authoritative via
11
+ * `resolveThemeIsDark` — which is why a CONSUMER-AUTHORED dark theme gets the
12
+ * dark basemap without registering anything here (ADR 0029; the old
13
+ * `THEME_META[theme].dark` lookup only knew the themes this repo ships). A
14
+ * `dark`/`light` class on `<html>` is honored for non-brand hosts (next-themes
15
+ * et al.); the OS preference is only the last-resort fallback.
16
+ */
17
+ function getBrandTheme(): BasemapTheme | null {
18
+ if (typeof document === "undefined") return null;
19
+ const root = document.documentElement;
20
+ if (root.getAttribute("data-theme")) return resolveThemeIsDark(root) ? "dark" : "light";
21
+ if (root.classList.contains("dark")) return "dark";
22
+ if (root.classList.contains("light")) return "light";
23
+ return null;
24
+ }
25
+
26
+ function getSystemTheme(): BasemapTheme {
27
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light";
28
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
29
+ }
30
+
31
+ function getThemeAttribute(): string {
32
+ if (typeof document === "undefined") return "";
33
+ return document.documentElement.getAttribute("data-theme") ?? "";
34
+ }
35
+
36
+ /**
37
+ * Resolve the basemap flavor and a change-key for the active brand theme.
38
+ * The observer stays active even with an explicit `theme` prop: token colors
39
+ * (routes, clusters, …) must still re-resolve when `data-theme` changes.
40
+ */
41
+ export function useResolvedBasemapTheme(themeProp?: BasemapTheme): {
42
+ resolvedTheme: BasemapTheme;
43
+ themeKey: string;
44
+ } {
45
+ const [detected, setDetected] = useState<BasemapTheme>(() => getBrandTheme() ?? getSystemTheme());
46
+ const [themeAttr, setThemeAttr] = useState(getThemeAttribute);
47
+
48
+ useEffect(() => {
49
+ const update = () => {
50
+ setThemeAttr(getThemeAttribute());
51
+ const brand = getBrandTheme();
52
+ if (brand) setDetected(brand);
53
+ };
54
+ const observer = new MutationObserver(update);
55
+ observer.observe(document.documentElement, {
56
+ attributes: true,
57
+ attributeFilter: ["data-theme", "class"],
58
+ });
59
+
60
+ const mediaQuery =
61
+ typeof window.matchMedia === "function"
62
+ ? window.matchMedia("(prefers-color-scheme: dark)")
63
+ : null;
64
+ const handleSystemChange = (e: MediaQueryListEvent) => {
65
+ if (!getBrandTheme()) setDetected(e.matches ? "dark" : "light");
66
+ };
67
+ mediaQuery?.addEventListener("change", handleSystemChange);
68
+
69
+ return () => {
70
+ observer.disconnect();
71
+ mediaQuery?.removeEventListener("change", handleSystemChange);
72
+ };
73
+ }, []);
74
+
75
+ const resolvedTheme = themeProp ?? detected;
76
+ return { resolvedTheme, themeKey: `${themeAttr}:${resolvedTheme}` };
77
+ }
@@ -0,0 +1 @@
1
+ export { MapClusterLayer, type MapClusterLayerProps } from "./map-cluster-layer";
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { MapCanvas } from "../map-canvas";
4
+ import { MapClusterLayer } from "./map-cluster-layer";
5
+
6
+ // Deterministic pseudo-random points (seeded LCG, stable across renders).
7
+ function makePoints(count: number): GeoJSON.FeatureCollection<GeoJSON.Point, { id: number }> {
8
+ let seed = 42;
9
+ const next = () => {
10
+ seed = (seed * 1664525 + 1013904223) % 4294967296;
11
+ return seed / 4294967296;
12
+ };
13
+ const features: GeoJSON.Feature<GeoJSON.Point, { id: number }>[] = [];
14
+ for (let i = 0; i < count; i += 1) {
15
+ // Cluster the points around a handful of European hubs.
16
+ const hubs: [number, number][] = [
17
+ [13.4, 52.5],
18
+ [2.35, 48.85],
19
+ [-0.13, 51.51],
20
+ [12.5, 41.9],
21
+ [-3.7, 40.42],
22
+ ];
23
+ const hub = hubs[Math.floor(next() * hubs.length)]!;
24
+ features.push({
25
+ type: "Feature",
26
+ properties: { id: i },
27
+ geometry: {
28
+ type: "Point",
29
+ coordinates: [hub[0] + (next() - 0.5) * 6, hub[1] + (next() - 0.5) * 4],
30
+ },
31
+ });
32
+ }
33
+ return { type: "FeatureCollection", features };
34
+ }
35
+
36
+ const points = makePoints(1200);
37
+
38
+ const meta = {
39
+ title: "Maps/MapClusterLayer",
40
+ component: MapClusterLayer,
41
+ tags: ["autodocs"],
42
+ parameters: { layout: "fullscreen" },
43
+ } satisfies Meta<typeof MapClusterLayer>;
44
+ export default meta;
45
+ type Story = StoryObj<typeof meta>;
46
+
47
+ /** Cluster circles step through the status tokens as point counts grow; click a cluster to zoom in. */
48
+ export const Default: Story = {
49
+ render: () => (
50
+ <div className="h-[480px]">
51
+ <MapCanvas center={[5, 47]} zoom={3.5}>
52
+ <MapClusterLayer data={points} clusterThresholds={[50, 200]} />
53
+ </MapCanvas>
54
+ </div>
55
+ ),
56
+ };