@deftui/addons 0.1.3 → 0.1.4
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/dist/map.d.mts +30 -0
- package/dist/map.mjs +18 -0
- package/dist/maplibregl/map.d.mts +388 -0
- package/dist/maplibregl/map.mjs +1641 -0
- package/dist/node_modules/.bun/@types_geojson@7946.0.16/node_modules/@types/geojson/index.d.mts +174 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/Icon.mjs +37 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/context.mjs +12 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/createLucideIcon.mjs +23 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/loader-circle.mjs +20 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/locate.mjs +51 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/maximize.mjs +33 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/minus.mjs +19 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/plus.mjs +22 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/x.mjs +22 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/build/buildLucideIconForReact.mjs +22 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/build/buildLucideIconNode.mjs +57 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/build/defaultAttributes.mjs +19 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/utils/hasA11yProp.mjs +12 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.mjs +11 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/utils/toCamelCase.mjs +22 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/utils/toKebabCase.mjs +9 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/utils/toLucideIconData.mjs +18 -0
- package/dist/node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/shared/src/utils/toPascalCase.mjs +13 -0
- package/dist/react-hook-form/field.mjs +1 -1
- package/dist/styles.css +21 -0
- package/package.json +12 -3
|
@@ -0,0 +1,1641 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import LoaderCircle from "../node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/loader-circle.mjs";
|
|
3
|
+
import Locate from "../node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/locate.mjs";
|
|
4
|
+
import Maximize from "../node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/maximize.mjs";
|
|
5
|
+
import Minus from "../node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/minus.mjs";
|
|
6
|
+
import Plus from "../node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/plus.mjs";
|
|
7
|
+
import X from "../node_modules/.bun/lucide-react@1.66.0_c841d34b494ec30a/node_modules/lucide-react/dist/esm/icons/x.mjs";
|
|
8
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
9
|
+
import { cn } from "@deftui/react";
|
|
10
|
+
import { createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
// src/maplibregl/map.tsx
|
|
14
|
+
let maplibrePromise = null;
|
|
15
|
+
function loadMapLibre() {
|
|
16
|
+
maplibrePromise ??= import("maplibre-gl").then((lib) => {
|
|
17
|
+
if (!lib.getWorkerUrl()) lib.setWorkerUrl(`https://unpkg.com/maplibre-gl@${lib.getVersion()}/dist/maplibre-gl-worker.mjs`);
|
|
18
|
+
return lib;
|
|
19
|
+
}).catch((error) => {
|
|
20
|
+
maplibrePromise = null;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
return maplibrePromise;
|
|
24
|
+
}
|
|
25
|
+
if (typeof window !== "undefined") loadMapLibre().catch(() => {});
|
|
26
|
+
const defaultStyles = {
|
|
27
|
+
dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
|
28
|
+
light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
|
|
29
|
+
};
|
|
30
|
+
const blankMapStyle = {
|
|
31
|
+
version: 8,
|
|
32
|
+
sources: {},
|
|
33
|
+
layers: [{
|
|
34
|
+
id: "background",
|
|
35
|
+
type: "background",
|
|
36
|
+
paint: { "background-color": "rgba(0, 0, 0, 0)" }
|
|
37
|
+
}]
|
|
38
|
+
};
|
|
39
|
+
const blankGlobeStyle = {
|
|
40
|
+
...blankMapStyle,
|
|
41
|
+
projection: { type: "globe" },
|
|
42
|
+
layers: [{
|
|
43
|
+
id: "background",
|
|
44
|
+
type: "background",
|
|
45
|
+
paint: { "background-color": "rgba(128, 128, 128, 0.12)" }
|
|
46
|
+
}]
|
|
47
|
+
};
|
|
48
|
+
function useStableValue(value) {
|
|
49
|
+
const key = useMemo(() => JSON.stringify(value) ?? "", [value]);
|
|
50
|
+
return useMemo(() => value, [key]);
|
|
51
|
+
}
|
|
52
|
+
function mergeHoverPaint(paint, hoverPaint) {
|
|
53
|
+
if (!hoverPaint) return paint;
|
|
54
|
+
const merged = { ...paint };
|
|
55
|
+
for (const [key, hoverValue] of Object.entries(hoverPaint)) {
|
|
56
|
+
if (hoverValue === void 0) continue;
|
|
57
|
+
const baseValue = merged[key];
|
|
58
|
+
merged[key] = baseValue === void 0 ? hoverValue : [
|
|
59
|
+
"case",
|
|
60
|
+
[
|
|
61
|
+
"boolean",
|
|
62
|
+
["feature-state", "hover"],
|
|
63
|
+
false
|
|
64
|
+
],
|
|
65
|
+
hoverValue,
|
|
66
|
+
baseValue
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
return merged;
|
|
70
|
+
}
|
|
71
|
+
function getDocumentTheme() {
|
|
72
|
+
if (typeof document === "undefined") return null;
|
|
73
|
+
const root = document.documentElement;
|
|
74
|
+
if (root.classList.contains("dark")) return "dark";
|
|
75
|
+
if (root.classList.contains("light")) return "light";
|
|
76
|
+
const dataTheme = root.dataset.theme;
|
|
77
|
+
if (dataTheme === "dark" || dataTheme === "light") return dataTheme;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function getSystemTheme() {
|
|
81
|
+
if (typeof window === "undefined") return "light";
|
|
82
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
83
|
+
}
|
|
84
|
+
function useResolvedTheme(themeProp) {
|
|
85
|
+
const [detectedTheme, setDetectedTheme] = useState(() => getDocumentTheme() ?? getSystemTheme());
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (themeProp) return;
|
|
88
|
+
const observer = new MutationObserver(() => {
|
|
89
|
+
const docTheme = getDocumentTheme();
|
|
90
|
+
if (docTheme) setDetectedTheme(docTheme);
|
|
91
|
+
});
|
|
92
|
+
observer.observe(document.documentElement, {
|
|
93
|
+
attributes: true,
|
|
94
|
+
attributeFilter: ["class", "data-theme"]
|
|
95
|
+
});
|
|
96
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
97
|
+
const handleSystemChange = (e) => {
|
|
98
|
+
if (!getDocumentTheme()) setDetectedTheme(e.matches ? "dark" : "light");
|
|
99
|
+
};
|
|
100
|
+
mediaQuery.addEventListener("change", handleSystemChange);
|
|
101
|
+
return () => {
|
|
102
|
+
observer.disconnect();
|
|
103
|
+
mediaQuery.removeEventListener("change", handleSystemChange);
|
|
104
|
+
};
|
|
105
|
+
}, [themeProp]);
|
|
106
|
+
return themeProp ?? detectedTheme;
|
|
107
|
+
}
|
|
108
|
+
const MapContext = createContext(null);
|
|
109
|
+
function useMap() {
|
|
110
|
+
const context = useContext(MapContext);
|
|
111
|
+
if (!context) throw new Error("useMap must be used within a Map component");
|
|
112
|
+
return context;
|
|
113
|
+
}
|
|
114
|
+
function useMapLib() {
|
|
115
|
+
const { mapLib } = useMap();
|
|
116
|
+
if (!mapLib) throw new Error("useMapLib must be used within a Map component");
|
|
117
|
+
return mapLib;
|
|
118
|
+
}
|
|
119
|
+
function DefaultLoader() {
|
|
120
|
+
return /* @__PURE__ */ jsx("div", {
|
|
121
|
+
className: "bg-background/50 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-xs",
|
|
122
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
123
|
+
className: "flex gap-1",
|
|
124
|
+
children: [
|
|
125
|
+
/* @__PURE__ */ jsx("span", { className: "bg-muted-foreground/60 size-1.5 animate-pulse rounded-full" }),
|
|
126
|
+
/* @__PURE__ */ jsx("span", { className: "bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]" }),
|
|
127
|
+
/* @__PURE__ */ jsx("span", { className: "bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]" })
|
|
128
|
+
]
|
|
129
|
+
})
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function getViewport(map) {
|
|
133
|
+
const center = map.getCenter();
|
|
134
|
+
return {
|
|
135
|
+
center: [center.lng, center.lat],
|
|
136
|
+
zoom: map.getZoom(),
|
|
137
|
+
bearing: map.getBearing(),
|
|
138
|
+
pitch: map.getPitch()
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const Map = forwardRef(function Map({ children, className, theme: themeProp, styles, blank = false, projection, viewport, onViewportChange, loading = false, ...props }, ref) {
|
|
142
|
+
const containerRef = useRef(null);
|
|
143
|
+
const [mapLib, setMapLib] = useState(null);
|
|
144
|
+
const [mapInstance, setMapInstance] = useState(null);
|
|
145
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
146
|
+
const [isStyleLoaded, setIsStyleLoaded] = useState(false);
|
|
147
|
+
const [pendingStyle, setPendingStyle] = useState(null);
|
|
148
|
+
const currentStyleRef = useRef(null);
|
|
149
|
+
const styleSwapInFlightRef = useRef(false);
|
|
150
|
+
const internalUpdateRef = useRef(false);
|
|
151
|
+
const resolvedTheme = useResolvedTheme(themeProp);
|
|
152
|
+
const isControlled = viewport !== void 0 && onViewportChange !== void 0;
|
|
153
|
+
const onViewportChangeRef = useRef(onViewportChange);
|
|
154
|
+
onViewportChangeRef.current = onViewportChange;
|
|
155
|
+
const stableStyles = useStableValue(styles);
|
|
156
|
+
const isGlobe = projection?.type === "globe";
|
|
157
|
+
const mapStyles = useMemo(() => {
|
|
158
|
+
if (stableStyles) return {
|
|
159
|
+
dark: stableStyles.dark ?? defaultStyles.dark,
|
|
160
|
+
light: stableStyles.light ?? defaultStyles.light
|
|
161
|
+
};
|
|
162
|
+
if (blank) {
|
|
163
|
+
const style = isGlobe ? blankGlobeStyle : blankMapStyle;
|
|
164
|
+
return {
|
|
165
|
+
dark: style,
|
|
166
|
+
light: style
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return defaultStyles;
|
|
170
|
+
}, [
|
|
171
|
+
stableStyles,
|
|
172
|
+
blank,
|
|
173
|
+
isGlobe
|
|
174
|
+
]);
|
|
175
|
+
const initOptionsRef = useRef({
|
|
176
|
+
props,
|
|
177
|
+
viewport,
|
|
178
|
+
resolvedTheme,
|
|
179
|
+
mapStyles
|
|
180
|
+
});
|
|
181
|
+
initOptionsRef.current = {
|
|
182
|
+
props,
|
|
183
|
+
viewport,
|
|
184
|
+
resolvedTheme,
|
|
185
|
+
mapStyles
|
|
186
|
+
};
|
|
187
|
+
useImperativeHandle(ref, () => mapInstance, [mapInstance]);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
let cancelled = false;
|
|
190
|
+
let map;
|
|
191
|
+
loadMapLibre().then((lib) => {
|
|
192
|
+
if (cancelled || !containerRef.current) return;
|
|
193
|
+
const { props, viewport, resolvedTheme, mapStyles } = initOptionsRef.current;
|
|
194
|
+
const initialStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
195
|
+
currentStyleRef.current = initialStyle;
|
|
196
|
+
const instance = new lib.Map({
|
|
197
|
+
container: containerRef.current,
|
|
198
|
+
style: initialStyle,
|
|
199
|
+
renderWorldCopies: false,
|
|
200
|
+
attributionControl: { compact: true },
|
|
201
|
+
...props,
|
|
202
|
+
...viewport
|
|
203
|
+
});
|
|
204
|
+
if (!instance.painter) throw new lib.GPUInitializationError({}, null);
|
|
205
|
+
map = instance;
|
|
206
|
+
instance.on("load", () => setIsLoaded(true));
|
|
207
|
+
instance.on("style.load", () => {
|
|
208
|
+
styleSwapInFlightRef.current = false;
|
|
209
|
+
setIsStyleLoaded(true);
|
|
210
|
+
});
|
|
211
|
+
instance.on("move", () => {
|
|
212
|
+
if (internalUpdateRef.current) return;
|
|
213
|
+
onViewportChangeRef.current?.(getViewport(instance));
|
|
214
|
+
});
|
|
215
|
+
setMapLib(lib);
|
|
216
|
+
setMapInstance(instance);
|
|
217
|
+
}).catch((error) => {
|
|
218
|
+
if (cancelled) return;
|
|
219
|
+
setMapInstance(() => {
|
|
220
|
+
throw error;
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
return () => {
|
|
224
|
+
cancelled = true;
|
|
225
|
+
if (!map) return;
|
|
226
|
+
map.remove();
|
|
227
|
+
setIsLoaded(false);
|
|
228
|
+
setIsStyleLoaded(false);
|
|
229
|
+
setMapInstance(null);
|
|
230
|
+
};
|
|
231
|
+
}, []);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!mapInstance || !isControlled || !viewport) return;
|
|
234
|
+
if (mapInstance.isMoving()) return;
|
|
235
|
+
const current = getViewport(mapInstance);
|
|
236
|
+
const next = {
|
|
237
|
+
center: viewport.center ?? current.center,
|
|
238
|
+
zoom: viewport.zoom ?? current.zoom,
|
|
239
|
+
bearing: viewport.bearing ?? current.bearing,
|
|
240
|
+
pitch: viewport.pitch ?? current.pitch
|
|
241
|
+
};
|
|
242
|
+
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) return;
|
|
243
|
+
internalUpdateRef.current = true;
|
|
244
|
+
mapInstance.jumpTo(next);
|
|
245
|
+
internalUpdateRef.current = false;
|
|
246
|
+
}, [
|
|
247
|
+
mapInstance,
|
|
248
|
+
isControlled,
|
|
249
|
+
viewport
|
|
250
|
+
]);
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (!mapInstance || !resolvedTheme) return;
|
|
253
|
+
const newStyle = resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
254
|
+
if (currentStyleRef.current === newStyle) return;
|
|
255
|
+
currentStyleRef.current = newStyle;
|
|
256
|
+
setIsStyleLoaded(false);
|
|
257
|
+
setPendingStyle(newStyle);
|
|
258
|
+
}, [
|
|
259
|
+
mapInstance,
|
|
260
|
+
resolvedTheme,
|
|
261
|
+
mapStyles
|
|
262
|
+
]);
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!mapInstance || !pendingStyle) return;
|
|
265
|
+
setPendingStyle(null);
|
|
266
|
+
styleSwapInFlightRef.current = true;
|
|
267
|
+
mapInstance.setStyle(pendingStyle, { diff: false });
|
|
268
|
+
}, [mapInstance, pendingStyle]);
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (!mapInstance || !isStyleLoaded || !projection) return;
|
|
271
|
+
if (styleSwapInFlightRef.current) return;
|
|
272
|
+
mapInstance.setProjection(projection);
|
|
273
|
+
}, [
|
|
274
|
+
mapInstance,
|
|
275
|
+
isStyleLoaded,
|
|
276
|
+
projection
|
|
277
|
+
]);
|
|
278
|
+
const contextValue = useMemo(() => ({
|
|
279
|
+
map: mapInstance,
|
|
280
|
+
mapLib,
|
|
281
|
+
isLoaded: isLoaded && isStyleLoaded,
|
|
282
|
+
resolvedTheme
|
|
283
|
+
}), [
|
|
284
|
+
mapInstance,
|
|
285
|
+
mapLib,
|
|
286
|
+
isLoaded,
|
|
287
|
+
isStyleLoaded,
|
|
288
|
+
resolvedTheme
|
|
289
|
+
]);
|
|
290
|
+
return /* @__PURE__ */ jsx(MapContext.Provider, {
|
|
291
|
+
value: contextValue,
|
|
292
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
293
|
+
ref: containerRef,
|
|
294
|
+
className: cn("relative h-full w-full", className),
|
|
295
|
+
children: [(!isLoaded || loading) && /* @__PURE__ */ jsx(DefaultLoader, {}), mapInstance && children]
|
|
296
|
+
})
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
const MarkerContext = createContext(null);
|
|
300
|
+
function useMarkerContext() {
|
|
301
|
+
const context = useContext(MarkerContext);
|
|
302
|
+
if (!context) throw new Error("Marker components must be used within MapMarker");
|
|
303
|
+
return context;
|
|
304
|
+
}
|
|
305
|
+
function MapMarker({ longitude, latitude, children, onClick, onMouseEnter, onMouseLeave, onDragStart, onDrag, onDragEnd, draggable = false, ...markerOptions }) {
|
|
306
|
+
const { map } = useMap();
|
|
307
|
+
const { Marker } = useMapLib();
|
|
308
|
+
const callbacksRef = useRef({
|
|
309
|
+
onClick,
|
|
310
|
+
onMouseEnter,
|
|
311
|
+
onMouseLeave,
|
|
312
|
+
onDragStart,
|
|
313
|
+
onDrag,
|
|
314
|
+
onDragEnd
|
|
315
|
+
});
|
|
316
|
+
callbacksRef.current = {
|
|
317
|
+
onClick,
|
|
318
|
+
onMouseEnter,
|
|
319
|
+
onMouseLeave,
|
|
320
|
+
onDragStart,
|
|
321
|
+
onDrag,
|
|
322
|
+
onDragEnd
|
|
323
|
+
};
|
|
324
|
+
const marker = useMemo(() => {
|
|
325
|
+
const markerInstance = new Marker({
|
|
326
|
+
...markerOptions,
|
|
327
|
+
element: document.createElement("div"),
|
|
328
|
+
draggable
|
|
329
|
+
}).setLngLat([longitude, latitude]);
|
|
330
|
+
const handleClick = (e) => callbacksRef.current.onClick?.(e);
|
|
331
|
+
const handleMouseEnter = (e) => callbacksRef.current.onMouseEnter?.(e);
|
|
332
|
+
const handleMouseLeave = (e) => callbacksRef.current.onMouseLeave?.(e);
|
|
333
|
+
markerInstance.getElement()?.addEventListener("click", handleClick);
|
|
334
|
+
markerInstance.getElement()?.addEventListener("mouseenter", handleMouseEnter);
|
|
335
|
+
markerInstance.getElement()?.addEventListener("mouseleave", handleMouseLeave);
|
|
336
|
+
const handleDragStart = () => {
|
|
337
|
+
const lngLat = markerInstance.getLngLat();
|
|
338
|
+
callbacksRef.current.onDragStart?.({
|
|
339
|
+
lng: lngLat.lng,
|
|
340
|
+
lat: lngLat.lat
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
const handleDrag = () => {
|
|
344
|
+
const lngLat = markerInstance.getLngLat();
|
|
345
|
+
callbacksRef.current.onDrag?.({
|
|
346
|
+
lng: lngLat.lng,
|
|
347
|
+
lat: lngLat.lat
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
const handleDragEnd = () => {
|
|
351
|
+
const lngLat = markerInstance.getLngLat();
|
|
352
|
+
callbacksRef.current.onDragEnd?.({
|
|
353
|
+
lng: lngLat.lng,
|
|
354
|
+
lat: lngLat.lat
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
markerInstance.on("dragstart", handleDragStart);
|
|
358
|
+
markerInstance.on("drag", handleDrag);
|
|
359
|
+
markerInstance.on("dragend", handleDragEnd);
|
|
360
|
+
return markerInstance;
|
|
361
|
+
}, []);
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
if (!map) return;
|
|
364
|
+
marker.addTo(map);
|
|
365
|
+
return () => {
|
|
366
|
+
marker.remove();
|
|
367
|
+
};
|
|
368
|
+
}, [map]);
|
|
369
|
+
const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
const current = marker.getLngLat();
|
|
372
|
+
if (current.lng !== longitude || current.lat !== latitude) marker.setLngLat([longitude, latitude]);
|
|
373
|
+
if (marker.isDraggable() !== draggable) marker.setDraggable(draggable);
|
|
374
|
+
const currentOffset = marker.getOffset();
|
|
375
|
+
const newOffset = offset ?? [0, 0];
|
|
376
|
+
const [newOffsetX, newOffsetY] = Array.isArray(newOffset) ? newOffset : [newOffset.x, newOffset.y];
|
|
377
|
+
if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) marker.setOffset(newOffset);
|
|
378
|
+
if (marker.getRotation() !== (rotation ?? 0)) marker.setRotation(rotation ?? 0);
|
|
379
|
+
if (marker.getRotationAlignment() !== (rotationAlignment ?? "auto")) marker.setRotationAlignment(rotationAlignment ?? "auto");
|
|
380
|
+
if (marker.getPitchAlignment() !== (pitchAlignment ?? "auto")) marker.setPitchAlignment(pitchAlignment ?? "auto");
|
|
381
|
+
}, [
|
|
382
|
+
marker,
|
|
383
|
+
longitude,
|
|
384
|
+
latitude,
|
|
385
|
+
draggable,
|
|
386
|
+
offset,
|
|
387
|
+
rotation,
|
|
388
|
+
rotationAlignment,
|
|
389
|
+
pitchAlignment
|
|
390
|
+
]);
|
|
391
|
+
return /* @__PURE__ */ jsx(MarkerContext.Provider, {
|
|
392
|
+
value: {
|
|
393
|
+
marker,
|
|
394
|
+
map
|
|
395
|
+
},
|
|
396
|
+
children
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function MarkerContent({ children, className }) {
|
|
400
|
+
const { marker } = useMarkerContext();
|
|
401
|
+
return createPortal(/* @__PURE__ */ jsx("div", {
|
|
402
|
+
className: cn("relative cursor-pointer", className),
|
|
403
|
+
children: children || /* @__PURE__ */ jsx(DefaultMarkerIcon, {})
|
|
404
|
+
}), marker.getElement());
|
|
405
|
+
}
|
|
406
|
+
function DefaultMarkerIcon() {
|
|
407
|
+
return /* @__PURE__ */ jsx("div", { className: "relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg" });
|
|
408
|
+
}
|
|
409
|
+
function PopupCloseButton({ onClick }) {
|
|
410
|
+
return /* @__PURE__ */ jsx("button", {
|
|
411
|
+
type: "button",
|
|
412
|
+
onClick,
|
|
413
|
+
"aria-label": "Close popup",
|
|
414
|
+
className: "focus-visible:ring-ring hover:bg-muted text-foreground absolute top-1 right-1 z-10 inline-flex size-5 cursor-pointer items-center justify-center rounded-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset",
|
|
415
|
+
children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function MarkerPopup({ children, className, closeButton = false, ...popupOptions }) {
|
|
419
|
+
const { marker, map } = useMarkerContext();
|
|
420
|
+
const { Popup } = useMapLib();
|
|
421
|
+
const container = useMemo(() => document.createElement("div"), []);
|
|
422
|
+
const { offset, maxWidth } = popupOptions;
|
|
423
|
+
const popup = useMemo(() => {
|
|
424
|
+
return new Popup({
|
|
425
|
+
offset: 16,
|
|
426
|
+
...popupOptions,
|
|
427
|
+
closeButton: false
|
|
428
|
+
}).setMaxWidth("none").setDOMContent(container);
|
|
429
|
+
}, []);
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
if (!map) return;
|
|
432
|
+
popup.setDOMContent(container);
|
|
433
|
+
marker.setPopup(popup);
|
|
434
|
+
return () => {
|
|
435
|
+
marker.setPopup(null);
|
|
436
|
+
};
|
|
437
|
+
}, [map]);
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
popup.setOffset(offset ?? 16);
|
|
440
|
+
if (maxWidth) popup.setMaxWidth(maxWidth);
|
|
441
|
+
}, [
|
|
442
|
+
popup,
|
|
443
|
+
offset,
|
|
444
|
+
maxWidth
|
|
445
|
+
]);
|
|
446
|
+
const handleClose = () => popup.remove();
|
|
447
|
+
return createPortal(/* @__PURE__ */ jsxs("div", {
|
|
448
|
+
className: cn("bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md", "animate-in fade-in-0 zoom-in-95 duration-200 ease-out", className),
|
|
449
|
+
children: [closeButton && /* @__PURE__ */ jsx(PopupCloseButton, { onClick: handleClose }), children]
|
|
450
|
+
}), container);
|
|
451
|
+
}
|
|
452
|
+
function MarkerTooltip({ children, className, ...popupOptions }) {
|
|
453
|
+
const { marker, map } = useMarkerContext();
|
|
454
|
+
const { Popup } = useMapLib();
|
|
455
|
+
const container = useMemo(() => document.createElement("div"), []);
|
|
456
|
+
const { offset, maxWidth } = popupOptions;
|
|
457
|
+
const tooltip = useMemo(() => {
|
|
458
|
+
return new Popup({
|
|
459
|
+
offset: 16,
|
|
460
|
+
...popupOptions,
|
|
461
|
+
closeOnClick: true,
|
|
462
|
+
closeButton: false
|
|
463
|
+
}).setMaxWidth("none");
|
|
464
|
+
}, []);
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (!map) return;
|
|
467
|
+
tooltip.setDOMContent(container);
|
|
468
|
+
const handleMouseEnter = () => {
|
|
469
|
+
tooltip.setLngLat(marker.getLngLat()).addTo(map);
|
|
470
|
+
};
|
|
471
|
+
const handleMouseLeave = () => tooltip.remove();
|
|
472
|
+
marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
|
|
473
|
+
marker.getElement()?.addEventListener("mouseleave", handleMouseLeave);
|
|
474
|
+
return () => {
|
|
475
|
+
marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
|
|
476
|
+
marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave);
|
|
477
|
+
tooltip.remove();
|
|
478
|
+
};
|
|
479
|
+
}, [map]);
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
tooltip.setOffset(offset ?? 16);
|
|
482
|
+
if (maxWidth) tooltip.setMaxWidth(maxWidth);
|
|
483
|
+
}, [
|
|
484
|
+
tooltip,
|
|
485
|
+
offset,
|
|
486
|
+
maxWidth
|
|
487
|
+
]);
|
|
488
|
+
return createPortal(/* @__PURE__ */ jsx("div", {
|
|
489
|
+
className: cn("bg-foreground text-background pointer-events-none rounded-md px-2 py-1 text-xs text-balance shadow-md", "animate-in fade-in-0 zoom-in-95 duration-200 ease-out", className),
|
|
490
|
+
children
|
|
491
|
+
}), container);
|
|
492
|
+
}
|
|
493
|
+
function MarkerLabel({ children, className, position = "top" }) {
|
|
494
|
+
return /* @__PURE__ */ jsx("div", {
|
|
495
|
+
className: cn("absolute left-1/2 -translate-x-1/2 whitespace-nowrap", "text-foreground text-[10px] font-medium", {
|
|
496
|
+
top: "bottom-full mb-1",
|
|
497
|
+
bottom: "top-full mt-1"
|
|
498
|
+
}[position], className),
|
|
499
|
+
children
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const positionClasses = {
|
|
503
|
+
"top-left": "top-2 left-2",
|
|
504
|
+
"top-right": "top-2 right-2",
|
|
505
|
+
"bottom-left": "bottom-2 left-2",
|
|
506
|
+
"bottom-right": "bottom-10 right-2"
|
|
507
|
+
};
|
|
508
|
+
function ControlGroup({ children }) {
|
|
509
|
+
return /* @__PURE__ */ jsx("div", {
|
|
510
|
+
className: "border-border bg-background [&>button:not(:last-child)]:border-border flex flex-col overflow-hidden rounded-md border shadow-sm [&>button:not(:last-child)]:border-b",
|
|
511
|
+
children
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function ControlButton({ onClick, label, children, disabled = false }) {
|
|
515
|
+
return /* @__PURE__ */ jsx("button", {
|
|
516
|
+
onClick,
|
|
517
|
+
"aria-label": label,
|
|
518
|
+
type: "button",
|
|
519
|
+
className: cn("flex size-8 items-center justify-center transition-colors", "first:rounded-t-md last:rounded-b-md", "hover:bg-accent dark:hover:bg-accent/40", "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset", "disabled:pointer-events-none disabled:opacity-50"),
|
|
520
|
+
disabled,
|
|
521
|
+
children
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function MapControls({ position = "bottom-right", showZoom = true, showCompass = false, showLocate = false, showFullscreen = false, className, onLocate }) {
|
|
525
|
+
const { map } = useMap();
|
|
526
|
+
const [waitingForLocation, setWaitingForLocation] = useState(false);
|
|
527
|
+
const handleZoomIn = useCallback(() => {
|
|
528
|
+
map?.zoomTo(map.getZoom() + 1, { duration: 300 });
|
|
529
|
+
}, [map]);
|
|
530
|
+
const handleZoomOut = useCallback(() => {
|
|
531
|
+
map?.zoomTo(map.getZoom() - 1, { duration: 300 });
|
|
532
|
+
}, [map]);
|
|
533
|
+
const handleResetBearing = useCallback(() => {
|
|
534
|
+
map?.resetNorthPitch({ duration: 300 });
|
|
535
|
+
}, [map]);
|
|
536
|
+
const handleLocate = useCallback(() => {
|
|
537
|
+
if (!("geolocation" in navigator)) return;
|
|
538
|
+
setWaitingForLocation(true);
|
|
539
|
+
navigator.geolocation.getCurrentPosition((pos) => {
|
|
540
|
+
const coords = {
|
|
541
|
+
longitude: pos.coords.longitude,
|
|
542
|
+
latitude: pos.coords.latitude
|
|
543
|
+
};
|
|
544
|
+
map?.flyTo({
|
|
545
|
+
center: [coords.longitude, coords.latitude],
|
|
546
|
+
zoom: 14,
|
|
547
|
+
duration: 1500
|
|
548
|
+
});
|
|
549
|
+
onLocate?.(coords);
|
|
550
|
+
setWaitingForLocation(false);
|
|
551
|
+
}, (error) => {
|
|
552
|
+
console.error("Error getting location:", error);
|
|
553
|
+
setWaitingForLocation(false);
|
|
554
|
+
}, { timeout: 1e4 });
|
|
555
|
+
}, [map, onLocate]);
|
|
556
|
+
const handleFullscreen = useCallback(() => {
|
|
557
|
+
const container = map?.getContainer();
|
|
558
|
+
if (!container) return;
|
|
559
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
560
|
+
else container.requestFullscreen();
|
|
561
|
+
}, [map]);
|
|
562
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
563
|
+
className: cn("absolute z-10 flex flex-col gap-1.5", positionClasses[position], className),
|
|
564
|
+
children: [
|
|
565
|
+
showZoom && /* @__PURE__ */ jsxs(ControlGroup, { children: [/* @__PURE__ */ jsx(ControlButton, {
|
|
566
|
+
onClick: handleZoomIn,
|
|
567
|
+
label: "Zoom in",
|
|
568
|
+
children: /* @__PURE__ */ jsx(Plus, { className: "size-4" })
|
|
569
|
+
}), /* @__PURE__ */ jsx(ControlButton, {
|
|
570
|
+
onClick: handleZoomOut,
|
|
571
|
+
label: "Zoom out",
|
|
572
|
+
children: /* @__PURE__ */ jsx(Minus, { className: "size-4" })
|
|
573
|
+
})] }),
|
|
574
|
+
showCompass && /* @__PURE__ */ jsx(ControlGroup, { children: /* @__PURE__ */ jsx(CompassButton, { onClick: handleResetBearing }) }),
|
|
575
|
+
showLocate && /* @__PURE__ */ jsx(ControlGroup, { children: /* @__PURE__ */ jsx(ControlButton, {
|
|
576
|
+
onClick: handleLocate,
|
|
577
|
+
label: "Find my location",
|
|
578
|
+
disabled: waitingForLocation,
|
|
579
|
+
children: waitingForLocation ? /* @__PURE__ */ jsx(LoaderCircle, { className: "size-4 animate-spin" }) : /* @__PURE__ */ jsx(Locate, { className: "size-4" })
|
|
580
|
+
}) }),
|
|
581
|
+
showFullscreen && /* @__PURE__ */ jsx(ControlGroup, { children: /* @__PURE__ */ jsx(ControlButton, {
|
|
582
|
+
onClick: handleFullscreen,
|
|
583
|
+
label: "Toggle fullscreen",
|
|
584
|
+
children: /* @__PURE__ */ jsx(Maximize, { className: "size-4" })
|
|
585
|
+
}) })
|
|
586
|
+
]
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
function CompassButton({ onClick }) {
|
|
590
|
+
const { map } = useMap();
|
|
591
|
+
const compassRef = useRef(null);
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
if (!map || !compassRef.current) return;
|
|
594
|
+
const compass = compassRef.current;
|
|
595
|
+
const updateRotation = () => {
|
|
596
|
+
const bearing = map.getBearing();
|
|
597
|
+
const pitch = map.getPitch();
|
|
598
|
+
compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
|
|
599
|
+
};
|
|
600
|
+
map.on("rotate", updateRotation);
|
|
601
|
+
map.on("pitch", updateRotation);
|
|
602
|
+
updateRotation();
|
|
603
|
+
return () => {
|
|
604
|
+
map.off("rotate", updateRotation);
|
|
605
|
+
map.off("pitch", updateRotation);
|
|
606
|
+
};
|
|
607
|
+
}, [map]);
|
|
608
|
+
return /* @__PURE__ */ jsx(ControlButton, {
|
|
609
|
+
onClick,
|
|
610
|
+
label: "Reset bearing to north",
|
|
611
|
+
children: /* @__PURE__ */ jsxs("svg", {
|
|
612
|
+
ref: compassRef,
|
|
613
|
+
viewBox: "0 0 24 24",
|
|
614
|
+
className: "size-5",
|
|
615
|
+
style: { transformStyle: "preserve-3d" },
|
|
616
|
+
children: [
|
|
617
|
+
/* @__PURE__ */ jsx("path", {
|
|
618
|
+
d: "M12 2L16 12H12V2Z",
|
|
619
|
+
className: "fill-red-500"
|
|
620
|
+
}),
|
|
621
|
+
/* @__PURE__ */ jsx("path", {
|
|
622
|
+
d: "M12 2L8 12H12V2Z",
|
|
623
|
+
className: "fill-red-300"
|
|
624
|
+
}),
|
|
625
|
+
/* @__PURE__ */ jsx("path", {
|
|
626
|
+
d: "M12 22L16 12H12V22Z",
|
|
627
|
+
className: "fill-muted-foreground/60"
|
|
628
|
+
}),
|
|
629
|
+
/* @__PURE__ */ jsx("path", {
|
|
630
|
+
d: "M12 22L8 12H12V22Z",
|
|
631
|
+
className: "fill-muted-foreground/30"
|
|
632
|
+
})
|
|
633
|
+
]
|
|
634
|
+
})
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
function MapPopup({ longitude, latitude, onClose, children, className, closeButton = false, ...popupOptions }) {
|
|
638
|
+
const { map } = useMap();
|
|
639
|
+
const { Popup } = useMapLib();
|
|
640
|
+
const onCloseRef = useRef(onClose);
|
|
641
|
+
onCloseRef.current = onClose;
|
|
642
|
+
const container = useMemo(() => document.createElement("div"), []);
|
|
643
|
+
const { offset, maxWidth } = popupOptions;
|
|
644
|
+
const popup = useMemo(() => {
|
|
645
|
+
return new Popup({
|
|
646
|
+
offset: 16,
|
|
647
|
+
...popupOptions,
|
|
648
|
+
closeButton: false
|
|
649
|
+
}).setMaxWidth("none").setLngLat([longitude, latitude]);
|
|
650
|
+
}, []);
|
|
651
|
+
useEffect(() => {
|
|
652
|
+
if (!map) return;
|
|
653
|
+
const onCloseProp = () => onCloseRef.current?.();
|
|
654
|
+
popup.on("close", onCloseProp);
|
|
655
|
+
popup.setDOMContent(container);
|
|
656
|
+
popup.addTo(map);
|
|
657
|
+
return () => {
|
|
658
|
+
popup.off("close", onCloseProp);
|
|
659
|
+
if (popup.isOpen()) popup.remove();
|
|
660
|
+
};
|
|
661
|
+
}, [map]);
|
|
662
|
+
useEffect(() => {
|
|
663
|
+
const current = popup.getLngLat();
|
|
664
|
+
if (!current || current.lng !== longitude || current.lat !== latitude) popup.setLngLat([longitude, latitude]);
|
|
665
|
+
popup.setOffset(offset ?? 16);
|
|
666
|
+
if (maxWidth) popup.setMaxWidth(maxWidth);
|
|
667
|
+
}, [
|
|
668
|
+
popup,
|
|
669
|
+
longitude,
|
|
670
|
+
latitude,
|
|
671
|
+
offset,
|
|
672
|
+
maxWidth
|
|
673
|
+
]);
|
|
674
|
+
const handleClose = () => {
|
|
675
|
+
popup.remove();
|
|
676
|
+
};
|
|
677
|
+
return createPortal(/* @__PURE__ */ jsxs("div", {
|
|
678
|
+
className: cn("bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md", "animate-in fade-in-0 zoom-in-95 duration-200 ease-out", className),
|
|
679
|
+
children: [closeButton && /* @__PURE__ */ jsx(PopupCloseButton, { onClick: handleClose }), children]
|
|
680
|
+
}), container);
|
|
681
|
+
}
|
|
682
|
+
const EMPTY_ROUTE_MEASURE = {
|
|
683
|
+
cumulative: [],
|
|
684
|
+
total: 0
|
|
685
|
+
};
|
|
686
|
+
const EMPTY_COORDINATES = [];
|
|
687
|
+
/**
|
|
688
|
+
* `beforeId`, but only when that layer is actually in the style. MapLibre's
|
|
689
|
+
* `addLayer` fires an error and returns *without adding the layer* when
|
|
690
|
+
* `before` is missing, which would silently drop the route — easy to hit with
|
|
691
|
+
* a `beforeId` that exists in one basemap but not another.
|
|
692
|
+
*/
|
|
693
|
+
function resolveBeforeId(map, beforeId) {
|
|
694
|
+
return beforeId && map.getLayer(beforeId) ? beforeId : void 0;
|
|
695
|
+
}
|
|
696
|
+
function clampFraction(value) {
|
|
697
|
+
if (!Number.isFinite(value)) return 0;
|
|
698
|
+
return Math.min(1, Math.max(0, value));
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Cumulative length along the route. Segment lengths use an equirectangular
|
|
702
|
+
* approximation (longitude scaled by cos(latitude)) — accurate enough for
|
|
703
|
+
* splitting a route at a fraction, and much cheaper than haversine.
|
|
704
|
+
*/
|
|
705
|
+
function measureRoute(coordinates) {
|
|
706
|
+
if (coordinates.length < 2) return EMPTY_ROUTE_MEASURE;
|
|
707
|
+
const cumulative = [0];
|
|
708
|
+
let total = 0;
|
|
709
|
+
for (let i = 1; i < coordinates.length; i += 1) {
|
|
710
|
+
const [lng1, lat1] = coordinates[i - 1];
|
|
711
|
+
const [lng2, lat2] = coordinates[i];
|
|
712
|
+
const midLat = (lat1 + lat2) / 2 * (Math.PI / 180);
|
|
713
|
+
total += Math.hypot((lng2 - lng1) * Math.cos(midLat), lat2 - lat1);
|
|
714
|
+
cumulative.push(total);
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
cumulative,
|
|
718
|
+
total
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/** Index of the segment that contains `distance`, clamped to the route. */
|
|
722
|
+
function findSegmentIndex(cumulative, distance) {
|
|
723
|
+
let low = 0;
|
|
724
|
+
let high = cumulative.length - 1;
|
|
725
|
+
while (low < high) {
|
|
726
|
+
const mid = Math.floor((low + high) / 2);
|
|
727
|
+
if (cumulative[mid] < distance) low = mid + 1;
|
|
728
|
+
else high = mid;
|
|
729
|
+
}
|
|
730
|
+
return Math.min(low === 0 ? 0 : low - 1, cumulative.length - 2);
|
|
731
|
+
}
|
|
732
|
+
/** The [longitude, latitude] sitting `fraction` of the way along the route. */
|
|
733
|
+
function pointAtFraction(coordinates, measure, fraction) {
|
|
734
|
+
if (coordinates.length === 0) return null;
|
|
735
|
+
if (coordinates.length === 1 || measure.total === 0) return coordinates[0];
|
|
736
|
+
const target = measure.total * clampFraction(fraction);
|
|
737
|
+
const index = findSegmentIndex(measure.cumulative, target);
|
|
738
|
+
const [lng1, lat1] = coordinates[index];
|
|
739
|
+
const [lng2, lat2] = coordinates[index + 1];
|
|
740
|
+
const segment = measure.cumulative[index + 1] - measure.cumulative[index];
|
|
741
|
+
const ratio = segment === 0 ? 0 : (target - measure.cumulative[index]) / segment;
|
|
742
|
+
return [lng1 + (lng2 - lng1) * ratio, lat1 + (lat2 - lat1) * ratio];
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* The portion of the route up to `fraction`. The final point is interpolated,
|
|
746
|
+
* so the slice ends exactly at the fraction rather than at the nearest vertex.
|
|
747
|
+
*/
|
|
748
|
+
function sliceAtFraction(coordinates, measure, fraction) {
|
|
749
|
+
if (coordinates.length < 2) return [];
|
|
750
|
+
const t = clampFraction(fraction);
|
|
751
|
+
if (t <= 0 || measure.total === 0) return [];
|
|
752
|
+
if (t >= 1) return coordinates;
|
|
753
|
+
const target = measure.total * t;
|
|
754
|
+
const index = findSegmentIndex(measure.cumulative, target);
|
|
755
|
+
const point = pointAtFraction(coordinates, measure, t);
|
|
756
|
+
const traveled = coordinates.slice(0, index + 1);
|
|
757
|
+
if (point) traveled.push(point);
|
|
758
|
+
return traveled;
|
|
759
|
+
}
|
|
760
|
+
const RouteContext = createContext(null);
|
|
761
|
+
function useMapRoute() {
|
|
762
|
+
const context = useContext(RouteContext);
|
|
763
|
+
if (!context) throw new Error("Route components must be used within MapRoute");
|
|
764
|
+
return context;
|
|
765
|
+
}
|
|
766
|
+
function MapRoute({ id: propId, coordinates: coordinatesProp, color = "#4285F4", width = 3, opacity = .8, dashArray, progress, active = false, activeColor, activeWidth, activeOpacity, activeDashArray, beforeId, onClick, onMouseEnter, onMouseLeave, interactive = true, children }) {
|
|
767
|
+
const { map, isLoaded } = useMap();
|
|
768
|
+
const autoId = useId();
|
|
769
|
+
const id = propId ?? autoId;
|
|
770
|
+
const sourceId = `route-source-${id}`;
|
|
771
|
+
const layerId = `route-layer-${id}`;
|
|
772
|
+
const [ready, setReady] = useState(false);
|
|
773
|
+
const coordinates = coordinatesProp.length > 0 ? coordinatesProp : EMPTY_COORDINATES;
|
|
774
|
+
const resolvedColor = active ? activeColor ?? color : color;
|
|
775
|
+
const resolvedWidth = active ? activeWidth ?? width : width;
|
|
776
|
+
const resolvedOpacity = active ? activeOpacity ?? opacity : opacity;
|
|
777
|
+
const resolvedDashArray = active ? activeDashArray ?? dashArray : dashArray;
|
|
778
|
+
const measure = useMemo(() => measureRoute(coordinates), [coordinates]);
|
|
779
|
+
const traveled = useMemo(() => progress === void 0 ? [] : sliceAtFraction(coordinates, measure, progress), [
|
|
780
|
+
coordinates,
|
|
781
|
+
measure,
|
|
782
|
+
progress
|
|
783
|
+
]);
|
|
784
|
+
const pointAt = useCallback((at) => {
|
|
785
|
+
if (coordinates.length === 0) return null;
|
|
786
|
+
if (at === "start") return coordinates[0];
|
|
787
|
+
if (at === "end") return coordinates[coordinates.length - 1];
|
|
788
|
+
if (at === "progress") {
|
|
789
|
+
if (progress === void 0) return null;
|
|
790
|
+
return pointAtFraction(coordinates, measure, progress);
|
|
791
|
+
}
|
|
792
|
+
return pointAtFraction(coordinates, measure, at);
|
|
793
|
+
}, [
|
|
794
|
+
coordinates,
|
|
795
|
+
measure,
|
|
796
|
+
progress
|
|
797
|
+
]);
|
|
798
|
+
const childLayersRef = useRef([]);
|
|
799
|
+
const registerLayer = useCallback((childLayerId) => {
|
|
800
|
+
childLayersRef.current = [...childLayersRef.current, childLayerId];
|
|
801
|
+
return () => {
|
|
802
|
+
childLayersRef.current = childLayersRef.current.filter((entry) => entry !== childLayerId);
|
|
803
|
+
};
|
|
804
|
+
}, []);
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
if (!isLoaded || !map) return;
|
|
807
|
+
map.addSource(sourceId, {
|
|
808
|
+
type: "geojson",
|
|
809
|
+
data: {
|
|
810
|
+
type: "Feature",
|
|
811
|
+
properties: {},
|
|
812
|
+
geometry: {
|
|
813
|
+
type: "LineString",
|
|
814
|
+
coordinates: []
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
map.addLayer({
|
|
819
|
+
id: layerId,
|
|
820
|
+
type: "line",
|
|
821
|
+
source: sourceId,
|
|
822
|
+
layout: {
|
|
823
|
+
"line-join": "round",
|
|
824
|
+
"line-cap": "round"
|
|
825
|
+
},
|
|
826
|
+
paint: {
|
|
827
|
+
"line-color": resolvedColor,
|
|
828
|
+
"line-width": resolvedWidth,
|
|
829
|
+
"line-opacity": resolvedOpacity,
|
|
830
|
+
...resolvedDashArray && { "line-dasharray": resolvedDashArray }
|
|
831
|
+
}
|
|
832
|
+
}, resolveBeforeId(map, beforeId));
|
|
833
|
+
setReady(true);
|
|
834
|
+
return () => {
|
|
835
|
+
setReady(false);
|
|
836
|
+
try {
|
|
837
|
+
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
838
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
839
|
+
} catch {}
|
|
840
|
+
};
|
|
841
|
+
}, [isLoaded, map]);
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
if (!isLoaded || !map) return;
|
|
844
|
+
const source = map.getSource(sourceId);
|
|
845
|
+
if (source) source.setData({
|
|
846
|
+
type: "Feature",
|
|
847
|
+
properties: {},
|
|
848
|
+
geometry: {
|
|
849
|
+
type: "LineString",
|
|
850
|
+
coordinates: coordinates.length < 2 ? [] : coordinates
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}, [
|
|
854
|
+
isLoaded,
|
|
855
|
+
map,
|
|
856
|
+
coordinates,
|
|
857
|
+
sourceId
|
|
858
|
+
]);
|
|
859
|
+
useEffect(() => {
|
|
860
|
+
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
861
|
+
map.setPaintProperty(layerId, "line-color", resolvedColor);
|
|
862
|
+
map.setPaintProperty(layerId, "line-width", resolvedWidth);
|
|
863
|
+
map.setPaintProperty(layerId, "line-opacity", resolvedOpacity);
|
|
864
|
+
map.setPaintProperty(layerId, "line-dasharray", resolvedDashArray);
|
|
865
|
+
}, [
|
|
866
|
+
isLoaded,
|
|
867
|
+
map,
|
|
868
|
+
layerId,
|
|
869
|
+
resolvedColor,
|
|
870
|
+
resolvedWidth,
|
|
871
|
+
resolvedOpacity,
|
|
872
|
+
resolvedDashArray
|
|
873
|
+
]);
|
|
874
|
+
useEffect(() => {
|
|
875
|
+
if (!ready || !map || !active) return;
|
|
876
|
+
let lastLayerSet = "";
|
|
877
|
+
const raise = () => {
|
|
878
|
+
const order = map.getLayersOrder();
|
|
879
|
+
const layerSet = [...order].sort().join("|");
|
|
880
|
+
if (layerSet === lastLayerSet) return;
|
|
881
|
+
lastLayerSet = layerSet;
|
|
882
|
+
const owned = [layerId, ...childLayersRef.current].filter((entry) => map.getLayer(entry));
|
|
883
|
+
if (owned.length === 0) return;
|
|
884
|
+
const before = resolveBeforeId(map, beforeId);
|
|
885
|
+
const limit = before ? order.indexOf(before) : order.length;
|
|
886
|
+
const top = order.slice(Math.max(0, limit - owned.length), limit);
|
|
887
|
+
if (owned.every((entry, index) => top[index] === entry)) return;
|
|
888
|
+
for (const entry of owned) map.moveLayer(entry, before);
|
|
889
|
+
};
|
|
890
|
+
raise();
|
|
891
|
+
map.on("styledata", raise);
|
|
892
|
+
return () => {
|
|
893
|
+
map.off("styledata", raise);
|
|
894
|
+
};
|
|
895
|
+
}, [
|
|
896
|
+
ready,
|
|
897
|
+
map,
|
|
898
|
+
active,
|
|
899
|
+
layerId,
|
|
900
|
+
beforeId
|
|
901
|
+
]);
|
|
902
|
+
useEffect(() => {
|
|
903
|
+
if (!isLoaded || !map || !interactive) return;
|
|
904
|
+
const handleClick = () => {
|
|
905
|
+
onClick?.();
|
|
906
|
+
};
|
|
907
|
+
const handleMouseEnter = () => {
|
|
908
|
+
map.getCanvas().style.cursor = "pointer";
|
|
909
|
+
onMouseEnter?.();
|
|
910
|
+
};
|
|
911
|
+
const handleMouseLeave = () => {
|
|
912
|
+
map.getCanvas().style.cursor = "";
|
|
913
|
+
onMouseLeave?.();
|
|
914
|
+
};
|
|
915
|
+
map.on("click", layerId, handleClick);
|
|
916
|
+
map.on("mouseenter", layerId, handleMouseEnter);
|
|
917
|
+
map.on("mouseleave", layerId, handleMouseLeave);
|
|
918
|
+
return () => {
|
|
919
|
+
map.off("click", layerId, handleClick);
|
|
920
|
+
map.off("mouseenter", layerId, handleMouseEnter);
|
|
921
|
+
map.off("mouseleave", layerId, handleMouseLeave);
|
|
922
|
+
};
|
|
923
|
+
}, [
|
|
924
|
+
isLoaded,
|
|
925
|
+
map,
|
|
926
|
+
layerId,
|
|
927
|
+
onClick,
|
|
928
|
+
onMouseEnter,
|
|
929
|
+
onMouseLeave,
|
|
930
|
+
interactive
|
|
931
|
+
]);
|
|
932
|
+
const contextValue = useMemo(() => ({
|
|
933
|
+
id,
|
|
934
|
+
ready,
|
|
935
|
+
coordinates,
|
|
936
|
+
traveled,
|
|
937
|
+
progress,
|
|
938
|
+
color: resolvedColor,
|
|
939
|
+
width: resolvedWidth,
|
|
940
|
+
opacity: resolvedOpacity,
|
|
941
|
+
dashArray: resolvedDashArray,
|
|
942
|
+
beforeId,
|
|
943
|
+
pointAt,
|
|
944
|
+
registerLayer
|
|
945
|
+
}), [
|
|
946
|
+
id,
|
|
947
|
+
ready,
|
|
948
|
+
coordinates,
|
|
949
|
+
traveled,
|
|
950
|
+
progress,
|
|
951
|
+
resolvedColor,
|
|
952
|
+
resolvedWidth,
|
|
953
|
+
resolvedOpacity,
|
|
954
|
+
resolvedDashArray,
|
|
955
|
+
beforeId,
|
|
956
|
+
pointAt,
|
|
957
|
+
registerLayer
|
|
958
|
+
]);
|
|
959
|
+
return /* @__PURE__ */ jsx(RouteContext.Provider, {
|
|
960
|
+
value: contextValue,
|
|
961
|
+
children
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Draws the traveled portion of the parent `MapRoute` on top of the base line.
|
|
966
|
+
* Renders nothing until the route has a `progress` value.
|
|
967
|
+
*/
|
|
968
|
+
function RouteProgress({ color, width, opacity, dashArray }) {
|
|
969
|
+
const { map, isLoaded } = useMap();
|
|
970
|
+
const route = useMapRoute();
|
|
971
|
+
const { ready, traveled, registerLayer, beforeId } = route;
|
|
972
|
+
const sourceId = `route-progress-source-${route.id}`;
|
|
973
|
+
const layerId = `route-progress-layer-${route.id}`;
|
|
974
|
+
const resolvedColor = color ?? route.color;
|
|
975
|
+
const resolvedWidth = width ?? route.width;
|
|
976
|
+
const resolvedOpacity = opacity ?? route.opacity;
|
|
977
|
+
useEffect(() => {
|
|
978
|
+
if (!ready || !map) return;
|
|
979
|
+
map.addSource(sourceId, {
|
|
980
|
+
type: "geojson",
|
|
981
|
+
data: {
|
|
982
|
+
type: "Feature",
|
|
983
|
+
properties: {},
|
|
984
|
+
geometry: {
|
|
985
|
+
type: "LineString",
|
|
986
|
+
coordinates: []
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
map.addLayer({
|
|
991
|
+
id: layerId,
|
|
992
|
+
type: "line",
|
|
993
|
+
source: sourceId,
|
|
994
|
+
layout: {
|
|
995
|
+
"line-join": "round",
|
|
996
|
+
"line-cap": "round"
|
|
997
|
+
},
|
|
998
|
+
paint: {
|
|
999
|
+
"line-color": resolvedColor,
|
|
1000
|
+
"line-width": resolvedWidth,
|
|
1001
|
+
"line-opacity": resolvedOpacity,
|
|
1002
|
+
...dashArray && { "line-dasharray": dashArray }
|
|
1003
|
+
}
|
|
1004
|
+
}, resolveBeforeId(map, beforeId));
|
|
1005
|
+
const unregister = registerLayer(layerId);
|
|
1006
|
+
return () => {
|
|
1007
|
+
unregister();
|
|
1008
|
+
try {
|
|
1009
|
+
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
1010
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1011
|
+
} catch {}
|
|
1012
|
+
};
|
|
1013
|
+
}, [ready, map]);
|
|
1014
|
+
useEffect(() => {
|
|
1015
|
+
if (!ready || !map) return;
|
|
1016
|
+
const source = map.getSource(sourceId);
|
|
1017
|
+
if (source) source.setData({
|
|
1018
|
+
type: "Feature",
|
|
1019
|
+
properties: {},
|
|
1020
|
+
geometry: {
|
|
1021
|
+
type: "LineString",
|
|
1022
|
+
coordinates: traveled.length < 2 ? [] : traveled
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
}, [
|
|
1026
|
+
ready,
|
|
1027
|
+
map,
|
|
1028
|
+
traveled,
|
|
1029
|
+
sourceId
|
|
1030
|
+
]);
|
|
1031
|
+
useEffect(() => {
|
|
1032
|
+
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
1033
|
+
map.setPaintProperty(layerId, "line-color", resolvedColor);
|
|
1034
|
+
map.setPaintProperty(layerId, "line-width", resolvedWidth);
|
|
1035
|
+
map.setPaintProperty(layerId, "line-opacity", resolvedOpacity);
|
|
1036
|
+
map.setPaintProperty(layerId, "line-dasharray", dashArray);
|
|
1037
|
+
}, [
|
|
1038
|
+
isLoaded,
|
|
1039
|
+
map,
|
|
1040
|
+
layerId,
|
|
1041
|
+
resolvedColor,
|
|
1042
|
+
resolvedWidth,
|
|
1043
|
+
resolvedOpacity,
|
|
1044
|
+
dashArray
|
|
1045
|
+
]);
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* A `MapMarker` anchored to a position along the parent `MapRoute`. Takes the
|
|
1050
|
+
* same children as `MapMarker` (`MarkerContent`, `MarkerPopup`, ...).
|
|
1051
|
+
*/
|
|
1052
|
+
function RouteMarker({ at, children, ...markerProps }) {
|
|
1053
|
+
const { pointAt } = useMapRoute();
|
|
1054
|
+
const position = pointAt(at);
|
|
1055
|
+
if (!position) return null;
|
|
1056
|
+
return /* @__PURE__ */ jsx(MapMarker, {
|
|
1057
|
+
longitude: position[0],
|
|
1058
|
+
latitude: position[1],
|
|
1059
|
+
...markerProps,
|
|
1060
|
+
children
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
const GEOJSON_DEFAULT_COLORS = {
|
|
1064
|
+
light: {
|
|
1065
|
+
fill: "#d4d4d4",
|
|
1066
|
+
line: "#ffffff"
|
|
1067
|
+
},
|
|
1068
|
+
dark: {
|
|
1069
|
+
fill: "#404040",
|
|
1070
|
+
line: "#171717"
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
/**
|
|
1074
|
+
* Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like
|
|
1075
|
+
* `MapRoute` / `MapArc` — drop it inside `<Map>` (typically with `blank`) for
|
|
1076
|
+
* choropleths and region/data maps. For full control over expressions and
|
|
1077
|
+
* multiple layers, manage layers directly via `useMap()` instead.
|
|
1078
|
+
*/
|
|
1079
|
+
function MapGeoJSON({ data, id: propId, promoteId, fillPaint, linePaint, fillHoverPaint, onClick, onHover, interactive = false, beforeId }) {
|
|
1080
|
+
const { map, isLoaded, resolvedTheme } = useMap();
|
|
1081
|
+
const autoId = useId();
|
|
1082
|
+
const id = propId ?? autoId;
|
|
1083
|
+
const sourceId = `geojson-source-${id}`;
|
|
1084
|
+
const fillLayerId = `geojson-fill-${id}`;
|
|
1085
|
+
const lineLayerId = `geojson-line-${id}`;
|
|
1086
|
+
const defaults = GEOJSON_DEFAULT_COLORS[resolvedTheme];
|
|
1087
|
+
const showFill = fillPaint !== false;
|
|
1088
|
+
const showLine = linePaint !== false;
|
|
1089
|
+
const mergedFillPaint = useMemo(() => mergeHoverPaint({
|
|
1090
|
+
"fill-color": defaults.fill,
|
|
1091
|
+
...fillPaint
|
|
1092
|
+
}, fillHoverPaint), [
|
|
1093
|
+
defaults.fill,
|
|
1094
|
+
fillPaint,
|
|
1095
|
+
fillHoverPaint
|
|
1096
|
+
]);
|
|
1097
|
+
const mergedLinePaint = useMemo(() => ({
|
|
1098
|
+
"line-color": defaults.line,
|
|
1099
|
+
"line-width": .5,
|
|
1100
|
+
...linePaint
|
|
1101
|
+
}), [defaults.line, linePaint]);
|
|
1102
|
+
const latestRef = useRef({
|
|
1103
|
+
onClick,
|
|
1104
|
+
onHover
|
|
1105
|
+
});
|
|
1106
|
+
latestRef.current = {
|
|
1107
|
+
onClick,
|
|
1108
|
+
onHover
|
|
1109
|
+
};
|
|
1110
|
+
useEffect(() => {
|
|
1111
|
+
if (!isLoaded || !map) return;
|
|
1112
|
+
map.addSource(sourceId, {
|
|
1113
|
+
type: "geojson",
|
|
1114
|
+
data,
|
|
1115
|
+
...promoteId ? { promoteId } : {}
|
|
1116
|
+
});
|
|
1117
|
+
return () => {
|
|
1118
|
+
try {
|
|
1119
|
+
if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
|
|
1120
|
+
if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
|
|
1121
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1122
|
+
} catch {}
|
|
1123
|
+
};
|
|
1124
|
+
}, [isLoaded, map]);
|
|
1125
|
+
useEffect(() => {
|
|
1126
|
+
if (!isLoaded || !map) return;
|
|
1127
|
+
map.getSource(sourceId)?.setData(data);
|
|
1128
|
+
}, [
|
|
1129
|
+
isLoaded,
|
|
1130
|
+
map,
|
|
1131
|
+
data,
|
|
1132
|
+
sourceId
|
|
1133
|
+
]);
|
|
1134
|
+
useEffect(() => {
|
|
1135
|
+
if (!isLoaded || !map) return;
|
|
1136
|
+
if (!map.getSource(sourceId)) return;
|
|
1137
|
+
if (showFill && !map.getLayer(fillLayerId)) map.addLayer({
|
|
1138
|
+
id: fillLayerId,
|
|
1139
|
+
type: "fill",
|
|
1140
|
+
source: sourceId,
|
|
1141
|
+
paint: mergedFillPaint
|
|
1142
|
+
}, beforeId);
|
|
1143
|
+
else if (!showFill && map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
|
|
1144
|
+
if (showLine && !map.getLayer(lineLayerId)) map.addLayer({
|
|
1145
|
+
id: lineLayerId,
|
|
1146
|
+
type: "line",
|
|
1147
|
+
source: sourceId,
|
|
1148
|
+
paint: mergedLinePaint
|
|
1149
|
+
}, beforeId);
|
|
1150
|
+
else if (!showLine && map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
|
|
1151
|
+
if (showFill && map.getLayer(fillLayerId)) for (const [key, value] of Object.entries(mergedFillPaint)) map.setPaintProperty(fillLayerId, key, value);
|
|
1152
|
+
if (showLine && map.getLayer(lineLayerId)) for (const [key, value] of Object.entries(mergedLinePaint)) map.setPaintProperty(lineLayerId, key, value);
|
|
1153
|
+
}, [
|
|
1154
|
+
isLoaded,
|
|
1155
|
+
map,
|
|
1156
|
+
sourceId,
|
|
1157
|
+
fillLayerId,
|
|
1158
|
+
lineLayerId,
|
|
1159
|
+
showFill,
|
|
1160
|
+
showLine,
|
|
1161
|
+
mergedFillPaint,
|
|
1162
|
+
mergedLinePaint,
|
|
1163
|
+
beforeId
|
|
1164
|
+
]);
|
|
1165
|
+
useEffect(() => {
|
|
1166
|
+
if (!isLoaded || !map || !interactive || !showFill) return;
|
|
1167
|
+
let hoveredId = null;
|
|
1168
|
+
const setHover = (next) => {
|
|
1169
|
+
if (next === hoveredId) return;
|
|
1170
|
+
const sourceExists = !!map.getSource(sourceId);
|
|
1171
|
+
if (hoveredId != null && sourceExists) map.setFeatureState({
|
|
1172
|
+
source: sourceId,
|
|
1173
|
+
id: hoveredId
|
|
1174
|
+
}, { hover: false });
|
|
1175
|
+
hoveredId = next;
|
|
1176
|
+
if (next != null && sourceExists) map.setFeatureState({
|
|
1177
|
+
source: sourceId,
|
|
1178
|
+
id: next
|
|
1179
|
+
}, { hover: true });
|
|
1180
|
+
};
|
|
1181
|
+
const handleMouseMove = (e) => {
|
|
1182
|
+
const feature = e.features?.[0];
|
|
1183
|
+
if (!feature) return;
|
|
1184
|
+
map.getCanvas().style.cursor = "pointer";
|
|
1185
|
+
const featureId = feature.id;
|
|
1186
|
+
if (featureId === hoveredId) return;
|
|
1187
|
+
setHover(featureId ?? null);
|
|
1188
|
+
latestRef.current.onHover?.({
|
|
1189
|
+
feature,
|
|
1190
|
+
longitude: e.lngLat.lng,
|
|
1191
|
+
latitude: e.lngLat.lat,
|
|
1192
|
+
originalEvent: e
|
|
1193
|
+
});
|
|
1194
|
+
};
|
|
1195
|
+
const handleMouseLeave = () => {
|
|
1196
|
+
setHover(null);
|
|
1197
|
+
map.getCanvas().style.cursor = "";
|
|
1198
|
+
latestRef.current.onHover?.(null);
|
|
1199
|
+
};
|
|
1200
|
+
const handleClick = (e) => {
|
|
1201
|
+
const feature = e.features?.[0];
|
|
1202
|
+
if (!feature) return;
|
|
1203
|
+
latestRef.current.onClick?.({
|
|
1204
|
+
feature,
|
|
1205
|
+
longitude: e.lngLat.lng,
|
|
1206
|
+
latitude: e.lngLat.lat,
|
|
1207
|
+
originalEvent: e
|
|
1208
|
+
});
|
|
1209
|
+
};
|
|
1210
|
+
map.on("mousemove", fillLayerId, handleMouseMove);
|
|
1211
|
+
map.on("mouseleave", fillLayerId, handleMouseLeave);
|
|
1212
|
+
map.on("click", fillLayerId, handleClick);
|
|
1213
|
+
return () => {
|
|
1214
|
+
map.off("mousemove", fillLayerId, handleMouseMove);
|
|
1215
|
+
map.off("mouseleave", fillLayerId, handleMouseLeave);
|
|
1216
|
+
map.off("click", fillLayerId, handleClick);
|
|
1217
|
+
setHover(null);
|
|
1218
|
+
map.getCanvas().style.cursor = "";
|
|
1219
|
+
};
|
|
1220
|
+
}, [
|
|
1221
|
+
isLoaded,
|
|
1222
|
+
map,
|
|
1223
|
+
fillLayerId,
|
|
1224
|
+
sourceId,
|
|
1225
|
+
interactive,
|
|
1226
|
+
showFill
|
|
1227
|
+
]);
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
const DEFAULT_ARC_CURVATURE = .2;
|
|
1231
|
+
const DEFAULT_ARC_SAMPLES = 64;
|
|
1232
|
+
const ARC_HIT_MIN_WIDTH = 12;
|
|
1233
|
+
const ARC_HIT_PADDING = 6;
|
|
1234
|
+
const DEFAULT_ARC_PAINT = {
|
|
1235
|
+
"line-color": "#4285F4",
|
|
1236
|
+
"line-width": 2,
|
|
1237
|
+
"line-opacity": .85
|
|
1238
|
+
};
|
|
1239
|
+
const DEFAULT_ARC_LAYOUT = {
|
|
1240
|
+
"line-join": "round",
|
|
1241
|
+
"line-cap": "round"
|
|
1242
|
+
};
|
|
1243
|
+
function buildArcCoordinates(from, to, curvature, samples) {
|
|
1244
|
+
const [x0, y0] = from;
|
|
1245
|
+
const [xTo, y2] = to;
|
|
1246
|
+
const rawDx = xTo - x0;
|
|
1247
|
+
const x2 = rawDx > 180 ? xTo - 360 : rawDx < -180 ? xTo + 360 : xTo;
|
|
1248
|
+
const dx = x2 - x0;
|
|
1249
|
+
const dy = y2 - y0;
|
|
1250
|
+
const distance = Math.hypot(dx, dy);
|
|
1251
|
+
if (distance === 0 || curvature === 0) return [from, [x2, y2]];
|
|
1252
|
+
const mx = (x0 + x2) / 2;
|
|
1253
|
+
const my = (y0 + y2) / 2;
|
|
1254
|
+
const nx = -dy / distance;
|
|
1255
|
+
const ny = dx / distance;
|
|
1256
|
+
const offset = distance * curvature;
|
|
1257
|
+
const cx = mx + nx * offset;
|
|
1258
|
+
const cy = my + ny * offset;
|
|
1259
|
+
const points = [];
|
|
1260
|
+
const segments = Math.max(2, Math.floor(samples));
|
|
1261
|
+
for (let i = 0; i <= segments; i += 1) {
|
|
1262
|
+
const t = i / segments;
|
|
1263
|
+
const inv = 1 - t;
|
|
1264
|
+
const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2;
|
|
1265
|
+
const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2;
|
|
1266
|
+
points.push([x, y]);
|
|
1267
|
+
}
|
|
1268
|
+
return points;
|
|
1269
|
+
}
|
|
1270
|
+
function MapArc({ data, id: propId, curvature = DEFAULT_ARC_CURVATURE, samples = DEFAULT_ARC_SAMPLES, paint, layout, hoverPaint, onClick, onHover, interactive = true, beforeId }) {
|
|
1271
|
+
const { map, isLoaded } = useMap();
|
|
1272
|
+
const autoId = useId();
|
|
1273
|
+
const id = propId ?? autoId;
|
|
1274
|
+
const sourceId = `arc-source-${id}`;
|
|
1275
|
+
const layerId = `arc-layer-${id}`;
|
|
1276
|
+
const hitLayerId = `arc-hit-layer-${id}`;
|
|
1277
|
+
const mergedPaint = useMemo(() => mergeHoverPaint({
|
|
1278
|
+
...DEFAULT_ARC_PAINT,
|
|
1279
|
+
...paint
|
|
1280
|
+
}, hoverPaint), [paint, hoverPaint]);
|
|
1281
|
+
const mergedLayout = useMemo(() => ({
|
|
1282
|
+
...DEFAULT_ARC_LAYOUT,
|
|
1283
|
+
...layout
|
|
1284
|
+
}), [layout]);
|
|
1285
|
+
const hitWidth = useMemo(() => {
|
|
1286
|
+
const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"];
|
|
1287
|
+
return Math.max((typeof w === "number" ? w : ARC_HIT_MIN_WIDTH) + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
|
|
1288
|
+
}, [paint]);
|
|
1289
|
+
const geoJSON = useMemo(() => ({
|
|
1290
|
+
type: "FeatureCollection",
|
|
1291
|
+
features: data.map((arc) => {
|
|
1292
|
+
const { from, to, ...properties } = arc;
|
|
1293
|
+
return {
|
|
1294
|
+
type: "Feature",
|
|
1295
|
+
properties,
|
|
1296
|
+
geometry: {
|
|
1297
|
+
type: "LineString",
|
|
1298
|
+
coordinates: buildArcCoordinates(from, to, curvature, samples)
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
})
|
|
1302
|
+
}), [
|
|
1303
|
+
data,
|
|
1304
|
+
curvature,
|
|
1305
|
+
samples
|
|
1306
|
+
]);
|
|
1307
|
+
const latestRef = useRef({
|
|
1308
|
+
data,
|
|
1309
|
+
onClick,
|
|
1310
|
+
onHover
|
|
1311
|
+
});
|
|
1312
|
+
latestRef.current = {
|
|
1313
|
+
data,
|
|
1314
|
+
onClick,
|
|
1315
|
+
onHover
|
|
1316
|
+
};
|
|
1317
|
+
useEffect(() => {
|
|
1318
|
+
if (!isLoaded || !map) return;
|
|
1319
|
+
map.addSource(sourceId, {
|
|
1320
|
+
type: "geojson",
|
|
1321
|
+
data: geoJSON,
|
|
1322
|
+
promoteId: "id"
|
|
1323
|
+
});
|
|
1324
|
+
map.addLayer({
|
|
1325
|
+
id: hitLayerId,
|
|
1326
|
+
type: "line",
|
|
1327
|
+
source: sourceId,
|
|
1328
|
+
layout: DEFAULT_ARC_LAYOUT,
|
|
1329
|
+
paint: {
|
|
1330
|
+
"line-color": "rgba(0, 0, 0, 0)",
|
|
1331
|
+
"line-width": hitWidth,
|
|
1332
|
+
"line-opacity": 1
|
|
1333
|
+
}
|
|
1334
|
+
}, beforeId);
|
|
1335
|
+
map.addLayer({
|
|
1336
|
+
id: layerId,
|
|
1337
|
+
type: "line",
|
|
1338
|
+
source: sourceId,
|
|
1339
|
+
layout: mergedLayout,
|
|
1340
|
+
paint: mergedPaint
|
|
1341
|
+
}, beforeId);
|
|
1342
|
+
return () => {
|
|
1343
|
+
try {
|
|
1344
|
+
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
1345
|
+
if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
|
|
1346
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1347
|
+
} catch {}
|
|
1348
|
+
};
|
|
1349
|
+
}, [isLoaded, map]);
|
|
1350
|
+
useEffect(() => {
|
|
1351
|
+
if (!isLoaded || !map) return;
|
|
1352
|
+
map.getSource(sourceId)?.setData(geoJSON);
|
|
1353
|
+
}, [
|
|
1354
|
+
isLoaded,
|
|
1355
|
+
map,
|
|
1356
|
+
geoJSON,
|
|
1357
|
+
sourceId
|
|
1358
|
+
]);
|
|
1359
|
+
useEffect(() => {
|
|
1360
|
+
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
1361
|
+
for (const [key, value] of Object.entries(mergedPaint)) map.setPaintProperty(layerId, key, value);
|
|
1362
|
+
for (const [key, value] of Object.entries(mergedLayout)) map.setLayoutProperty(layerId, key, value);
|
|
1363
|
+
if (map.getLayer(hitLayerId)) map.setPaintProperty(hitLayerId, "line-width", hitWidth);
|
|
1364
|
+
}, [
|
|
1365
|
+
isLoaded,
|
|
1366
|
+
map,
|
|
1367
|
+
layerId,
|
|
1368
|
+
hitLayerId,
|
|
1369
|
+
mergedPaint,
|
|
1370
|
+
mergedLayout,
|
|
1371
|
+
hitWidth
|
|
1372
|
+
]);
|
|
1373
|
+
useEffect(() => {
|
|
1374
|
+
if (!isLoaded || !map || !interactive) return;
|
|
1375
|
+
let hoveredId = null;
|
|
1376
|
+
const setHover = (next) => {
|
|
1377
|
+
if (next === hoveredId) return;
|
|
1378
|
+
const sourceExists = !!map.getSource(sourceId);
|
|
1379
|
+
if (hoveredId != null && sourceExists) map.setFeatureState({
|
|
1380
|
+
source: sourceId,
|
|
1381
|
+
id: hoveredId
|
|
1382
|
+
}, { hover: false });
|
|
1383
|
+
hoveredId = next;
|
|
1384
|
+
if (next != null && sourceExists) map.setFeatureState({
|
|
1385
|
+
source: sourceId,
|
|
1386
|
+
id: next
|
|
1387
|
+
}, { hover: true });
|
|
1388
|
+
};
|
|
1389
|
+
const findArc = (featureId) => featureId == null ? void 0 : latestRef.current.data.find((arc) => String(arc.id) === String(featureId));
|
|
1390
|
+
const handleMouseMove = (e) => {
|
|
1391
|
+
const featureId = e.features?.[0]?.id;
|
|
1392
|
+
if (featureId == null || featureId === hoveredId) return;
|
|
1393
|
+
setHover(featureId);
|
|
1394
|
+
map.getCanvas().style.cursor = "pointer";
|
|
1395
|
+
const arc = findArc(featureId);
|
|
1396
|
+
if (arc) latestRef.current.onHover?.({
|
|
1397
|
+
arc,
|
|
1398
|
+
longitude: e.lngLat.lng,
|
|
1399
|
+
latitude: e.lngLat.lat,
|
|
1400
|
+
originalEvent: e
|
|
1401
|
+
});
|
|
1402
|
+
};
|
|
1403
|
+
const handleMouseLeave = () => {
|
|
1404
|
+
setHover(null);
|
|
1405
|
+
map.getCanvas().style.cursor = "";
|
|
1406
|
+
latestRef.current.onHover?.(null);
|
|
1407
|
+
};
|
|
1408
|
+
const handleClick = (e) => {
|
|
1409
|
+
const arc = findArc(e.features?.[0]?.id);
|
|
1410
|
+
if (!arc) return;
|
|
1411
|
+
latestRef.current.onClick?.({
|
|
1412
|
+
arc,
|
|
1413
|
+
longitude: e.lngLat.lng,
|
|
1414
|
+
latitude: e.lngLat.lat,
|
|
1415
|
+
originalEvent: e
|
|
1416
|
+
});
|
|
1417
|
+
};
|
|
1418
|
+
map.on("mousemove", hitLayerId, handleMouseMove);
|
|
1419
|
+
map.on("mouseleave", hitLayerId, handleMouseLeave);
|
|
1420
|
+
map.on("click", hitLayerId, handleClick);
|
|
1421
|
+
return () => {
|
|
1422
|
+
map.off("mousemove", hitLayerId, handleMouseMove);
|
|
1423
|
+
map.off("mouseleave", hitLayerId, handleMouseLeave);
|
|
1424
|
+
map.off("click", hitLayerId, handleClick);
|
|
1425
|
+
setHover(null);
|
|
1426
|
+
map.getCanvas().style.cursor = "";
|
|
1427
|
+
};
|
|
1428
|
+
}, [
|
|
1429
|
+
isLoaded,
|
|
1430
|
+
map,
|
|
1431
|
+
hitLayerId,
|
|
1432
|
+
sourceId,
|
|
1433
|
+
interactive
|
|
1434
|
+
]);
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
const DEFAULT_CLUSTER_COLORS = [
|
|
1438
|
+
"#3b82f6",
|
|
1439
|
+
"#1d4ed8",
|
|
1440
|
+
"#1e3a8a"
|
|
1441
|
+
];
|
|
1442
|
+
const DEFAULT_CLUSTER_THRESHOLDS = [100, 750];
|
|
1443
|
+
function MapClusterLayer({ data, clusterMaxZoom = 14, clusterRadius = 50, clusterColors = DEFAULT_CLUSTER_COLORS, clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS, pointColor = "#3b82f6", onPointClick, onClusterClick }) {
|
|
1444
|
+
const { map, isLoaded } = useMap();
|
|
1445
|
+
const id = useId();
|
|
1446
|
+
const sourceId = `cluster-source-${id}`;
|
|
1447
|
+
const clusterLayerId = `clusters-${id}`;
|
|
1448
|
+
const clusterCountLayerId = `cluster-count-${id}`;
|
|
1449
|
+
const unclusteredLayerId = `unclustered-point-${id}`;
|
|
1450
|
+
const stylePropsRef = useRef({
|
|
1451
|
+
clusterColors,
|
|
1452
|
+
clusterThresholds,
|
|
1453
|
+
pointColor
|
|
1454
|
+
});
|
|
1455
|
+
useEffect(() => {
|
|
1456
|
+
if (!isLoaded || !map) return;
|
|
1457
|
+
map.addSource(sourceId, {
|
|
1458
|
+
type: "geojson",
|
|
1459
|
+
data,
|
|
1460
|
+
cluster: true,
|
|
1461
|
+
clusterMaxZoom,
|
|
1462
|
+
clusterRadius
|
|
1463
|
+
});
|
|
1464
|
+
map.addLayer({
|
|
1465
|
+
id: clusterLayerId,
|
|
1466
|
+
type: "circle",
|
|
1467
|
+
source: sourceId,
|
|
1468
|
+
filter: ["has", "point_count"],
|
|
1469
|
+
paint: {
|
|
1470
|
+
"circle-color": [
|
|
1471
|
+
"step",
|
|
1472
|
+
["get", "point_count"],
|
|
1473
|
+
clusterColors[0],
|
|
1474
|
+
clusterThresholds[0],
|
|
1475
|
+
clusterColors[1],
|
|
1476
|
+
clusterThresholds[1],
|
|
1477
|
+
clusterColors[2]
|
|
1478
|
+
],
|
|
1479
|
+
"circle-radius": [
|
|
1480
|
+
"step",
|
|
1481
|
+
["get", "point_count"],
|
|
1482
|
+
20,
|
|
1483
|
+
clusterThresholds[0],
|
|
1484
|
+
30,
|
|
1485
|
+
clusterThresholds[1],
|
|
1486
|
+
40
|
|
1487
|
+
],
|
|
1488
|
+
"circle-stroke-width": .75,
|
|
1489
|
+
"circle-stroke-color": "#fff",
|
|
1490
|
+
"circle-opacity": .85
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
map.addLayer({
|
|
1494
|
+
id: clusterCountLayerId,
|
|
1495
|
+
type: "symbol",
|
|
1496
|
+
source: sourceId,
|
|
1497
|
+
filter: ["has", "point_count"],
|
|
1498
|
+
layout: {
|
|
1499
|
+
"text-field": "{point_count_abbreviated}",
|
|
1500
|
+
"text-font": ["Open Sans Semibold"],
|
|
1501
|
+
"text-size": 12
|
|
1502
|
+
},
|
|
1503
|
+
paint: { "text-color": "#fff" }
|
|
1504
|
+
});
|
|
1505
|
+
map.addLayer({
|
|
1506
|
+
id: unclusteredLayerId,
|
|
1507
|
+
type: "circle",
|
|
1508
|
+
source: sourceId,
|
|
1509
|
+
filter: ["!", ["has", "point_count"]],
|
|
1510
|
+
paint: {
|
|
1511
|
+
"circle-color": pointColor,
|
|
1512
|
+
"circle-radius": 5,
|
|
1513
|
+
"circle-stroke-width": 2,
|
|
1514
|
+
"circle-stroke-color": "#fff"
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
return () => {
|
|
1518
|
+
try {
|
|
1519
|
+
if (map.getLayer(clusterCountLayerId)) map.removeLayer(clusterCountLayerId);
|
|
1520
|
+
if (map.getLayer(unclusteredLayerId)) map.removeLayer(unclusteredLayerId);
|
|
1521
|
+
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
|
|
1522
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1523
|
+
} catch {}
|
|
1524
|
+
};
|
|
1525
|
+
}, [
|
|
1526
|
+
isLoaded,
|
|
1527
|
+
map,
|
|
1528
|
+
sourceId
|
|
1529
|
+
]);
|
|
1530
|
+
useEffect(() => {
|
|
1531
|
+
if (!isLoaded || !map || typeof data === "string") return;
|
|
1532
|
+
const source = map.getSource(sourceId);
|
|
1533
|
+
if (source) source.setData(data);
|
|
1534
|
+
}, [
|
|
1535
|
+
isLoaded,
|
|
1536
|
+
map,
|
|
1537
|
+
data,
|
|
1538
|
+
sourceId
|
|
1539
|
+
]);
|
|
1540
|
+
useEffect(() => {
|
|
1541
|
+
if (!isLoaded || !map) return;
|
|
1542
|
+
const prev = stylePropsRef.current;
|
|
1543
|
+
const colorsChanged = prev.clusterColors !== clusterColors || prev.clusterThresholds !== clusterThresholds;
|
|
1544
|
+
if (map.getLayer(clusterLayerId) && colorsChanged) {
|
|
1545
|
+
map.setPaintProperty(clusterLayerId, "circle-color", [
|
|
1546
|
+
"step",
|
|
1547
|
+
["get", "point_count"],
|
|
1548
|
+
clusterColors[0],
|
|
1549
|
+
clusterThresholds[0],
|
|
1550
|
+
clusterColors[1],
|
|
1551
|
+
clusterThresholds[1],
|
|
1552
|
+
clusterColors[2]
|
|
1553
|
+
]);
|
|
1554
|
+
map.setPaintProperty(clusterLayerId, "circle-radius", [
|
|
1555
|
+
"step",
|
|
1556
|
+
["get", "point_count"],
|
|
1557
|
+
20,
|
|
1558
|
+
clusterThresholds[0],
|
|
1559
|
+
30,
|
|
1560
|
+
clusterThresholds[1],
|
|
1561
|
+
40
|
|
1562
|
+
]);
|
|
1563
|
+
}
|
|
1564
|
+
if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor);
|
|
1565
|
+
stylePropsRef.current = {
|
|
1566
|
+
clusterColors,
|
|
1567
|
+
clusterThresholds,
|
|
1568
|
+
pointColor
|
|
1569
|
+
};
|
|
1570
|
+
}, [
|
|
1571
|
+
isLoaded,
|
|
1572
|
+
map,
|
|
1573
|
+
clusterLayerId,
|
|
1574
|
+
unclusteredLayerId,
|
|
1575
|
+
clusterColors,
|
|
1576
|
+
clusterThresholds,
|
|
1577
|
+
pointColor
|
|
1578
|
+
]);
|
|
1579
|
+
useEffect(() => {
|
|
1580
|
+
if (!isLoaded || !map) return;
|
|
1581
|
+
const handleClusterClick = async (e) => {
|
|
1582
|
+
const features = map.queryRenderedFeatures(e.point, { layers: [clusterLayerId] });
|
|
1583
|
+
if (!features.length) return;
|
|
1584
|
+
const feature = features[0];
|
|
1585
|
+
const clusterId = feature.properties?.cluster_id;
|
|
1586
|
+
const pointCount = feature.properties?.point_count;
|
|
1587
|
+
const coordinates = feature.geometry.coordinates;
|
|
1588
|
+
if (onClusterClick) onClusterClick(clusterId, coordinates, pointCount);
|
|
1589
|
+
else {
|
|
1590
|
+
const zoom = await map.getSource(sourceId).getClusterExpansionZoom(clusterId);
|
|
1591
|
+
map.easeTo({
|
|
1592
|
+
center: coordinates,
|
|
1593
|
+
zoom
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
const handlePointClick = (e) => {
|
|
1598
|
+
if (!onPointClick || !e.features?.length) return;
|
|
1599
|
+
const feature = e.features[0];
|
|
1600
|
+
const coordinates = feature.geometry.coordinates.slice();
|
|
1601
|
+
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
|
|
1602
|
+
onPointClick(feature, coordinates);
|
|
1603
|
+
};
|
|
1604
|
+
const handleMouseEnterCluster = () => {
|
|
1605
|
+
map.getCanvas().style.cursor = "pointer";
|
|
1606
|
+
};
|
|
1607
|
+
const handleMouseLeaveCluster = () => {
|
|
1608
|
+
map.getCanvas().style.cursor = "";
|
|
1609
|
+
};
|
|
1610
|
+
const handleMouseEnterPoint = () => {
|
|
1611
|
+
if (onPointClick) map.getCanvas().style.cursor = "pointer";
|
|
1612
|
+
};
|
|
1613
|
+
const handleMouseLeavePoint = () => {
|
|
1614
|
+
map.getCanvas().style.cursor = "";
|
|
1615
|
+
};
|
|
1616
|
+
map.on("click", clusterLayerId, handleClusterClick);
|
|
1617
|
+
map.on("click", unclusteredLayerId, handlePointClick);
|
|
1618
|
+
map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
|
1619
|
+
map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
|
1620
|
+
map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
|
1621
|
+
map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
|
1622
|
+
return () => {
|
|
1623
|
+
map.off("click", clusterLayerId, handleClusterClick);
|
|
1624
|
+
map.off("click", unclusteredLayerId, handlePointClick);
|
|
1625
|
+
map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
|
1626
|
+
map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
|
1627
|
+
map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
|
1628
|
+
map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
|
1629
|
+
};
|
|
1630
|
+
}, [
|
|
1631
|
+
isLoaded,
|
|
1632
|
+
map,
|
|
1633
|
+
clusterLayerId,
|
|
1634
|
+
unclusteredLayerId,
|
|
1635
|
+
sourceId,
|
|
1636
|
+
onClusterClick,
|
|
1637
|
+
onPointClick
|
|
1638
|
+
]);
|
|
1639
|
+
return null;
|
|
1640
|
+
}
|
|
1641
|
+
export { Map, MapArc, MapClusterLayer, MapControls, MapGeoJSON, MapMarker, MapPopup, MapRoute, MarkerContent, MarkerLabel, MarkerPopup, MarkerTooltip, RouteMarker, RouteProgress, useMap };
|