@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,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
|
+
}
|