@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
package/dist/index.js ADDED
@@ -0,0 +1,1475 @@
1
+ "use client";
2
+ import "./index.css";
3
+
4
+ // src/map-canvas/map-canvas.tsx
5
+ import MapLibreGL from "maplibre-gl";
6
+ import "maplibre-gl/dist/maplibre-gl.css";
7
+ import {
8
+ forwardRef,
9
+ useCallback,
10
+ useEffect as useEffect2,
11
+ useImperativeHandle,
12
+ useMemo,
13
+ useRef,
14
+ useState as useState2
15
+ } from "react";
16
+ import { Spinner, StatePanel } from "@elabs-ai/components-ui";
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+
19
+ // src/map-canvas/map-context.ts
20
+ import { createContext, use } from "react";
21
+ var MapContext = createContext(null);
22
+ function useMap() {
23
+ const context = use(MapContext);
24
+ if (!context) {
25
+ throw new Error("useMap must be used within a <MapCanvas>");
26
+ }
27
+ return context;
28
+ }
29
+
30
+ // src/map-canvas/use-resolved-basemap-theme.ts
31
+ import { resolveThemeIsDark } from "@elabs-ai/components-tokens";
32
+ import { useEffect, useState } from "react";
33
+ function getBrandTheme() {
34
+ if (typeof document === "undefined") return null;
35
+ const root = document.documentElement;
36
+ if (root.getAttribute("data-theme")) return resolveThemeIsDark(root) ? "dark" : "light";
37
+ if (root.classList.contains("dark")) return "dark";
38
+ if (root.classList.contains("light")) return "light";
39
+ return null;
40
+ }
41
+ function getSystemTheme() {
42
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light";
43
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
44
+ }
45
+ function getThemeAttribute() {
46
+ if (typeof document === "undefined") return "";
47
+ return document.documentElement.getAttribute("data-theme") ?? "";
48
+ }
49
+ function useResolvedBasemapTheme(themeProp) {
50
+ const [detected, setDetected] = useState(() => getBrandTheme() ?? getSystemTheme());
51
+ const [themeAttr, setThemeAttr] = useState(getThemeAttribute);
52
+ useEffect(() => {
53
+ const update = () => {
54
+ setThemeAttr(getThemeAttribute());
55
+ const brand = getBrandTheme();
56
+ if (brand) setDetected(brand);
57
+ };
58
+ const observer = new MutationObserver(update);
59
+ observer.observe(document.documentElement, {
60
+ attributes: true,
61
+ attributeFilter: ["data-theme", "class"]
62
+ });
63
+ const mediaQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
64
+ const handleSystemChange = (e) => {
65
+ if (!getBrandTheme()) setDetected(e.matches ? "dark" : "light");
66
+ };
67
+ mediaQuery?.addEventListener("change", handleSystemChange);
68
+ return () => {
69
+ observer.disconnect();
70
+ mediaQuery?.removeEventListener("change", handleSystemChange);
71
+ };
72
+ }, []);
73
+ const resolvedTheme = themeProp ?? detected;
74
+ return { resolvedTheme, themeKey: `${themeAttr}:${resolvedTheme}` };
75
+ }
76
+
77
+ // src/map-canvas/map-canvas.tsx
78
+ import { jsx, jsxs } from "react/jsx-runtime";
79
+ var defaultStyles = {
80
+ dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
81
+ light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
82
+ };
83
+ var blankMapStyle = {
84
+ version: 8,
85
+ sources: {},
86
+ layers: [
87
+ {
88
+ id: "background",
89
+ type: "background",
90
+ paint: { "background-color": "transparent" }
91
+ }
92
+ ]
93
+ };
94
+ function MapLoadingOverlay() {
95
+ return /* @__PURE__ */ jsx("div", { className: "absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-xs", children: /* @__PURE__ */ jsx(Spinner, { label: "Loading map", className: "size-5" }) });
96
+ }
97
+ function getViewport(map) {
98
+ const center = map.getCenter();
99
+ return {
100
+ center: [center.lng, center.lat],
101
+ zoom: map.getZoom(),
102
+ bearing: map.getBearing(),
103
+ pitch: map.getPitch()
104
+ };
105
+ }
106
+ var MapCanvas = forwardRef(function MapCanvas2({
107
+ children,
108
+ className,
109
+ theme: themeProp,
110
+ styles,
111
+ blank = false,
112
+ projection,
113
+ viewport,
114
+ onViewportChange,
115
+ loading = false,
116
+ ...props
117
+ }, ref) {
118
+ const containerRef = useRef(null);
119
+ const [mapInstance, setMapInstance] = useState2(null);
120
+ const [initFailed, setInitFailed] = useState2(false);
121
+ const [isLoaded, setIsLoaded] = useState2(false);
122
+ const [isStyleLoaded, setIsStyleLoaded] = useState2(false);
123
+ const currentStyleRef = useRef(null);
124
+ const styleTimeoutRef = useRef(null);
125
+ const internalUpdateRef = useRef(false);
126
+ const { resolvedTheme, themeKey } = useResolvedBasemapTheme(themeProp);
127
+ const isControlled = viewport !== void 0 && onViewportChange !== void 0;
128
+ const onViewportChangeRef = useRef(onViewportChange);
129
+ onViewportChangeRef.current = onViewportChange;
130
+ const mapStyles = useMemo(() => {
131
+ if (styles) {
132
+ return {
133
+ dark: styles.dark ?? defaultStyles.dark,
134
+ light: styles.light ?? defaultStyles.light
135
+ };
136
+ }
137
+ if (blank) {
138
+ return { dark: blankMapStyle, light: blankMapStyle };
139
+ }
140
+ return defaultStyles;
141
+ }, [styles, blank]);
142
+ useImperativeHandle(ref, () => mapInstance, [mapInstance]);
143
+ const clearStyleTimeout = useCallback(() => {
144
+ if (styleTimeoutRef.current) {
145
+ clearTimeout(styleTimeoutRef.current);
146
+ styleTimeoutRef.current = null;
147
+ }
148
+ }, []);
149
+ useEffect2(() => {
150
+ if (!containerRef.current) return;
151
+ const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
152
+ currentStyleRef.current = initialStyle;
153
+ let map;
154
+ try {
155
+ map = new MapLibreGL.Map({
156
+ container: containerRef.current,
157
+ style: initialStyle,
158
+ renderWorldCopies: false,
159
+ // Attribution control OFF by default — a maintainer decision for internal
160
+ // use, taken deliberately and recorded in CHANGELOG.md + the map-components
161
+ // rule. NOTE THE CONSTRAINT: the default Carto basemap serves OpenStreetMap
162
+ // data, which is ODbL-licensed and requires the credit, and Carto's terms
163
+ // require it too — so a surface that ships PUBLICLY on these tiles must turn
164
+ // it back on with `attributionControl={{ compact: true }}` (it wins through
165
+ // `...props` below), or move to tiles licensed without the requirement via
166
+ // `styles` / `blank`.
167
+ attributionControl: false,
168
+ ...props,
169
+ ...viewport
170
+ });
171
+ } catch {
172
+ setInitFailed(true);
173
+ return;
174
+ }
175
+ const styleDataHandler = () => {
176
+ clearStyleTimeout();
177
+ styleTimeoutRef.current = setTimeout(() => {
178
+ setIsStyleLoaded(true);
179
+ if (projection) {
180
+ map.setProjection(projection);
181
+ }
182
+ }, 100);
183
+ };
184
+ const loadHandler = () => {
185
+ setIsLoaded(true);
186
+ map.getContainer().querySelectorAll("details.maplibregl-ctrl-attrib[open]").forEach((el) => {
187
+ el.open = false;
188
+ });
189
+ };
190
+ const handleMove = () => {
191
+ if (internalUpdateRef.current) return;
192
+ onViewportChangeRef.current?.(getViewport(map));
193
+ };
194
+ map.on("load", loadHandler);
195
+ map.on("styledata", styleDataHandler);
196
+ map.on("move", handleMove);
197
+ setMapInstance(map);
198
+ return () => {
199
+ clearStyleTimeout();
200
+ map.off("load", loadHandler);
201
+ map.off("styledata", styleDataHandler);
202
+ map.off("move", handleMove);
203
+ map.remove();
204
+ setIsLoaded(false);
205
+ setIsStyleLoaded(false);
206
+ setMapInstance(null);
207
+ };
208
+ }, []);
209
+ useEffect2(() => {
210
+ if (!mapInstance || !isControlled || !viewport) return;
211
+ if (mapInstance.isMoving()) return;
212
+ const current = getViewport(mapInstance);
213
+ const next = {
214
+ center: viewport.center ?? current.center,
215
+ zoom: viewport.zoom ?? current.zoom,
216
+ bearing: viewport.bearing ?? current.bearing,
217
+ pitch: viewport.pitch ?? current.pitch
218
+ };
219
+ if (next.center[0] === current.center[0] && next.center[1] === current.center[1] && next.zoom === current.zoom && next.bearing === current.bearing && next.pitch === current.pitch) {
220
+ return;
221
+ }
222
+ internalUpdateRef.current = true;
223
+ mapInstance.jumpTo(next);
224
+ internalUpdateRef.current = false;
225
+ }, [mapInstance, isControlled, viewport]);
226
+ useEffect2(() => {
227
+ if (!mapInstance || !resolvedTheme) return;
228
+ const newStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
229
+ if (currentStyleRef.current === newStyle) return;
230
+ clearStyleTimeout();
231
+ currentStyleRef.current = newStyle;
232
+ setIsStyleLoaded(false);
233
+ mapInstance.setStyle(newStyle, { diff: true });
234
+ }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);
235
+ useEffect2(() => {
236
+ if (!mapInstance || !isStyleLoaded || !projection) return;
237
+ mapInstance.setProjection(projection);
238
+ }, [mapInstance, isStyleLoaded, projection]);
239
+ const contextValue = useMemo(
240
+ () => ({
241
+ map: mapInstance,
242
+ isLoaded: isLoaded && isStyleLoaded,
243
+ resolvedTheme,
244
+ themeKey
245
+ }),
246
+ [mapInstance, isLoaded, isStyleLoaded, resolvedTheme, themeKey]
247
+ );
248
+ if (initFailed) {
249
+ return /* @__PURE__ */ jsx("div", { className: cn("relative h-full w-full", className), children: /* @__PURE__ */ jsx(
250
+ StatePanel,
251
+ {
252
+ kind: "error",
253
+ title: "Map unavailable",
254
+ description: "This browser can\u2019t render WebGL maps.",
255
+ className: "h-full"
256
+ }
257
+ ) });
258
+ }
259
+ return /* @__PURE__ */ jsx(MapContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxs("div", { ref: containerRef, className: cn("relative h-full w-full", className), children: [
260
+ (!isLoaded || loading) && /* @__PURE__ */ jsx(MapLoadingOverlay, {}),
261
+ mapInstance && children
262
+ ] }) });
263
+ });
264
+
265
+ // src/map-marker/map-marker.tsx
266
+ import MapLibreGL2 from "maplibre-gl";
267
+ import { createContext as createContext2, use as use2, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef2 } from "react";
268
+ import { createPortal } from "react-dom";
269
+ import { X } from "lucide-react";
270
+ import { cn as cn2 } from "@elabs-ai/components-ui/lib/cn";
271
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
272
+ var MarkerContext = createContext2(null);
273
+ function useMarkerContext() {
274
+ const context = use2(MarkerContext);
275
+ if (!context) {
276
+ throw new Error("MapMarker sub-components must be used within <MapMarker>");
277
+ }
278
+ return context;
279
+ }
280
+ function MapMarker({
281
+ longitude,
282
+ latitude,
283
+ children,
284
+ onClick,
285
+ onMouseEnter,
286
+ onMouseLeave,
287
+ onDragStart,
288
+ onDrag,
289
+ onDragEnd,
290
+ draggable = false,
291
+ ...markerOptions
292
+ }) {
293
+ const { map } = useMap();
294
+ const callbacksRef = useRef2({
295
+ onClick,
296
+ onMouseEnter,
297
+ onMouseLeave,
298
+ onDragStart,
299
+ onDrag,
300
+ onDragEnd
301
+ });
302
+ callbacksRef.current = {
303
+ onClick,
304
+ onMouseEnter,
305
+ onMouseLeave,
306
+ onDragStart,
307
+ onDrag,
308
+ onDragEnd
309
+ };
310
+ const marker = useMemo2(() => {
311
+ const markerInstance = new MapLibreGL2.Marker({
312
+ ...markerOptions,
313
+ element: document.createElement("div"),
314
+ draggable
315
+ }).setLngLat([longitude, latitude]);
316
+ const handleClick = (e) => callbacksRef.current.onClick?.(e);
317
+ const handleMouseEnter = (e) => callbacksRef.current.onMouseEnter?.(e);
318
+ const handleMouseLeave = (e) => callbacksRef.current.onMouseLeave?.(e);
319
+ markerInstance.getElement()?.addEventListener("click", handleClick);
320
+ markerInstance.getElement()?.addEventListener("mouseenter", handleMouseEnter);
321
+ markerInstance.getElement()?.addEventListener("mouseleave", handleMouseLeave);
322
+ const handleDragStart = () => {
323
+ const lngLat = markerInstance.getLngLat();
324
+ callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
325
+ };
326
+ const handleDrag = () => {
327
+ const lngLat = markerInstance.getLngLat();
328
+ callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
329
+ };
330
+ const handleDragEnd = () => {
331
+ const lngLat = markerInstance.getLngLat();
332
+ callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
333
+ };
334
+ markerInstance.on("dragstart", handleDragStart);
335
+ markerInstance.on("drag", handleDrag);
336
+ markerInstance.on("dragend", handleDragEnd);
337
+ return markerInstance;
338
+ }, []);
339
+ useEffect3(() => {
340
+ if (!map) return;
341
+ marker.addTo(map);
342
+ return () => {
343
+ marker.remove();
344
+ };
345
+ }, [map]);
346
+ const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
347
+ useEffect3(() => {
348
+ const current = marker.getLngLat();
349
+ if (current.lng !== longitude || current.lat !== latitude) {
350
+ marker.setLngLat([longitude, latitude]);
351
+ }
352
+ if (marker.isDraggable() !== draggable) {
353
+ marker.setDraggable(draggable);
354
+ }
355
+ const currentOffset = marker.getOffset();
356
+ const newOffset = offset ?? [0, 0];
357
+ const [newOffsetX, newOffsetY] = Array.isArray(newOffset) ? newOffset : [newOffset.x, newOffset.y];
358
+ if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {
359
+ marker.setOffset(newOffset);
360
+ }
361
+ if (marker.getRotation() !== (rotation ?? 0)) {
362
+ marker.setRotation(rotation ?? 0);
363
+ }
364
+ if (marker.getRotationAlignment() !== (rotationAlignment ?? "auto")) {
365
+ marker.setRotationAlignment(rotationAlignment ?? "auto");
366
+ }
367
+ if (marker.getPitchAlignment() !== (pitchAlignment ?? "auto")) {
368
+ marker.setPitchAlignment(pitchAlignment ?? "auto");
369
+ }
370
+ }, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment]);
371
+ return /* @__PURE__ */ jsx2(MarkerContext.Provider, { value: { marker, map }, children });
372
+ }
373
+ function MapMarkerContent({ children, className }) {
374
+ const { marker } = useMarkerContext();
375
+ return createPortal(
376
+ /* @__PURE__ */ jsx2("div", { className: cn2("relative cursor-pointer", className), children: children || /* @__PURE__ */ jsx2(DefaultMarkerIcon, {}) }),
377
+ marker.getElement()
378
+ );
379
+ }
380
+ function DefaultMarkerIcon() {
381
+ return /* @__PURE__ */ jsx2("div", { className: "relative size-4 rounded-full border-2 border-background bg-primary shadow-sm" });
382
+ }
383
+ function PopupCloseButton({ onClick }) {
384
+ return /* @__PURE__ */ jsx2(
385
+ "button",
386
+ {
387
+ type: "button",
388
+ onClick,
389
+ "aria-label": "Close popup",
390
+ className: "absolute top-1 right-1 z-10 inline-flex size-5 items-center justify-center rounded-sm text-foreground transition-colors duration-fast hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
391
+ children: /* @__PURE__ */ jsx2(X, { className: "size-3.5", "aria-hidden": "true" })
392
+ }
393
+ );
394
+ }
395
+ function MapMarkerPopup({
396
+ children,
397
+ className,
398
+ closeButton = false,
399
+ ...popupOptions
400
+ }) {
401
+ const { marker, map } = useMarkerContext();
402
+ const container = useMemo2(() => document.createElement("div"), []);
403
+ const { offset, maxWidth } = popupOptions;
404
+ const popup = useMemo2(() => {
405
+ return new MapLibreGL2.Popup({
406
+ offset: 16,
407
+ ...popupOptions,
408
+ closeButton: false
409
+ }).setMaxWidth("none").setDOMContent(container);
410
+ }, []);
411
+ useEffect3(() => {
412
+ if (!map) return;
413
+ popup.setDOMContent(container);
414
+ marker.setPopup(popup);
415
+ return () => {
416
+ marker.setPopup(null);
417
+ };
418
+ }, [map]);
419
+ useEffect3(() => {
420
+ popup.setOffset(offset ?? 16);
421
+ if (maxWidth) {
422
+ popup.setMaxWidth(maxWidth);
423
+ }
424
+ }, [popup, offset, maxWidth]);
425
+ const handleClose = () => popup.remove();
426
+ return createPortal(
427
+ /* @__PURE__ */ jsxs2(
428
+ "div",
429
+ {
430
+ className: cn2(
431
+ "relative max-w-62 rounded-md bg-popover p-3 text-popover-foreground shadow-ring-md",
432
+ "animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance",
433
+ className
434
+ ),
435
+ children: [
436
+ closeButton && /* @__PURE__ */ jsx2(PopupCloseButton, { onClick: handleClose }),
437
+ children
438
+ ]
439
+ }
440
+ ),
441
+ container
442
+ );
443
+ }
444
+ function MapMarkerTooltip({ children, className, ...popupOptions }) {
445
+ const { marker, map } = useMarkerContext();
446
+ const container = useMemo2(() => document.createElement("div"), []);
447
+ const { offset, maxWidth } = popupOptions;
448
+ const tooltip = useMemo2(() => {
449
+ return new MapLibreGL2.Popup({
450
+ offset: 16,
451
+ ...popupOptions,
452
+ closeOnClick: true,
453
+ closeButton: false
454
+ }).setMaxWidth("none");
455
+ }, []);
456
+ useEffect3(() => {
457
+ if (!map) return;
458
+ tooltip.setDOMContent(container);
459
+ const handleMouseEnter = () => {
460
+ tooltip.setLngLat(marker.getLngLat()).addTo(map);
461
+ };
462
+ const handleMouseLeave = () => tooltip.remove();
463
+ marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
464
+ marker.getElement()?.addEventListener("mouseleave", handleMouseLeave);
465
+ return () => {
466
+ marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
467
+ marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave);
468
+ tooltip.remove();
469
+ };
470
+ }, [map]);
471
+ useEffect3(() => {
472
+ tooltip.setOffset(offset ?? 16);
473
+ if (maxWidth) {
474
+ tooltip.setMaxWidth(maxWidth);
475
+ }
476
+ }, [tooltip, offset, maxWidth]);
477
+ return createPortal(
478
+ /* @__PURE__ */ jsx2(
479
+ "div",
480
+ {
481
+ className: cn2(
482
+ "pointer-events-none rounded-md bg-foreground px-2 py-1 text-meta text-balance text-background shadow-md",
483
+ "animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance",
484
+ className
485
+ ),
486
+ children
487
+ }
488
+ ),
489
+ container
490
+ );
491
+ }
492
+ function MapMarkerLabel({ children, className, position = "top" }) {
493
+ const { marker } = useMarkerContext();
494
+ const positionClasses2 = {
495
+ top: "bottom-full mb-1",
496
+ bottom: "top-full mt-1"
497
+ };
498
+ return createPortal(
499
+ /* @__PURE__ */ jsx2(
500
+ "div",
501
+ {
502
+ className: cn2(
503
+ "absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
504
+ "text-meta font-medium text-foreground",
505
+ positionClasses2[position],
506
+ className
507
+ ),
508
+ children
509
+ }
510
+ ),
511
+ marker.getElement()
512
+ );
513
+ }
514
+
515
+ // src/map-popup/map-popup.tsx
516
+ import MapLibreGL3 from "maplibre-gl";
517
+ import { useEffect as useEffect4, useMemo as useMemo3, useRef as useRef3 } from "react";
518
+ import { createPortal as createPortal2 } from "react-dom";
519
+ import { X as X2 } from "lucide-react";
520
+ import { cn as cn3 } from "@elabs-ai/components-ui/lib/cn";
521
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
522
+ function MapPopup({
523
+ longitude,
524
+ latitude,
525
+ onClose,
526
+ children,
527
+ className,
528
+ closeButton = false,
529
+ ...popupOptions
530
+ }) {
531
+ const { map } = useMap();
532
+ const onCloseRef = useRef3(onClose);
533
+ onCloseRef.current = onClose;
534
+ const container = useMemo3(() => document.createElement("div"), []);
535
+ const { offset, maxWidth } = popupOptions;
536
+ const popup = useMemo3(() => {
537
+ return new MapLibreGL3.Popup({
538
+ offset: 16,
539
+ ...popupOptions,
540
+ closeButton: false
541
+ }).setMaxWidth("none").setLngLat([longitude, latitude]);
542
+ }, []);
543
+ useEffect4(() => {
544
+ if (!map) return;
545
+ const onCloseProp = () => onCloseRef.current?.();
546
+ popup.on("close", onCloseProp);
547
+ popup.setDOMContent(container);
548
+ popup.addTo(map);
549
+ return () => {
550
+ popup.off("close", onCloseProp);
551
+ if (popup.isOpen()) {
552
+ popup.remove();
553
+ }
554
+ };
555
+ }, [map]);
556
+ useEffect4(() => {
557
+ const current = popup.getLngLat();
558
+ if (!current || current.lng !== longitude || current.lat !== latitude) {
559
+ popup.setLngLat([longitude, latitude]);
560
+ }
561
+ popup.setOffset(offset ?? 16);
562
+ if (maxWidth) {
563
+ popup.setMaxWidth(maxWidth);
564
+ }
565
+ }, [popup, longitude, latitude, offset, maxWidth]);
566
+ const handleClose = () => {
567
+ popup.remove();
568
+ };
569
+ return createPortal2(
570
+ /* @__PURE__ */ jsxs3(
571
+ "div",
572
+ {
573
+ className: cn3(
574
+ "relative max-w-62 rounded-md bg-popover p-3 text-popover-foreground shadow-ring-md",
575
+ "animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance",
576
+ className
577
+ ),
578
+ children: [
579
+ closeButton && /* @__PURE__ */ jsx3(
580
+ "button",
581
+ {
582
+ type: "button",
583
+ onClick: handleClose,
584
+ "aria-label": "Close popup",
585
+ className: "absolute top-1 right-1 z-10 inline-flex size-5 items-center justify-center rounded-sm text-foreground transition-colors duration-fast hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
586
+ children: /* @__PURE__ */ jsx3(X2, { className: "size-3.5", "aria-hidden": "true" })
587
+ }
588
+ ),
589
+ children
590
+ ]
591
+ }
592
+ ),
593
+ container
594
+ );
595
+ }
596
+
597
+ // src/map-controls/map-controls.tsx
598
+ import { useCallback as useCallback2, useEffect as useEffect5, useRef as useRef4, useState as useState3 } from "react";
599
+ import { Locate, Maximize, Minus, Plus } from "lucide-react";
600
+ import { Spinner as Spinner2 } from "@elabs-ai/components-ui";
601
+ import { cn as cn4 } from "@elabs-ai/components-ui/lib/cn";
602
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
603
+ var positionClasses = {
604
+ "top-left": "top-2 left-2",
605
+ "top-right": "top-2 right-2",
606
+ "bottom-left": "bottom-2 left-2",
607
+ "bottom-right": "bottom-10 right-2"
608
+ };
609
+ function ControlGroup({ children }) {
610
+ return /* @__PURE__ */ jsx4("div", { className: "flex flex-col divide-y overflow-hidden rounded-md bg-surface-elevated shadow-ring-sm", children });
611
+ }
612
+ function ControlButton({
613
+ onClick,
614
+ label,
615
+ children,
616
+ disabled = false
617
+ }) {
618
+ return /* @__PURE__ */ jsx4(
619
+ "button",
620
+ {
621
+ onClick,
622
+ "aria-label": label,
623
+ type: "button",
624
+ className: cn4(
625
+ "flex size-8 items-center justify-center text-foreground transition-colors duration-fast",
626
+ "hover:bg-surface-muted",
627
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
628
+ "disabled:pointer-events-none disabled:opacity-50"
629
+ ),
630
+ disabled,
631
+ children
632
+ }
633
+ );
634
+ }
635
+ function MapControls({
636
+ position = "bottom-right",
637
+ showZoom = true,
638
+ showCompass = false,
639
+ showLocate = false,
640
+ showFullscreen = false,
641
+ className,
642
+ onLocate,
643
+ onLocateError
644
+ }) {
645
+ const { map } = useMap();
646
+ const [waitingForLocation, setWaitingForLocation] = useState3(false);
647
+ const handleZoomIn = useCallback2(() => {
648
+ map?.zoomTo(map.getZoom() + 1, { duration: 300 });
649
+ }, [map]);
650
+ const handleZoomOut = useCallback2(() => {
651
+ map?.zoomTo(map.getZoom() - 1, { duration: 300 });
652
+ }, [map]);
653
+ const handleResetBearing = useCallback2(() => {
654
+ map?.resetNorthPitch({ duration: 300 });
655
+ }, [map]);
656
+ const handleLocate = useCallback2(() => {
657
+ if (!("geolocation" in navigator)) return;
658
+ setWaitingForLocation(true);
659
+ navigator.geolocation.getCurrentPosition(
660
+ (pos) => {
661
+ const coords = {
662
+ longitude: pos.coords.longitude,
663
+ latitude: pos.coords.latitude
664
+ };
665
+ map?.flyTo({
666
+ center: [coords.longitude, coords.latitude],
667
+ zoom: 14,
668
+ duration: 1500
669
+ });
670
+ onLocate?.(coords);
671
+ setWaitingForLocation(false);
672
+ },
673
+ (error) => {
674
+ onLocateError?.(error);
675
+ setWaitingForLocation(false);
676
+ }
677
+ );
678
+ }, [map, onLocate, onLocateError]);
679
+ const handleFullscreen = useCallback2(() => {
680
+ const container = map?.getContainer();
681
+ if (!container) return;
682
+ if (document.fullscreenElement) {
683
+ void document.exitFullscreen();
684
+ } else {
685
+ void container.requestFullscreen();
686
+ }
687
+ }, [map]);
688
+ return /* @__PURE__ */ jsxs4(
689
+ "div",
690
+ {
691
+ className: cn4("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className),
692
+ children: [
693
+ showZoom && /* @__PURE__ */ jsxs4(ControlGroup, { children: [
694
+ /* @__PURE__ */ jsx4(ControlButton, { onClick: handleZoomIn, label: "Zoom in", children: /* @__PURE__ */ jsx4(Plus, { className: "size-4", "aria-hidden": "true" }) }),
695
+ /* @__PURE__ */ jsx4(ControlButton, { onClick: handleZoomOut, label: "Zoom out", children: /* @__PURE__ */ jsx4(Minus, { className: "size-4", "aria-hidden": "true" }) })
696
+ ] }),
697
+ showCompass && /* @__PURE__ */ jsx4(ControlGroup, { children: /* @__PURE__ */ jsx4(CompassButton, { onClick: handleResetBearing }) }),
698
+ showLocate && /* @__PURE__ */ jsx4(ControlGroup, { children: /* @__PURE__ */ jsx4(
699
+ ControlButton,
700
+ {
701
+ onClick: handleLocate,
702
+ label: "Find my location",
703
+ disabled: waitingForLocation,
704
+ children: waitingForLocation ? /* @__PURE__ */ jsx4(Spinner2, { label: "Locating", className: "size-4 text-foreground" }) : /* @__PURE__ */ jsx4(Locate, { className: "size-4", "aria-hidden": "true" })
705
+ }
706
+ ) }),
707
+ showFullscreen && /* @__PURE__ */ jsx4(ControlGroup, { children: /* @__PURE__ */ jsx4(ControlButton, { onClick: handleFullscreen, label: "Toggle fullscreen", children: /* @__PURE__ */ jsx4(Maximize, { className: "size-4", "aria-hidden": "true" }) }) })
708
+ ]
709
+ }
710
+ );
711
+ }
712
+ function CompassButton({ onClick }) {
713
+ const { map } = useMap();
714
+ const compassRef = useRef4(null);
715
+ useEffect5(() => {
716
+ if (!map || !compassRef.current) return;
717
+ const compass = compassRef.current;
718
+ const updateRotation = () => {
719
+ const bearing = map.getBearing();
720
+ const pitch = map.getPitch();
721
+ compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
722
+ };
723
+ map.on("rotate", updateRotation);
724
+ map.on("pitch", updateRotation);
725
+ updateRotation();
726
+ return () => {
727
+ map.off("rotate", updateRotation);
728
+ map.off("pitch", updateRotation);
729
+ };
730
+ }, [map]);
731
+ return /* @__PURE__ */ jsx4(ControlButton, { onClick, label: "Reset bearing to north", children: /* @__PURE__ */ jsxs4(
732
+ "svg",
733
+ {
734
+ ref: compassRef,
735
+ viewBox: "0 0 24 24",
736
+ "aria-hidden": "true",
737
+ className: "size-5 transition-transform duration-fast",
738
+ style: { transformStyle: "preserve-3d" },
739
+ children: [
740
+ /* @__PURE__ */ jsx4("path", { d: "M12 2L16 12H12V2Z", className: "fill-destructive" }),
741
+ /* @__PURE__ */ jsx4("path", { d: "M12 2L8 12H12V2Z", className: "fill-destructive/50" }),
742
+ /* @__PURE__ */ jsx4("path", { d: "M12 22L16 12H12V22Z", className: "fill-muted-foreground/60" }),
743
+ /* @__PURE__ */ jsx4("path", { d: "M12 22L8 12H12V22Z", className: "fill-muted-foreground/30" })
744
+ ]
745
+ }
746
+ ) });
747
+ }
748
+
749
+ // src/map-route/map-route.tsx
750
+ import { useEffect as useEffect6, useId } from "react";
751
+
752
+ // src/lib/use-token-color.ts
753
+ import { resolveTokenColor } from "@elabs-ai/components-tokens";
754
+ import { useLayoutEffect, useState as useState4 } from "react";
755
+ function useTokenColor(name, fallback = "#000000") {
756
+ const { map, themeKey } = useMap();
757
+ const [color, setColor] = useState4(fallback);
758
+ useLayoutEffect(() => {
759
+ setColor(resolveTokenColor(name, { el: map?.getContainer(), fallback }));
760
+ }, [name, fallback, map, themeKey]);
761
+ return color;
762
+ }
763
+
764
+ // src/map-route/map-route.tsx
765
+ function MapRoute({
766
+ id: propId,
767
+ coordinates,
768
+ color,
769
+ width = 3,
770
+ opacity = 0.8,
771
+ dashArray,
772
+ onClick,
773
+ onMouseEnter,
774
+ onMouseLeave,
775
+ interactive = true
776
+ }) {
777
+ const { map, isLoaded } = useMap();
778
+ const autoId = useId();
779
+ const id = propId ?? autoId;
780
+ const sourceId = `route-source-${id}`;
781
+ const layerId = `route-layer-${id}`;
782
+ const primary = useTokenColor("--primary");
783
+ const lineColor = color ?? primary;
784
+ useEffect6(() => {
785
+ if (!isLoaded || !map) return;
786
+ map.addSource(sourceId, {
787
+ type: "geojson",
788
+ data: {
789
+ type: "Feature",
790
+ properties: {},
791
+ geometry: { type: "LineString", coordinates: [] }
792
+ }
793
+ });
794
+ map.addLayer({
795
+ id: layerId,
796
+ type: "line",
797
+ source: sourceId,
798
+ layout: { "line-join": "round", "line-cap": "round" },
799
+ paint: {
800
+ "line-color": lineColor,
801
+ "line-width": width,
802
+ "line-opacity": opacity,
803
+ ...dashArray && { "line-dasharray": dashArray }
804
+ }
805
+ });
806
+ return () => {
807
+ try {
808
+ if (map.getLayer(layerId)) map.removeLayer(layerId);
809
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
810
+ } catch {
811
+ }
812
+ };
813
+ }, [isLoaded, map]);
814
+ useEffect6(() => {
815
+ if (!isLoaded || !map || coordinates.length < 2) return;
816
+ const source = map.getSource(sourceId);
817
+ if (source) {
818
+ source.setData({
819
+ type: "Feature",
820
+ properties: {},
821
+ geometry: { type: "LineString", coordinates }
822
+ });
823
+ }
824
+ }, [isLoaded, map, coordinates, sourceId]);
825
+ useEffect6(() => {
826
+ if (!isLoaded || !map || !map.getLayer(layerId)) return;
827
+ map.setPaintProperty(layerId, "line-color", lineColor);
828
+ map.setPaintProperty(layerId, "line-width", width);
829
+ map.setPaintProperty(layerId, "line-opacity", opacity);
830
+ map.setPaintProperty(layerId, "line-dasharray", dashArray);
831
+ }, [isLoaded, map, layerId, lineColor, width, opacity, dashArray]);
832
+ useEffect6(() => {
833
+ if (!isLoaded || !map || !interactive) return;
834
+ const handleClick = () => {
835
+ onClick?.();
836
+ };
837
+ const handleMouseEnter = () => {
838
+ map.getCanvas().style.cursor = "pointer";
839
+ onMouseEnter?.();
840
+ };
841
+ const handleMouseLeave = () => {
842
+ map.getCanvas().style.cursor = "";
843
+ onMouseLeave?.();
844
+ };
845
+ map.on("click", layerId, handleClick);
846
+ map.on("mouseenter", layerId, handleMouseEnter);
847
+ map.on("mouseleave", layerId, handleMouseLeave);
848
+ return () => {
849
+ map.off("click", layerId, handleClick);
850
+ map.off("mouseenter", layerId, handleMouseEnter);
851
+ map.off("mouseleave", layerId, handleMouseLeave);
852
+ };
853
+ }, [isLoaded, map, layerId, onClick, onMouseEnter, onMouseLeave, interactive]);
854
+ return null;
855
+ }
856
+
857
+ // src/map-arc/map-arc.tsx
858
+ import { useEffect as useEffect7, useId as useId2, useMemo as useMemo4, useRef as useRef5 } from "react";
859
+
860
+ // src/lib/arc-math.ts
861
+ function buildArcCoordinates(from, to, curvature, samples) {
862
+ const [x0, y0] = from;
863
+ const [xTo, y2] = to;
864
+ const rawDx = xTo - x0;
865
+ const x2 = rawDx > 180 ? xTo - 360 : rawDx < -180 ? xTo + 360 : xTo;
866
+ const dx = x2 - x0;
867
+ const dy = y2 - y0;
868
+ const distance = Math.hypot(dx, dy);
869
+ if (distance === 0 || curvature === 0) return [from, [x2, y2]];
870
+ const mx = (x0 + x2) / 2;
871
+ const my = (y0 + y2) / 2;
872
+ const nx = -dy / distance;
873
+ const ny = dx / distance;
874
+ const offset = distance * curvature;
875
+ const cx = mx + nx * offset;
876
+ const cy = my + ny * offset;
877
+ const points = [];
878
+ const segments = Math.max(2, Math.floor(samples));
879
+ for (let i = 0; i <= segments; i += 1) {
880
+ const t = i / segments;
881
+ const inv = 1 - t;
882
+ const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2;
883
+ const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2;
884
+ points.push([x, y]);
885
+ }
886
+ return points;
887
+ }
888
+
889
+ // src/lib/merge-hover-paint.ts
890
+ function mergeHoverPaint(paint, hoverPaint) {
891
+ if (!hoverPaint) return paint;
892
+ const merged = { ...paint };
893
+ for (const [key, hoverValue] of Object.entries(hoverPaint)) {
894
+ if (hoverValue === void 0) continue;
895
+ const baseValue = merged[key];
896
+ merged[key] = baseValue === void 0 ? hoverValue : ["case", ["boolean", ["feature-state", "hover"], false], hoverValue, baseValue];
897
+ }
898
+ return merged;
899
+ }
900
+
901
+ // src/map-arc/map-arc.tsx
902
+ var DEFAULT_ARC_CURVATURE = 0.2;
903
+ var DEFAULT_ARC_SAMPLES = 64;
904
+ var ARC_HIT_MIN_WIDTH = 12;
905
+ var ARC_HIT_PADDING = 6;
906
+ var DEFAULT_ARC_LAYOUT = {
907
+ "line-join": "round",
908
+ "line-cap": "round"
909
+ };
910
+ function MapArc({
911
+ data,
912
+ id: propId,
913
+ curvature = DEFAULT_ARC_CURVATURE,
914
+ samples = DEFAULT_ARC_SAMPLES,
915
+ paint,
916
+ layout,
917
+ hoverPaint,
918
+ onClick,
919
+ onHover,
920
+ interactive = true,
921
+ beforeId
922
+ }) {
923
+ const { map, isLoaded } = useMap();
924
+ const autoId = useId2();
925
+ const id = propId ?? autoId;
926
+ const sourceId = `arc-source-${id}`;
927
+ const layerId = `arc-layer-${id}`;
928
+ const hitLayerId = `arc-hit-layer-${id}`;
929
+ const primary = useTokenColor("--primary");
930
+ const mergedPaint = useMemo4(
931
+ () => mergeHoverPaint(
932
+ { "line-color": primary, "line-width": 2, "line-opacity": 0.85, ...paint },
933
+ hoverPaint
934
+ ),
935
+ [primary, paint, hoverPaint]
936
+ );
937
+ const mergedLayout = useMemo4(() => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), [layout]);
938
+ const hitWidth = useMemo4(() => {
939
+ const w = paint?.["line-width"] ?? 2;
940
+ const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
941
+ return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
942
+ }, [paint]);
943
+ const geoJSON = useMemo4(
944
+ () => ({
945
+ type: "FeatureCollection",
946
+ features: data.map((arc) => {
947
+ const { from, to, ...properties } = arc;
948
+ return {
949
+ type: "Feature",
950
+ properties,
951
+ geometry: {
952
+ type: "LineString",
953
+ coordinates: buildArcCoordinates(from, to, curvature, samples)
954
+ }
955
+ };
956
+ })
957
+ }),
958
+ [data, curvature, samples]
959
+ );
960
+ const latestRef = useRef5({ data, onClick, onHover });
961
+ latestRef.current = { data, onClick, onHover };
962
+ useEffect7(() => {
963
+ if (!isLoaded || !map) return;
964
+ map.addSource(sourceId, {
965
+ type: "geojson",
966
+ data: geoJSON,
967
+ promoteId: "id"
968
+ });
969
+ map.addLayer(
970
+ {
971
+ id: hitLayerId,
972
+ type: "line",
973
+ source: sourceId,
974
+ layout: DEFAULT_ARC_LAYOUT,
975
+ paint: {
976
+ "line-color": "transparent",
977
+ "line-width": hitWidth,
978
+ "line-opacity": 1
979
+ }
980
+ },
981
+ beforeId
982
+ );
983
+ map.addLayer(
984
+ {
985
+ id: layerId,
986
+ type: "line",
987
+ source: sourceId,
988
+ layout: mergedLayout,
989
+ paint: mergedPaint
990
+ },
991
+ beforeId
992
+ );
993
+ return () => {
994
+ try {
995
+ if (map.getLayer(layerId)) map.removeLayer(layerId);
996
+ if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
997
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
998
+ } catch {
999
+ }
1000
+ };
1001
+ }, [isLoaded, map]);
1002
+ useEffect7(() => {
1003
+ if (!isLoaded || !map) return;
1004
+ const source = map.getSource(sourceId);
1005
+ source?.setData(geoJSON);
1006
+ }, [isLoaded, map, geoJSON, sourceId]);
1007
+ useEffect7(() => {
1008
+ if (!isLoaded || !map || !map.getLayer(layerId)) return;
1009
+ for (const [key, value] of Object.entries(mergedPaint)) {
1010
+ map.setPaintProperty(layerId, key, value);
1011
+ }
1012
+ for (const [key, value] of Object.entries(mergedLayout)) {
1013
+ map.setLayoutProperty(layerId, key, value);
1014
+ }
1015
+ if (map.getLayer(hitLayerId)) {
1016
+ map.setPaintProperty(hitLayerId, "line-width", hitWidth);
1017
+ }
1018
+ }, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]);
1019
+ useEffect7(() => {
1020
+ if (!isLoaded || !map || !interactive) return;
1021
+ let hoveredId = null;
1022
+ const setHover = (next) => {
1023
+ if (next === hoveredId) return;
1024
+ const sourceExists = !!map.getSource(sourceId);
1025
+ if (hoveredId != null && sourceExists) {
1026
+ map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
1027
+ }
1028
+ hoveredId = next;
1029
+ if (next != null && sourceExists) {
1030
+ map.setFeatureState({ source: sourceId, id: next }, { hover: true });
1031
+ }
1032
+ };
1033
+ const findArc = (featureId) => featureId == null ? void 0 : latestRef.current.data.find((arc) => String(arc.id) === String(featureId));
1034
+ const handleMouseMove = (e) => {
1035
+ const featureId = e.features?.[0]?.id;
1036
+ if (featureId == null || featureId === hoveredId) return;
1037
+ setHover(featureId);
1038
+ map.getCanvas().style.cursor = "pointer";
1039
+ const arc = findArc(featureId);
1040
+ if (arc) {
1041
+ latestRef.current.onHover?.({
1042
+ arc,
1043
+ longitude: e.lngLat.lng,
1044
+ latitude: e.lngLat.lat,
1045
+ originalEvent: e
1046
+ });
1047
+ }
1048
+ };
1049
+ const handleMouseLeave = () => {
1050
+ setHover(null);
1051
+ map.getCanvas().style.cursor = "";
1052
+ latestRef.current.onHover?.(null);
1053
+ };
1054
+ const handleClick = (e) => {
1055
+ const arc = findArc(e.features?.[0]?.id);
1056
+ if (!arc) return;
1057
+ latestRef.current.onClick?.({
1058
+ arc,
1059
+ longitude: e.lngLat.lng,
1060
+ latitude: e.lngLat.lat,
1061
+ originalEvent: e
1062
+ });
1063
+ };
1064
+ map.on("mousemove", hitLayerId, handleMouseMove);
1065
+ map.on("mouseleave", hitLayerId, handleMouseLeave);
1066
+ map.on("click", hitLayerId, handleClick);
1067
+ return () => {
1068
+ map.off("mousemove", hitLayerId, handleMouseMove);
1069
+ map.off("mouseleave", hitLayerId, handleMouseLeave);
1070
+ map.off("click", hitLayerId, handleClick);
1071
+ setHover(null);
1072
+ map.getCanvas().style.cursor = "";
1073
+ };
1074
+ }, [isLoaded, map, hitLayerId, sourceId, interactive]);
1075
+ return null;
1076
+ }
1077
+
1078
+ // src/map-geojson/map-geojson.tsx
1079
+ import { useEffect as useEffect8, useId as useId3, useMemo as useMemo5, useRef as useRef6 } from "react";
1080
+ function MapGeoJSON({
1081
+ data,
1082
+ id: propId,
1083
+ promoteId,
1084
+ fillPaint,
1085
+ linePaint,
1086
+ fillHoverPaint,
1087
+ onClick,
1088
+ onHover,
1089
+ interactive = false,
1090
+ beforeId
1091
+ }) {
1092
+ const { map, isLoaded } = useMap();
1093
+ const autoId = useId3();
1094
+ const id = propId ?? autoId;
1095
+ const sourceId = `geojson-source-${id}`;
1096
+ const fillLayerId = `geojson-fill-${id}`;
1097
+ const lineLayerId = `geojson-line-${id}`;
1098
+ const defaultFill = useTokenColor("--border");
1099
+ const defaultLine = useTokenColor("--background");
1100
+ const showFill = fillPaint !== false;
1101
+ const showLine = linePaint !== false;
1102
+ const mergedFillPaint = useMemo5(
1103
+ () => mergeHoverPaint({ "fill-color": defaultFill, ...fillPaint || {} }, fillHoverPaint),
1104
+ [defaultFill, fillPaint, fillHoverPaint]
1105
+ );
1106
+ const mergedLinePaint = useMemo5(
1107
+ () => ({
1108
+ "line-color": defaultLine,
1109
+ "line-width": 0.5,
1110
+ ...linePaint || {}
1111
+ }),
1112
+ [defaultLine, linePaint]
1113
+ );
1114
+ const latestRef = useRef6({ onClick, onHover });
1115
+ latestRef.current = { onClick, onHover };
1116
+ useEffect8(() => {
1117
+ if (!isLoaded || !map) return;
1118
+ map.addSource(sourceId, {
1119
+ type: "geojson",
1120
+ data,
1121
+ ...promoteId ? { promoteId } : {}
1122
+ });
1123
+ return () => {
1124
+ try {
1125
+ if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
1126
+ if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
1127
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
1128
+ } catch {
1129
+ }
1130
+ };
1131
+ }, [isLoaded, map]);
1132
+ useEffect8(() => {
1133
+ if (!isLoaded || !map) return;
1134
+ const source = map.getSource(sourceId);
1135
+ source?.setData(data);
1136
+ }, [isLoaded, map, data, sourceId]);
1137
+ useEffect8(() => {
1138
+ if (!isLoaded || !map) return;
1139
+ const source = map.getSource(sourceId);
1140
+ if (!source) return;
1141
+ if (showFill && !map.getLayer(fillLayerId)) {
1142
+ map.addLayer(
1143
+ {
1144
+ id: fillLayerId,
1145
+ type: "fill",
1146
+ source: sourceId,
1147
+ paint: mergedFillPaint
1148
+ },
1149
+ beforeId
1150
+ );
1151
+ } else if (!showFill && map.getLayer(fillLayerId)) {
1152
+ map.removeLayer(fillLayerId);
1153
+ }
1154
+ if (showLine && !map.getLayer(lineLayerId)) {
1155
+ map.addLayer(
1156
+ {
1157
+ id: lineLayerId,
1158
+ type: "line",
1159
+ source: sourceId,
1160
+ paint: mergedLinePaint
1161
+ },
1162
+ beforeId
1163
+ );
1164
+ } else if (!showLine && map.getLayer(lineLayerId)) {
1165
+ map.removeLayer(lineLayerId);
1166
+ }
1167
+ if (showFill && map.getLayer(fillLayerId)) {
1168
+ for (const [key, value] of Object.entries(mergedFillPaint)) {
1169
+ map.setPaintProperty(fillLayerId, key, value);
1170
+ }
1171
+ }
1172
+ if (showLine && map.getLayer(lineLayerId)) {
1173
+ for (const [key, value] of Object.entries(mergedLinePaint)) {
1174
+ map.setPaintProperty(lineLayerId, key, value);
1175
+ }
1176
+ }
1177
+ }, [
1178
+ isLoaded,
1179
+ map,
1180
+ sourceId,
1181
+ fillLayerId,
1182
+ lineLayerId,
1183
+ showFill,
1184
+ showLine,
1185
+ mergedFillPaint,
1186
+ mergedLinePaint,
1187
+ beforeId
1188
+ ]);
1189
+ useEffect8(() => {
1190
+ if (!isLoaded || !map || !interactive || !showFill) return;
1191
+ let hoveredId = null;
1192
+ const setHover = (next) => {
1193
+ if (next === hoveredId) return;
1194
+ const sourceExists = !!map.getSource(sourceId);
1195
+ if (hoveredId != null && sourceExists) {
1196
+ map.setFeatureState({ source: sourceId, id: hoveredId }, { hover: false });
1197
+ }
1198
+ hoveredId = next;
1199
+ if (next != null && sourceExists) {
1200
+ map.setFeatureState({ source: sourceId, id: next }, { hover: true });
1201
+ }
1202
+ };
1203
+ const handleMouseMove = (e) => {
1204
+ const feature = e.features?.[0];
1205
+ if (!feature) return;
1206
+ map.getCanvas().style.cursor = "pointer";
1207
+ const featureId = feature.id;
1208
+ if (featureId === hoveredId) return;
1209
+ setHover(featureId ?? null);
1210
+ latestRef.current.onHover?.({
1211
+ feature,
1212
+ longitude: e.lngLat.lng,
1213
+ latitude: e.lngLat.lat,
1214
+ originalEvent: e
1215
+ });
1216
+ };
1217
+ const handleMouseLeave = () => {
1218
+ setHover(null);
1219
+ map.getCanvas().style.cursor = "";
1220
+ latestRef.current.onHover?.(null);
1221
+ };
1222
+ const handleClick = (e) => {
1223
+ const feature = e.features?.[0];
1224
+ if (!feature) return;
1225
+ latestRef.current.onClick?.({
1226
+ feature,
1227
+ longitude: e.lngLat.lng,
1228
+ latitude: e.lngLat.lat,
1229
+ originalEvent: e
1230
+ });
1231
+ };
1232
+ map.on("mousemove", fillLayerId, handleMouseMove);
1233
+ map.on("mouseleave", fillLayerId, handleMouseLeave);
1234
+ map.on("click", fillLayerId, handleClick);
1235
+ return () => {
1236
+ map.off("mousemove", fillLayerId, handleMouseMove);
1237
+ map.off("mouseleave", fillLayerId, handleMouseLeave);
1238
+ map.off("click", fillLayerId, handleClick);
1239
+ setHover(null);
1240
+ map.getCanvas().style.cursor = "";
1241
+ };
1242
+ }, [isLoaded, map, fillLayerId, sourceId, interactive, showFill]);
1243
+ return null;
1244
+ }
1245
+
1246
+ // src/map-cluster-layer/map-cluster-layer.tsx
1247
+ import { useEffect as useEffect9, useId as useId4, useMemo as useMemo6 } from "react";
1248
+ var DEFAULT_CLUSTER_THRESHOLDS = [100, 750];
1249
+ function MapClusterLayer({
1250
+ data,
1251
+ clusterMaxZoom = 14,
1252
+ clusterRadius = 50,
1253
+ clusterColors,
1254
+ clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS,
1255
+ pointColor,
1256
+ onPointClick,
1257
+ onClusterClick
1258
+ }) {
1259
+ const { map, isLoaded } = useMap();
1260
+ const id = useId4();
1261
+ const sourceId = `cluster-source-${id}`;
1262
+ const clusterLayerId = `clusters-${id}`;
1263
+ const clusterCountLayerId = `cluster-count-${id}`;
1264
+ const unclusteredLayerId = `unclustered-point-${id}`;
1265
+ const success = useTokenColor("--success");
1266
+ const warning = useTokenColor("--warning");
1267
+ const destructive = useTokenColor("--destructive");
1268
+ const primary = useTokenColor("--primary");
1269
+ const surface = useTokenColor("--background");
1270
+ const resolvedClusterColors = useMemo6(
1271
+ () => clusterColors ?? [success, warning, destructive],
1272
+ [clusterColors, success, warning, destructive]
1273
+ );
1274
+ const resolvedPointColor = pointColor ?? primary;
1275
+ useEffect9(() => {
1276
+ if (!isLoaded || !map) return;
1277
+ map.addSource(sourceId, {
1278
+ type: "geojson",
1279
+ data,
1280
+ cluster: true,
1281
+ clusterMaxZoom,
1282
+ clusterRadius
1283
+ });
1284
+ map.addLayer({
1285
+ id: clusterLayerId,
1286
+ type: "circle",
1287
+ source: sourceId,
1288
+ filter: ["has", "point_count"],
1289
+ paint: {
1290
+ "circle-color": [
1291
+ "step",
1292
+ ["get", "point_count"],
1293
+ resolvedClusterColors[0],
1294
+ clusterThresholds[0],
1295
+ resolvedClusterColors[1],
1296
+ clusterThresholds[1],
1297
+ resolvedClusterColors[2]
1298
+ ],
1299
+ "circle-radius": [
1300
+ "step",
1301
+ ["get", "point_count"],
1302
+ 20,
1303
+ clusterThresholds[0],
1304
+ 30,
1305
+ clusterThresholds[1],
1306
+ 40
1307
+ ],
1308
+ "circle-stroke-width": 1,
1309
+ "circle-stroke-color": surface,
1310
+ "circle-opacity": 0.85
1311
+ }
1312
+ });
1313
+ map.addLayer({
1314
+ id: clusterCountLayerId,
1315
+ type: "symbol",
1316
+ source: sourceId,
1317
+ filter: ["has", "point_count"],
1318
+ layout: {
1319
+ "text-field": "{point_count_abbreviated}",
1320
+ "text-font": ["Open Sans"],
1321
+ "text-size": 12
1322
+ },
1323
+ paint: {
1324
+ "text-color": surface
1325
+ }
1326
+ });
1327
+ map.addLayer({
1328
+ id: unclusteredLayerId,
1329
+ type: "circle",
1330
+ source: sourceId,
1331
+ filter: ["!", ["has", "point_count"]],
1332
+ paint: {
1333
+ "circle-color": resolvedPointColor,
1334
+ "circle-radius": 5,
1335
+ "circle-stroke-width": 2,
1336
+ "circle-stroke-color": surface
1337
+ }
1338
+ });
1339
+ return () => {
1340
+ try {
1341
+ if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
1342
+ if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
1343
+ if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
1344
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
1345
+ } catch {
1346
+ }
1347
+ };
1348
+ }, [isLoaded, map, sourceId]);
1349
+ useEffect9(() => {
1350
+ if (!isLoaded || !map || typeof data === "string") return;
1351
+ const source = map.getSource(sourceId);
1352
+ if (source) {
1353
+ source.setData(data);
1354
+ }
1355
+ }, [isLoaded, map, data, sourceId]);
1356
+ useEffect9(() => {
1357
+ if (!isLoaded || !map) return;
1358
+ if (map.getLayer(clusterLayerId)) {
1359
+ map.setPaintProperty(clusterLayerId, "circle-color", [
1360
+ "step",
1361
+ ["get", "point_count"],
1362
+ resolvedClusterColors[0],
1363
+ clusterThresholds[0],
1364
+ resolvedClusterColors[1],
1365
+ clusterThresholds[1],
1366
+ resolvedClusterColors[2]
1367
+ ]);
1368
+ map.setPaintProperty(clusterLayerId, "circle-radius", [
1369
+ "step",
1370
+ ["get", "point_count"],
1371
+ 20,
1372
+ clusterThresholds[0],
1373
+ 30,
1374
+ clusterThresholds[1],
1375
+ 40
1376
+ ]);
1377
+ map.setPaintProperty(clusterLayerId, "circle-stroke-color", surface);
1378
+ }
1379
+ if (map.getLayer(clusterCountLayerId)) {
1380
+ map.setPaintProperty(clusterCountLayerId, "text-color", surface);
1381
+ }
1382
+ if (map.getLayer(unclusteredLayerId)) {
1383
+ map.setPaintProperty(unclusteredLayerId, "circle-color", resolvedPointColor);
1384
+ map.setPaintProperty(unclusteredLayerId, "circle-stroke-color", surface);
1385
+ }
1386
+ }, [
1387
+ isLoaded,
1388
+ map,
1389
+ clusterLayerId,
1390
+ clusterCountLayerId,
1391
+ unclusteredLayerId,
1392
+ resolvedClusterColors,
1393
+ clusterThresholds,
1394
+ resolvedPointColor,
1395
+ surface
1396
+ ]);
1397
+ useEffect9(() => {
1398
+ if (!isLoaded || !map) return;
1399
+ const handleClusterClick = async (e) => {
1400
+ const features = map.queryRenderedFeatures(e.point, {
1401
+ layers: [clusterLayerId]
1402
+ });
1403
+ const feature = features[0];
1404
+ if (!feature) return;
1405
+ const clusterId = feature.properties?.cluster_id;
1406
+ const pointCount = feature.properties?.point_count;
1407
+ const coordinates = feature.geometry.coordinates;
1408
+ if (onClusterClick) {
1409
+ onClusterClick(clusterId, coordinates, pointCount);
1410
+ } else {
1411
+ const source = map.getSource(sourceId);
1412
+ const zoom = await source.getClusterExpansionZoom(clusterId);
1413
+ map.easeTo({
1414
+ center: coordinates,
1415
+ zoom
1416
+ });
1417
+ }
1418
+ };
1419
+ const handlePointClick = (e) => {
1420
+ const feature = e.features?.[0];
1421
+ if (!onPointClick || !feature) return;
1422
+ const coordinates = feature.geometry.coordinates.slice();
1423
+ while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
1424
+ coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
1425
+ }
1426
+ onPointClick(feature, coordinates);
1427
+ };
1428
+ const handleMouseEnterCluster = () => {
1429
+ map.getCanvas().style.cursor = "pointer";
1430
+ };
1431
+ const handleMouseLeaveCluster = () => {
1432
+ map.getCanvas().style.cursor = "";
1433
+ };
1434
+ const handleMouseEnterPoint = () => {
1435
+ if (onPointClick) {
1436
+ map.getCanvas().style.cursor = "pointer";
1437
+ }
1438
+ };
1439
+ const handleMouseLeavePoint = () => {
1440
+ map.getCanvas().style.cursor = "";
1441
+ };
1442
+ map.on("click", clusterLayerId, handleClusterClick);
1443
+ map.on("click", unclusteredLayerId, handlePointClick);
1444
+ map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
1445
+ map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
1446
+ map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
1447
+ map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
1448
+ return () => {
1449
+ map.off("click", clusterLayerId, handleClusterClick);
1450
+ map.off("click", unclusteredLayerId, handlePointClick);
1451
+ map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
1452
+ map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
1453
+ map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
1454
+ map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
1455
+ };
1456
+ }, [isLoaded, map, clusterLayerId, unclusteredLayerId, sourceId, onClusterClick, onPointClick]);
1457
+ return null;
1458
+ }
1459
+ export {
1460
+ MapArc,
1461
+ MapCanvas,
1462
+ MapClusterLayer,
1463
+ MapControls,
1464
+ MapGeoJSON,
1465
+ MapMarker,
1466
+ MapMarkerContent,
1467
+ MapMarkerLabel,
1468
+ MapMarkerPopup,
1469
+ MapMarkerTooltip,
1470
+ MapPopup,
1471
+ MapRoute,
1472
+ buildArcCoordinates,
1473
+ useMap
1474
+ };
1475
+ //# sourceMappingURL=index.js.map