@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/index.css +11 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +442 -0
- package/dist/index.js +1475 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/src/index.ts +37 -0
- package/src/lib/arc-math.test.ts +41 -0
- package/src/lib/arc-math.ts +45 -0
- package/src/lib/merge-hover-paint.test.ts +36 -0
- package/src/lib/merge-hover-paint.ts +21 -0
- package/src/lib/use-token-color.ts +23 -0
- package/src/map-arc/index.ts +9 -0
- package/src/map-arc/map-arc.stories.tsx +58 -0
- package/src/map-arc/map-arc.tsx +294 -0
- package/src/map-canvas/index.ts +8 -0
- package/src/map-canvas/map-canvas-webgl-fallback.test.tsx +30 -0
- package/src/map-canvas/map-canvas.stories.tsx +54 -0
- package/src/map-canvas/map-canvas.test.tsx +93 -0
- package/src/map-canvas/map-canvas.tsx +349 -0
- package/src/map-canvas/map-context.ts +32 -0
- package/src/map-canvas/maps.css +15 -0
- package/src/map-canvas/use-resolved-basemap-theme.ts +77 -0
- package/src/map-cluster-layer/index.ts +1 -0
- package/src/map-cluster-layer/map-cluster-layer.stories.tsx +56 -0
- package/src/map-cluster-layer/map-cluster-layer.tsx +292 -0
- package/src/map-controls/index.ts +1 -0
- package/src/map-controls/map-controls.stories.tsx +43 -0
- package/src/map-controls/map-controls.test.tsx +56 -0
- package/src/map-controls/map-controls.tsx +220 -0
- package/src/map-geojson/index.ts +9 -0
- package/src/map-geojson/map-geojson.stories.tsx +124 -0
- package/src/map-geojson/map-geojson.tsx +274 -0
- package/src/map-marker/index.ts +12 -0
- package/src/map-marker/map-marker.stories.tsx +71 -0
- package/src/map-marker/map-marker.test.tsx +81 -0
- package/src/map-marker/map-marker.tsx +373 -0
- package/src/map-popup/index.ts +1 -0
- package/src/map-popup/map-popup.tsx +113 -0
- package/src/map-route/index.ts +1 -0
- package/src/map-route/map-route.stories.tsx +56 -0
- package/src/map-route/map-route.tsx +143 -0
- 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
|
+
};
|