@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,373 @@
1
+ "use client";
2
+
3
+ import MapLibreGL, { type MarkerOptions, type PopupOptions } from "maplibre-gl";
4
+ import { createContext, use, useEffect, useMemo, useRef, type ReactNode } from "react";
5
+ import { createPortal } from "react-dom";
6
+ import { X } from "lucide-react";
7
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
8
+
9
+ import { useMap } from "../map-canvas/map-context";
10
+
11
+ type MarkerContextValue = {
12
+ marker: MapLibreGL.Marker;
13
+ map: MapLibreGL.Map | null;
14
+ };
15
+
16
+ const MarkerContext = createContext<MarkerContextValue | null>(null);
17
+
18
+ function useMarkerContext() {
19
+ const context = use(MarkerContext);
20
+ if (!context) {
21
+ throw new Error("MapMarker sub-components must be used within <MapMarker>");
22
+ }
23
+ return context;
24
+ }
25
+
26
+ export type MapMarkerProps = {
27
+ /** Longitude coordinate for the marker position. */
28
+ longitude: number;
29
+ /** Latitude coordinate for the marker position. */
30
+ latitude: number;
31
+ /** Marker sub-components (MapMarkerContent, MapMarkerPopup, MapMarkerTooltip, MapMarkerLabel). */
32
+ children: ReactNode;
33
+ /** Callback when the marker is clicked. */
34
+ onClick?: (e: MouseEvent) => void;
35
+ /** Callback when the mouse enters the marker. */
36
+ onMouseEnter?: (e: MouseEvent) => void;
37
+ /** Callback when the mouse leaves the marker. */
38
+ onMouseLeave?: (e: MouseEvent) => void;
39
+ /** Callback when a drag starts (requires `draggable`). */
40
+ onDragStart?: (lngLat: { lng: number; lat: number }) => void;
41
+ /** Callback during a drag (requires `draggable`). */
42
+ onDrag?: (lngLat: { lng: number; lat: number }) => void;
43
+ /** Callback when a drag ends (requires `draggable`). */
44
+ onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
45
+ } & Omit<MarkerOptions, "element">;
46
+
47
+ /**
48
+ * A marker anchored at a lng/lat. Compose the pieces you need:
49
+ * `MapMarkerContent` (the visual), `MapMarkerLabel`, `MapMarkerPopup` (opens
50
+ * on click) and `MapMarkerTooltip` (shows on hover).
51
+ */
52
+ export function MapMarker({
53
+ longitude,
54
+ latitude,
55
+ children,
56
+ onClick,
57
+ onMouseEnter,
58
+ onMouseLeave,
59
+ onDragStart,
60
+ onDrag,
61
+ onDragEnd,
62
+ draggable = false,
63
+ ...markerOptions
64
+ }: MapMarkerProps) {
65
+ const { map } = useMap();
66
+
67
+ const callbacksRef = useRef({
68
+ onClick,
69
+ onMouseEnter,
70
+ onMouseLeave,
71
+ onDragStart,
72
+ onDrag,
73
+ onDragEnd,
74
+ });
75
+ callbacksRef.current = {
76
+ onClick,
77
+ onMouseEnter,
78
+ onMouseLeave,
79
+ onDragStart,
80
+ onDrag,
81
+ onDragEnd,
82
+ };
83
+
84
+ const marker = useMemo(() => {
85
+ const markerInstance = new MapLibreGL.Marker({
86
+ ...markerOptions,
87
+ element: document.createElement("div"),
88
+ draggable,
89
+ }).setLngLat([longitude, latitude]);
90
+
91
+ const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
92
+ const handleMouseEnter = (e: MouseEvent) => callbacksRef.current.onMouseEnter?.(e);
93
+ const handleMouseLeave = (e: MouseEvent) => callbacksRef.current.onMouseLeave?.(e);
94
+
95
+ markerInstance.getElement()?.addEventListener("click", handleClick);
96
+ markerInstance.getElement()?.addEventListener("mouseenter", handleMouseEnter);
97
+ markerInstance.getElement()?.addEventListener("mouseleave", handleMouseLeave);
98
+
99
+ const handleDragStart = () => {
100
+ const lngLat = markerInstance.getLngLat();
101
+ callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
102
+ };
103
+ const handleDrag = () => {
104
+ const lngLat = markerInstance.getLngLat();
105
+ callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
106
+ };
107
+ const handleDragEnd = () => {
108
+ const lngLat = markerInstance.getLngLat();
109
+ callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
110
+ };
111
+
112
+ markerInstance.on("dragstart", handleDragStart);
113
+ markerInstance.on("drag", handleDrag);
114
+ markerInstance.on("dragend", handleDragEnd);
115
+
116
+ return markerInstance;
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- instance created once; position/options are synced by the effect below
118
+ }, []);
119
+
120
+ useEffect(() => {
121
+ if (!map) return;
122
+
123
+ marker.addTo(map);
124
+
125
+ return () => {
126
+ marker.remove();
127
+ };
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `marker` is stable (created once above)
129
+ }, [map]);
130
+
131
+ const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
132
+
133
+ useEffect(() => {
134
+ const current = marker.getLngLat();
135
+ if (current.lng !== longitude || current.lat !== latitude) {
136
+ marker.setLngLat([longitude, latitude]);
137
+ }
138
+
139
+ if (marker.isDraggable() !== draggable) {
140
+ marker.setDraggable(draggable);
141
+ }
142
+
143
+ const currentOffset = marker.getOffset();
144
+ const newOffset = offset ?? [0, 0];
145
+ const [newOffsetX, newOffsetY] = Array.isArray(newOffset)
146
+ ? newOffset
147
+ : [newOffset.x, newOffset.y];
148
+ if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {
149
+ marker.setOffset(newOffset);
150
+ }
151
+
152
+ if (marker.getRotation() !== (rotation ?? 0)) {
153
+ marker.setRotation(rotation ?? 0);
154
+ }
155
+ if (marker.getRotationAlignment() !== (rotationAlignment ?? "auto")) {
156
+ marker.setRotationAlignment(rotationAlignment ?? "auto");
157
+ }
158
+ if (marker.getPitchAlignment() !== (pitchAlignment ?? "auto")) {
159
+ marker.setPitchAlignment(pitchAlignment ?? "auto");
160
+ }
161
+ }, [marker, longitude, latitude, draggable, offset, rotation, rotationAlignment, pitchAlignment]);
162
+
163
+ return <MarkerContext.Provider value={{ marker, map }}>{children}</MarkerContext.Provider>;
164
+ }
165
+
166
+ export interface MapMarkerContentProps {
167
+ /** Custom marker content. Defaults to a primary-colored dot. */
168
+ children?: ReactNode;
169
+ /** Additional CSS classes for the marker container. */
170
+ className?: string;
171
+ }
172
+
173
+ /** The marker's visual, portaled into the MapLibre marker element. */
174
+ export function MapMarkerContent({ children, className }: MapMarkerContentProps) {
175
+ const { marker } = useMarkerContext();
176
+
177
+ return createPortal(
178
+ <div className={cn("relative cursor-pointer", className)}>
179
+ {children || <DefaultMarkerIcon />}
180
+ </div>,
181
+ marker.getElement(),
182
+ );
183
+ }
184
+
185
+ function DefaultMarkerIcon() {
186
+ return (
187
+ <div className="relative size-4 rounded-full border-2 border-background bg-primary shadow-sm" />
188
+ );
189
+ }
190
+
191
+ function PopupCloseButton({ onClick }: { onClick: () => void }) {
192
+ return (
193
+ <button
194
+ type="button"
195
+ onClick={onClick}
196
+ aria-label="Close popup"
197
+ 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"
198
+ >
199
+ <X className="size-3.5" aria-hidden="true" />
200
+ </button>
201
+ );
202
+ }
203
+
204
+ export type MapMarkerPopupProps = {
205
+ /** Popup content. */
206
+ children: ReactNode;
207
+ /** Additional CSS classes for the popup container. */
208
+ className?: string;
209
+ /** Show a close button in the popup (default: false). */
210
+ closeButton?: boolean;
211
+ } & Omit<PopupOptions, "className" | "closeButton">;
212
+
213
+ /** A popup attached to the marker — MapLibre toggles it on marker click. */
214
+ export function MapMarkerPopup({
215
+ children,
216
+ className,
217
+ closeButton = false,
218
+ ...popupOptions
219
+ }: MapMarkerPopupProps) {
220
+ const { marker, map } = useMarkerContext();
221
+ const container = useMemo(() => document.createElement("div"), []);
222
+ const { offset, maxWidth } = popupOptions;
223
+
224
+ const popup = useMemo(() => {
225
+ return new MapLibreGL.Popup({
226
+ offset: 16,
227
+ ...popupOptions,
228
+ closeButton: false,
229
+ })
230
+ .setMaxWidth("none")
231
+ .setDOMContent(container);
232
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- instance created once; options are synced by the effect below
233
+ }, []);
234
+
235
+ useEffect(() => {
236
+ if (!map) return;
237
+
238
+ popup.setDOMContent(container);
239
+ marker.setPopup(popup);
240
+
241
+ return () => {
242
+ marker.setPopup(null);
243
+ };
244
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- popup/marker/container are stable
245
+ }, [map]);
246
+
247
+ // Sync popup options when they change.
248
+ useEffect(() => {
249
+ popup.setOffset(offset ?? 16);
250
+ if (maxWidth) {
251
+ popup.setMaxWidth(maxWidth);
252
+ }
253
+ }, [popup, offset, maxWidth]);
254
+
255
+ const handleClose = () => popup.remove();
256
+
257
+ return createPortal(
258
+ <div
259
+ className={cn(
260
+ "relative max-w-62 rounded-md bg-popover p-3 text-popover-foreground shadow-ring-md",
261
+ "animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance",
262
+ className,
263
+ )}
264
+ >
265
+ {closeButton && <PopupCloseButton onClick={handleClose} />}
266
+ {children}
267
+ </div>,
268
+ container,
269
+ );
270
+ }
271
+
272
+ export type MapMarkerTooltipProps = {
273
+ /** Tooltip content. */
274
+ children: ReactNode;
275
+ /** Additional CSS classes for the tooltip container. */
276
+ className?: string;
277
+ } & Omit<PopupOptions, "className" | "closeButton" | "closeOnClick">;
278
+
279
+ /** A hover tooltip attached to the marker. */
280
+ export function MapMarkerTooltip({ children, className, ...popupOptions }: MapMarkerTooltipProps) {
281
+ const { marker, map } = useMarkerContext();
282
+ const container = useMemo(() => document.createElement("div"), []);
283
+ const { offset, maxWidth } = popupOptions;
284
+
285
+ const tooltip = useMemo(() => {
286
+ return new MapLibreGL.Popup({
287
+ offset: 16,
288
+ ...popupOptions,
289
+ closeOnClick: true,
290
+ closeButton: false,
291
+ }).setMaxWidth("none");
292
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- instance created once; options are synced by the effect below
293
+ }, []);
294
+
295
+ useEffect(() => {
296
+ if (!map) return;
297
+
298
+ tooltip.setDOMContent(container);
299
+
300
+ const handleMouseEnter = () => {
301
+ tooltip.setLngLat(marker.getLngLat()).addTo(map);
302
+ };
303
+ const handleMouseLeave = () => tooltip.remove();
304
+
305
+ marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
306
+ marker.getElement()?.addEventListener("mouseleave", handleMouseLeave);
307
+
308
+ return () => {
309
+ marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
310
+ marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave);
311
+ tooltip.remove();
312
+ };
313
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- tooltip/marker/container are stable
314
+ }, [map]);
315
+
316
+ // Sync tooltip options when they change.
317
+ useEffect(() => {
318
+ tooltip.setOffset(offset ?? 16);
319
+ if (maxWidth) {
320
+ tooltip.setMaxWidth(maxWidth);
321
+ }
322
+ }, [tooltip, offset, maxWidth]);
323
+
324
+ return createPortal(
325
+ <div
326
+ className={cn(
327
+ "pointer-events-none rounded-md bg-foreground px-2 py-1 text-meta text-balance text-background shadow-md",
328
+ "animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance",
329
+ className,
330
+ )}
331
+ >
332
+ {children}
333
+ </div>,
334
+ container,
335
+ );
336
+ }
337
+
338
+ export interface MapMarkerLabelProps {
339
+ /** Label text content. */
340
+ children: ReactNode;
341
+ /** Additional CSS classes for the label. */
342
+ className?: string;
343
+ /** Position of the label relative to the marker (default: "top"). */
344
+ position?: "top" | "bottom";
345
+ }
346
+
347
+ /**
348
+ * A small always-visible text label above or below the marker. Portaled into
349
+ * the marker element so it anchors to the marker whether composed as a sibling
350
+ * of `MapMarkerContent` or nested inside it (the three-theme sweep caught the
351
+ * sibling composition rendering the label against the map container instead).
352
+ */
353
+ export function MapMarkerLabel({ children, className, position = "top" }: MapMarkerLabelProps) {
354
+ const { marker } = useMarkerContext();
355
+ const positionClasses = {
356
+ top: "bottom-full mb-1",
357
+ bottom: "top-full mt-1",
358
+ };
359
+
360
+ return createPortal(
361
+ <div
362
+ className={cn(
363
+ "absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
364
+ "text-meta font-medium text-foreground",
365
+ positionClasses[position],
366
+ className,
367
+ )}
368
+ >
369
+ {children}
370
+ </div>,
371
+ marker.getElement(),
372
+ );
373
+ }
@@ -0,0 +1 @@
1
+ export { MapPopup, type MapPopupProps } from "./map-popup";
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import MapLibreGL, { type PopupOptions } from "maplibre-gl";
4
+ import { useEffect, useMemo, useRef, type ReactNode } from "react";
5
+ import { createPortal } from "react-dom";
6
+ import { X } from "lucide-react";
7
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
8
+
9
+ import { useMap } from "../map-canvas/map-context";
10
+
11
+ export type MapPopupProps = {
12
+ /** Longitude coordinate for the popup position. */
13
+ longitude: number;
14
+ /** Latitude coordinate for the popup position. */
15
+ latitude: number;
16
+ /** Callback when the popup is closed. */
17
+ onClose?: () => void;
18
+ /** Popup content. */
19
+ children: ReactNode;
20
+ /** Additional CSS classes for the popup container. */
21
+ className?: string;
22
+ /** Show a close button in the popup (default: false). */
23
+ closeButton?: boolean;
24
+ } & Omit<PopupOptions, "className" | "closeButton">;
25
+
26
+ /**
27
+ * A standalone popup anchored at a lng/lat (not attached to a marker) —
28
+ * typically rendered conditionally from app state (e.g. after a layer click).
29
+ */
30
+ export function MapPopup({
31
+ longitude,
32
+ latitude,
33
+ onClose,
34
+ children,
35
+ className,
36
+ closeButton = false,
37
+ ...popupOptions
38
+ }: MapPopupProps) {
39
+ const { map } = useMap();
40
+ const onCloseRef = useRef(onClose);
41
+ onCloseRef.current = onClose;
42
+ const container = useMemo(() => document.createElement("div"), []);
43
+ const { offset, maxWidth } = popupOptions;
44
+
45
+ const popup = useMemo(() => {
46
+ return new MapLibreGL.Popup({
47
+ offset: 16,
48
+ ...popupOptions,
49
+ closeButton: false,
50
+ })
51
+ .setMaxWidth("none")
52
+ .setLngLat([longitude, latitude]);
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- instance created once; position/options are synced by the effect below
54
+ }, []);
55
+
56
+ useEffect(() => {
57
+ if (!map) return;
58
+
59
+ const onCloseProp = () => onCloseRef.current?.();
60
+
61
+ popup.on("close", onCloseProp);
62
+
63
+ popup.setDOMContent(container);
64
+ popup.addTo(map);
65
+
66
+ return () => {
67
+ popup.off("close", onCloseProp);
68
+ if (popup.isOpen()) {
69
+ popup.remove();
70
+ }
71
+ };
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- popup/container are stable
73
+ }, [map]);
74
+
75
+ // Sync popup position and options when they change.
76
+ useEffect(() => {
77
+ const current = popup.getLngLat();
78
+ if (!current || current.lng !== longitude || current.lat !== latitude) {
79
+ popup.setLngLat([longitude, latitude]);
80
+ }
81
+ popup.setOffset(offset ?? 16);
82
+ if (maxWidth) {
83
+ popup.setMaxWidth(maxWidth);
84
+ }
85
+ }, [popup, longitude, latitude, offset, maxWidth]);
86
+
87
+ const handleClose = () => {
88
+ popup.remove();
89
+ };
90
+
91
+ return createPortal(
92
+ <div
93
+ className={cn(
94
+ "relative max-w-62 rounded-md bg-popover p-3 text-popover-foreground shadow-ring-md",
95
+ "animate-in fade-in-0 zoom-in-95 duration-fast ease-entrance",
96
+ className,
97
+ )}
98
+ >
99
+ {closeButton && (
100
+ <button
101
+ type="button"
102
+ onClick={handleClose}
103
+ aria-label="Close popup"
104
+ 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"
105
+ >
106
+ <X className="size-3.5" aria-hidden="true" />
107
+ </button>
108
+ )}
109
+ {children}
110
+ </div>,
111
+ container,
112
+ );
113
+ }
@@ -0,0 +1 @@
1
+ export { MapRoute, type MapRouteProps } from "./map-route";
@@ -0,0 +1,56 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import { MapCanvas } from "../map-canvas";
4
+ import { MapMarker, MapMarkerContent, MapMarkerLabel } from "../map-marker";
5
+ import { MapRoute } from "./map-route";
6
+
7
+ // A short cycle route through central Berlin.
8
+ const routeCoordinates: [number, number][] = [
9
+ [13.3777, 52.5163],
10
+ [13.3841, 52.5186],
11
+ [13.3903, 52.5209],
12
+ [13.3976, 52.5211],
13
+ [13.4049, 52.5216],
14
+ [13.4132, 52.5219],
15
+ [13.4213, 52.5205],
16
+ [13.4266, 52.5196],
17
+ [13.4318, 52.5211],
18
+ ];
19
+
20
+ const meta = {
21
+ title: "Maps/MapRoute",
22
+ component: MapRoute,
23
+ tags: ["autodocs"],
24
+ parameters: { layout: "fullscreen" },
25
+ } satisfies Meta<typeof MapRoute>;
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ /** Default paint follows the theme's `--primary` token. */
30
+ export const Default: Story = {
31
+ render: () => (
32
+ <div className="h-[480px]">
33
+ <MapCanvas center={[13.4049, 52.5196]} zoom={13}>
34
+ <MapRoute coordinates={routeCoordinates} />
35
+ <MapMarker longitude={13.3777} latitude={52.5163}>
36
+ <MapMarkerContent />
37
+ <MapMarkerLabel>Start</MapMarkerLabel>
38
+ </MapMarker>
39
+ <MapMarker longitude={13.4318} latitude={52.5211}>
40
+ <MapMarkerContent />
41
+ <MapMarkerLabel>End</MapMarkerLabel>
42
+ </MapMarker>
43
+ </MapCanvas>
44
+ </div>
45
+ ),
46
+ };
47
+
48
+ export const Dashed: Story = {
49
+ render: () => (
50
+ <div className="h-[480px]">
51
+ <MapCanvas center={[13.4049, 52.5196]} zoom={13}>
52
+ <MapRoute coordinates={routeCoordinates} dashArray={[2, 2]} width={2} opacity={0.9} />
53
+ </MapCanvas>
54
+ </div>
55
+ ),
56
+ };
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import type MapLibreGL from "maplibre-gl";
4
+ import { useEffect, useId } from "react";
5
+
6
+ import { useMap } from "../map-canvas/map-context";
7
+ import { useTokenColor } from "../lib/use-token-color";
8
+
9
+ export interface MapRouteProps {
10
+ /** Optional unique identifier for the route layer. */
11
+ id?: string;
12
+ /** Array of [longitude, latitude] coordinate pairs defining the route. */
13
+ coordinates: [number, number][];
14
+ /** Line color as a CSS color value. Defaults to the theme's `--primary` token. */
15
+ color?: string;
16
+ /** Line width in pixels (default: 3). */
17
+ width?: number;
18
+ /** Line opacity from 0 to 1 (default: 0.8). */
19
+ opacity?: number;
20
+ /** Dash pattern [dash length, gap length] for dashed lines. */
21
+ dashArray?: [number, number];
22
+ /** Callback when the route line is clicked. */
23
+ onClick?: () => void;
24
+ /** Callback when the mouse enters the route line. */
25
+ onMouseEnter?: () => void;
26
+ /** Callback when the mouse leaves the route line. */
27
+ onMouseLeave?: () => void;
28
+ /** Whether the route is interactive — shows a pointer cursor on hover (default: true). */
29
+ interactive?: boolean;
30
+ }
31
+
32
+ /** A GeoJSON line layer for routes/paths. Renders nothing itself — it draws on the map. */
33
+ export function MapRoute({
34
+ id: propId,
35
+ coordinates,
36
+ color,
37
+ width = 3,
38
+ opacity = 0.8,
39
+ dashArray,
40
+ onClick,
41
+ onMouseEnter,
42
+ onMouseLeave,
43
+ interactive = true,
44
+ }: MapRouteProps) {
45
+ const { map, isLoaded } = useMap();
46
+ const autoId = useId();
47
+ const id = propId ?? autoId;
48
+ const sourceId = `route-source-${id}`;
49
+ const layerId = `route-layer-${id}`;
50
+
51
+ const primary = useTokenColor("--primary");
52
+ const lineColor = color ?? primary;
53
+
54
+ // Add source and layer on mount.
55
+ useEffect(() => {
56
+ if (!isLoaded || !map) return;
57
+
58
+ map.addSource(sourceId, {
59
+ type: "geojson",
60
+ data: {
61
+ type: "Feature",
62
+ properties: {},
63
+ geometry: { type: "LineString", coordinates: [] },
64
+ },
65
+ });
66
+
67
+ map.addLayer({
68
+ id: layerId,
69
+ type: "line",
70
+ source: sourceId,
71
+ layout: { "line-join": "round", "line-cap": "round" },
72
+ paint: {
73
+ "line-color": lineColor,
74
+ "line-width": width,
75
+ "line-opacity": opacity,
76
+ ...(dashArray && { "line-dasharray": dashArray }),
77
+ },
78
+ });
79
+
80
+ return () => {
81
+ try {
82
+ if (map.getLayer(layerId)) map.removeLayer(layerId);
83
+ if (map.getSource(sourceId)) map.removeSource(sourceId);
84
+ } catch {
85
+ // style may be mid-reload
86
+ }
87
+ };
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- source/layer created once per map; data + paint are synced by the effects below
89
+ }, [isLoaded, map]);
90
+
91
+ // When coordinates change, update the source data.
92
+ useEffect(() => {
93
+ if (!isLoaded || !map || coordinates.length < 2) return;
94
+
95
+ const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
96
+ if (source) {
97
+ source.setData({
98
+ type: "Feature",
99
+ properties: {},
100
+ geometry: { type: "LineString", coordinates },
101
+ });
102
+ }
103
+ }, [isLoaded, map, coordinates, sourceId]);
104
+
105
+ // Sync paint when styling (or the resolved theme color) changes.
106
+ useEffect(() => {
107
+ if (!isLoaded || !map || !map.getLayer(layerId)) return;
108
+
109
+ map.setPaintProperty(layerId, "line-color", lineColor);
110
+ map.setPaintProperty(layerId, "line-width", width);
111
+ map.setPaintProperty(layerId, "line-opacity", opacity);
112
+ map.setPaintProperty(layerId, "line-dasharray", dashArray);
113
+ }, [isLoaded, map, layerId, lineColor, width, opacity, dashArray]);
114
+
115
+ // Handle click and hover events.
116
+ useEffect(() => {
117
+ if (!isLoaded || !map || !interactive) return;
118
+
119
+ const handleClick = () => {
120
+ onClick?.();
121
+ };
122
+ const handleMouseEnter = () => {
123
+ map.getCanvas().style.cursor = "pointer";
124
+ onMouseEnter?.();
125
+ };
126
+ const handleMouseLeave = () => {
127
+ map.getCanvas().style.cursor = "";
128
+ onMouseLeave?.();
129
+ };
130
+
131
+ map.on("click", layerId, handleClick);
132
+ map.on("mouseenter", layerId, handleMouseEnter);
133
+ map.on("mouseleave", layerId, handleMouseLeave);
134
+
135
+ return () => {
136
+ map.off("click", layerId, handleClick);
137
+ map.off("mouseenter", layerId, handleMouseEnter);
138
+ map.off("mouseleave", layerId, handleMouseLeave);
139
+ };
140
+ }, [isLoaded, map, layerId, onClick, onMouseEnter, onMouseLeave, interactive]);
141
+
142
+ return null;
143
+ }