@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
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
|