@traky-app/ui 0.0.0-stage → 1.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/README.md +30 -2
- package/package.json +26 -4
- package/registry/registry.json +6 -0
- package/registry/traky/hooks/use-mobile.ts +19 -0
- package/registry/traky/json/accordion.json +18 -0
- package/registry/traky/json/alert-dialog.json +20 -0
- package/registry/traky/json/alert.json +17 -0
- package/registry/traky/json/animated-beam.json +14 -0
- package/registry/traky/json/animated-list.json +14 -0
- package/registry/traky/json/avatar.json +17 -0
- package/registry/traky/json/badge.json +18 -0
- package/registry/traky/json/bento-grid.json +20 -0
- package/registry/traky/json/breadcrumb.json +18 -0
- package/registry/traky/json/bubble.json +18 -0
- package/registry/traky/json/button-group.json +21 -0
- package/registry/traky/json/button.json +18 -0
- package/registry/traky/json/calendar.json +21 -0
- package/registry/traky/json/card.json +14 -0
- package/registry/traky/json/carousel.json +21 -0
- package/registry/traky/json/chart.json +17 -0
- package/registry/traky/json/checkbox.json +18 -0
- package/registry/traky/json/checkpoint.json +22 -0
- package/registry/traky/json/code-block.json +21 -0
- package/registry/traky/json/collapsible.json +17 -0
- package/registry/traky/json/command.json +22 -0
- package/registry/traky/json/context-menu.json +18 -0
- package/registry/traky/json/dialog.json +18 -0
- package/registry/traky/json/drawer.json +17 -0
- package/registry/traky/json/dropdown-menu.json +18 -0
- package/registry/traky/json/empty.json +17 -0
- package/registry/traky/json/field.json +21 -0
- package/registry/traky/json/globe.json +17 -0
- package/registry/traky/json/hook-use-mobile.json +14 -0
- package/registry/traky/json/hover-card.json +17 -0
- package/registry/traky/json/input-group.json +22 -0
- package/registry/traky/json/input-otp.json +18 -0
- package/registry/traky/json/input.json +17 -0
- package/registry/traky/json/item.json +21 -0
- package/registry/traky/json/kbd.json +14 -0
- package/registry/traky/json/label.json +17 -0
- package/registry/traky/json/lib-disable-input-autofill.json +14 -0
- package/registry/traky/json/lib-sidebar-persistence.json +14 -0
- package/registry/traky/json/lib-utils.json +18 -0
- package/registry/traky/json/map.json +19 -0
- package/registry/traky/json/marker.json +18 -0
- package/registry/traky/json/marquee.json +14 -0
- package/registry/traky/json/message.json +14 -0
- package/registry/traky/json/navigation-menu.json +19 -0
- package/registry/traky/json/number-ticker.json +14 -0
- package/registry/traky/json/pagination.json +20 -0
- package/registry/traky/json/popover.json +17 -0
- package/registry/traky/json/progress.json +17 -0
- package/registry/traky/json/select.json +18 -0
- package/registry/traky/json/separator.json +17 -0
- package/registry/traky/json/sheet.json +18 -0
- package/registry/traky/json/sidebar.json +29 -0
- package/registry/traky/json/skeleton.json +14 -0
- package/registry/traky/json/sonner.json +18 -0
- package/registry/traky/json/spinner.json +17 -0
- package/registry/traky/json/switch.json +17 -0
- package/registry/traky/json/table.json +14 -0
- package/registry/traky/json/tabs.json +17 -0
- package/registry/traky/json/textarea.json +17 -0
- package/registry/traky/json/toggle-group.json +21 -0
- package/registry/traky/json/toggle.json +18 -0
- package/registry/traky/json/tooltip.json +17 -0
- package/registry/traky/json/trace.json +20 -0
- package/registry/traky/lib/disable-input-autofill.ts +23 -0
- package/registry/traky/lib/sidebar-persistence.ts +76 -0
- package/registry/traky/lib/utils.ts +6 -0
- package/registry/traky/registry.json +637 -0
- package/registry/traky/ui/accordion.tsx +65 -0
- package/registry/traky/ui/alert-dialog.tsx +163 -0
- package/registry/traky/ui/alert.tsx +66 -0
- package/registry/traky/ui/animated-beam.tsx +185 -0
- package/registry/traky/ui/animated-list.tsx +71 -0
- package/registry/traky/ui/avatar.tsx +53 -0
- package/registry/traky/ui/badge.tsx +46 -0
- package/registry/traky/ui/bento-grid.tsx +109 -0
- package/registry/traky/ui/breadcrumb.tsx +108 -0
- package/registry/traky/ui/bubble.tsx +125 -0
- package/registry/traky/ui/button-group.tsx +83 -0
- package/registry/traky/ui/button.tsx +64 -0
- package/registry/traky/ui/calendar.tsx +213 -0
- package/registry/traky/ui/card.tsx +92 -0
- package/registry/traky/ui/carousel.tsx +240 -0
- package/registry/traky/ui/chart.tsx +380 -0
- package/registry/traky/ui/checkbox.tsx +31 -0
- package/registry/traky/ui/checkpoint.tsx +94 -0
- package/registry/traky/ui/code-block.tsx +232 -0
- package/registry/traky/ui/collapsible.tsx +33 -0
- package/registry/traky/ui/command.tsx +190 -0
- package/registry/traky/ui/context-menu.tsx +251 -0
- package/registry/traky/ui/dialog.tsx +142 -0
- package/registry/traky/ui/drawer.tsx +135 -0
- package/registry/traky/ui/dropdown-menu.tsx +256 -0
- package/registry/traky/ui/empty.tsx +104 -0
- package/registry/traky/ui/field.tsx +248 -0
- package/registry/traky/ui/globe.tsx +234 -0
- package/registry/traky/ui/hover-card.tsx +44 -0
- package/registry/traky/ui/input-group.tsx +170 -0
- package/registry/traky/ui/input-otp.tsx +77 -0
- package/registry/traky/ui/input.tsx +24 -0
- package/registry/traky/ui/item.tsx +193 -0
- package/registry/traky/ui/kbd.tsx +28 -0
- package/registry/traky/ui/label.tsx +24 -0
- package/registry/traky/ui/map.tsx +2226 -0
- package/registry/traky/ui/marker.tsx +69 -0
- package/registry/traky/ui/marquee.tsx +74 -0
- package/registry/traky/ui/message.tsx +92 -0
- package/registry/traky/ui/navigation-menu.tsx +169 -0
- package/registry/traky/ui/number-ticker.tsx +90 -0
- package/registry/traky/ui/pagination.tsx +122 -0
- package/registry/traky/ui/popover.tsx +48 -0
- package/registry/traky/ui/progress.tsx +31 -0
- package/registry/traky/ui/select.tsx +193 -0
- package/registry/traky/ui/separator.tsx +28 -0
- package/registry/traky/ui/sheet.tsx +143 -0
- package/registry/traky/ui/sidebar.tsx +764 -0
- package/registry/traky/ui/skeleton.tsx +13 -0
- package/registry/traky/ui/sonner.tsx +71 -0
- package/registry/traky/ui/spinner.tsx +15 -0
- package/registry/traky/ui/switch.tsx +41 -0
- package/registry/traky/ui/table.tsx +116 -0
- package/registry/traky/ui/tabs.tsx +66 -0
- package/registry/traky/ui/textarea.tsx +20 -0
- package/registry/traky/ui/toggle-group.tsx +83 -0
- package/registry/traky/ui/toggle.tsx +47 -0
- package/registry/traky/ui/tooltip.tsx +61 -0
- package/registry/traky/ui/trace.tsx +165 -0
- package/schema/components.json +24 -0
|
@@ -0,0 +1,2226 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { IconX, IconMinus, IconPlus, IconCurrentLocation, IconMaximize, IconLoader2 } from "@tabler/icons-react";
|
|
4
|
+
import * as MapLibreGL from "maplibre-gl";
|
|
5
|
+
import type { PopupOptions, MarkerOptions } from "maplibre-gl";
|
|
6
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
7
|
+
import type * as GeoJSON from "geojson";
|
|
8
|
+
import {
|
|
9
|
+
createContext,
|
|
10
|
+
forwardRef,
|
|
11
|
+
useCallback,
|
|
12
|
+
useContext,
|
|
13
|
+
useEffect,
|
|
14
|
+
useId,
|
|
15
|
+
useImperativeHandle,
|
|
16
|
+
useMemo,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
type ReactNode,
|
|
20
|
+
} from "react";
|
|
21
|
+
import { createPortal } from "react-dom";
|
|
22
|
+
|
|
23
|
+
import { cn } from "@/lib/utils";
|
|
24
|
+
|
|
25
|
+
const defaultStyles = {
|
|
26
|
+
dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
|
27
|
+
light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// A tile-less, dependency-free style with a transparent background. Use it for
|
|
31
|
+
// data visualizations (choropleths, world arcs, dot maps) where you draw your
|
|
32
|
+
// own layers and don't need a street basemap. The easiest way to opt in is the
|
|
33
|
+
// `blank` prop:
|
|
34
|
+
// <Map blank>...</Map>
|
|
35
|
+
// The transparent background lets the themed container show through.
|
|
36
|
+
const blankMapStyle: MapLibreGL.StyleSpecification = {
|
|
37
|
+
version: 8,
|
|
38
|
+
sources: {},
|
|
39
|
+
layers: [
|
|
40
|
+
{
|
|
41
|
+
id: "background",
|
|
42
|
+
type: "background",
|
|
43
|
+
paint: { "background-color": "rgba(0, 0, 0, 0)" },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Prevent equivalent inline style objects from triggering a full map style reload.
|
|
49
|
+
function useStableValue<T>(value: T): T {
|
|
50
|
+
const key = useMemo(() => JSON.stringify(value) ?? "", [value]);
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
return useMemo(() => value, [key]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mergeHoverPaint<T extends Record<string, unknown>>(
|
|
56
|
+
paint: T,
|
|
57
|
+
hoverPaint: T | undefined,
|
|
58
|
+
): T {
|
|
59
|
+
if (!hoverPaint) return paint;
|
|
60
|
+
const merged: Record<string, unknown> = { ...paint };
|
|
61
|
+
for (const [key, hoverValue] of Object.entries(hoverPaint)) {
|
|
62
|
+
if (hoverValue === undefined) continue;
|
|
63
|
+
const baseValue = merged[key];
|
|
64
|
+
merged[key] =
|
|
65
|
+
baseValue === undefined
|
|
66
|
+
? hoverValue
|
|
67
|
+
: [
|
|
68
|
+
"case",
|
|
69
|
+
["boolean", ["feature-state", "hover"], false],
|
|
70
|
+
hoverValue,
|
|
71
|
+
baseValue,
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
return merged as T;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type Theme = "light" | "dark";
|
|
78
|
+
|
|
79
|
+
// Check the document for an explicit theme (works with next-themes, etc.).
|
|
80
|
+
// Covers both `attribute="class"` (the default) and `attribute="data-theme"`.
|
|
81
|
+
function getDocumentTheme(): Theme | null {
|
|
82
|
+
if (typeof document === "undefined") return null;
|
|
83
|
+
const root = document.documentElement;
|
|
84
|
+
if (root.classList.contains("dark")) return "dark";
|
|
85
|
+
if (root.classList.contains("light")) return "light";
|
|
86
|
+
const dataTheme = root.dataset.theme;
|
|
87
|
+
if (dataTheme === "dark" || dataTheme === "light") return dataTheme;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Get system preference
|
|
92
|
+
function getSystemTheme(): Theme {
|
|
93
|
+
if (typeof window === "undefined") return "light";
|
|
94
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
95
|
+
? "dark"
|
|
96
|
+
: "light";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function useResolvedTheme(themeProp?: "light" | "dark"): Theme {
|
|
100
|
+
const [detectedTheme, setDetectedTheme] = useState<Theme>(
|
|
101
|
+
() => getDocumentTheme() ?? getSystemTheme(),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (themeProp) return; // Skip detection if theme is provided via prop
|
|
106
|
+
|
|
107
|
+
// Watch for document theme changes (e.g., next-themes toggling the class
|
|
108
|
+
// or the data-theme attribute).
|
|
109
|
+
const observer = new MutationObserver(() => {
|
|
110
|
+
const docTheme = getDocumentTheme();
|
|
111
|
+
if (docTheme) {
|
|
112
|
+
setDetectedTheme(docTheme);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
observer.observe(document.documentElement, {
|
|
116
|
+
attributes: true,
|
|
117
|
+
attributeFilter: ["class", "data-theme"],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Also watch for system preference changes
|
|
121
|
+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
122
|
+
const handleSystemChange = (e: MediaQueryListEvent) => {
|
|
123
|
+
// Only use system preference if no document class is set
|
|
124
|
+
if (!getDocumentTheme()) {
|
|
125
|
+
setDetectedTheme(e.matches ? "dark" : "light");
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
mediaQuery.addEventListener("change", handleSystemChange);
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
observer.disconnect();
|
|
132
|
+
mediaQuery.removeEventListener("change", handleSystemChange);
|
|
133
|
+
};
|
|
134
|
+
}, [themeProp]);
|
|
135
|
+
|
|
136
|
+
return themeProp ?? detectedTheme;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type MapContextValue = {
|
|
140
|
+
map: MapLibreGL.Map | null;
|
|
141
|
+
isLoaded: boolean;
|
|
142
|
+
resolvedTheme: Theme;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const MapContext = createContext<MapContextValue | null>(null);
|
|
146
|
+
|
|
147
|
+
function useMap() {
|
|
148
|
+
const context = useContext(MapContext);
|
|
149
|
+
if (!context) {
|
|
150
|
+
throw new Error("useMap must be used within a Map component");
|
|
151
|
+
}
|
|
152
|
+
return context;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Map viewport state */
|
|
156
|
+
type MapViewport = {
|
|
157
|
+
/** Center coordinates [longitude, latitude] */
|
|
158
|
+
center: [number, number];
|
|
159
|
+
/** Zoom level */
|
|
160
|
+
zoom: number;
|
|
161
|
+
/** Bearing (rotation) in degrees */
|
|
162
|
+
bearing: number;
|
|
163
|
+
/** Pitch (tilt) in degrees */
|
|
164
|
+
pitch: number;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
type MapStyleOption = string | MapLibreGL.StyleSpecification;
|
|
168
|
+
|
|
169
|
+
type MapRef = MapLibreGL.Map;
|
|
170
|
+
|
|
171
|
+
type MapProps = {
|
|
172
|
+
children?: ReactNode;
|
|
173
|
+
/** Additional CSS classes for the map container */
|
|
174
|
+
className?: string;
|
|
175
|
+
/**
|
|
176
|
+
* Theme for the map. If not provided, automatically detects system preference.
|
|
177
|
+
* Pass your theme value here.
|
|
178
|
+
*/
|
|
179
|
+
theme?: Theme;
|
|
180
|
+
/** Custom map styles for light and dark themes. Overrides the default Carto styles. */
|
|
181
|
+
styles?: {
|
|
182
|
+
light?: MapStyleOption;
|
|
183
|
+
dark?: MapStyleOption;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Use a transparent, tile-less basemap instead of the default Carto street
|
|
187
|
+
* basemap — a blank canvas. Used alone it renders nothing; add your own
|
|
188
|
+
* layers on top (`<MapGeoJSON>`, `<MapArc>`, markers, etc.). Ideal for data
|
|
189
|
+
* visualizations (choropleths, arcs, dot maps).
|
|
190
|
+
* Ignored when an explicit `styles` prop is provided.
|
|
191
|
+
*/
|
|
192
|
+
blank?: boolean;
|
|
193
|
+
/** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
|
|
194
|
+
projection?: MapLibreGL.ProjectionSpecification;
|
|
195
|
+
/**
|
|
196
|
+
* Controlled viewport. When provided with onViewportChange,
|
|
197
|
+
* the map becomes controlled and viewport is driven by this prop.
|
|
198
|
+
*/
|
|
199
|
+
viewport?: Partial<MapViewport>;
|
|
200
|
+
/**
|
|
201
|
+
* Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).
|
|
202
|
+
* Can be used standalone to observe changes, or with `viewport` prop
|
|
203
|
+
* to enable controlled mode where the map viewport is driven by your state.
|
|
204
|
+
*/
|
|
205
|
+
onViewportChange?: (viewport: MapViewport) => void;
|
|
206
|
+
/** Show a loading indicator on the map */
|
|
207
|
+
loading?: boolean;
|
|
208
|
+
} & Omit<MapLibreGL.MapOptions, "container" | "style">;
|
|
209
|
+
|
|
210
|
+
function DefaultLoader() {
|
|
211
|
+
return (
|
|
212
|
+
<div className="bg-background/50 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-xs">
|
|
213
|
+
<div className="flex gap-1">
|
|
214
|
+
<span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full" />
|
|
215
|
+
<span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:150ms]" />
|
|
216
|
+
<span className="bg-muted-foreground/60 size-1.5 animate-pulse rounded-full [animation-delay:300ms]" />
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getViewport(map: MapLibreGL.Map): MapViewport {
|
|
223
|
+
const center = map.getCenter();
|
|
224
|
+
return {
|
|
225
|
+
center: [center.lng, center.lat],
|
|
226
|
+
zoom: map.getZoom(),
|
|
227
|
+
bearing: map.getBearing(),
|
|
228
|
+
pitch: map.getPitch(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const Map = forwardRef<MapRef, MapProps>(function Map(
|
|
233
|
+
{
|
|
234
|
+
children,
|
|
235
|
+
className,
|
|
236
|
+
theme: themeProp,
|
|
237
|
+
styles,
|
|
238
|
+
blank = false,
|
|
239
|
+
projection,
|
|
240
|
+
viewport,
|
|
241
|
+
onViewportChange,
|
|
242
|
+
loading = false,
|
|
243
|
+
...props
|
|
244
|
+
},
|
|
245
|
+
ref,
|
|
246
|
+
) {
|
|
247
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
248
|
+
const [mapInstance, setMapInstance] = useState<MapLibreGL.Map | null>(null);
|
|
249
|
+
const [isLoaded, setIsLoaded] = useState(false);
|
|
250
|
+
const [isStyleLoaded, setIsStyleLoaded] = useState(false);
|
|
251
|
+
const [pendingStyle, setPendingStyle] = useState<MapStyleOption | null>(null);
|
|
252
|
+
const currentStyleRef = useRef<MapStyleOption | null>(null);
|
|
253
|
+
const styleSwapInFlightRef = useRef(false);
|
|
254
|
+
const internalUpdateRef = useRef(false);
|
|
255
|
+
const resolvedTheme = useResolvedTheme(themeProp);
|
|
256
|
+
|
|
257
|
+
const isControlled = viewport !== undefined && onViewportChange !== undefined;
|
|
258
|
+
|
|
259
|
+
const onViewportChangeRef = useRef(onViewportChange);
|
|
260
|
+
onViewportChangeRef.current = onViewportChange;
|
|
261
|
+
|
|
262
|
+
const stableStyles = useStableValue(styles);
|
|
263
|
+
|
|
264
|
+
const mapStyles = useMemo(() => {
|
|
265
|
+
// Explicit styles win. Otherwise `blank` opts into the transparent
|
|
266
|
+
// tile-less basemap; with neither, fall back to the Carto defaults.
|
|
267
|
+
if (stableStyles) {
|
|
268
|
+
return {
|
|
269
|
+
dark: stableStyles.dark ?? defaultStyles.dark,
|
|
270
|
+
light: stableStyles.light ?? defaultStyles.light,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (blank) {
|
|
274
|
+
return { dark: blankMapStyle, light: blankMapStyle };
|
|
275
|
+
}
|
|
276
|
+
return defaultStyles;
|
|
277
|
+
}, [stableStyles, blank]);
|
|
278
|
+
|
|
279
|
+
// Expose the map instance to the parent component
|
|
280
|
+
useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
|
|
281
|
+
|
|
282
|
+
// Initialize the map
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (!containerRef.current) return;
|
|
285
|
+
|
|
286
|
+
const initialStyle =
|
|
287
|
+
resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
288
|
+
currentStyleRef.current = initialStyle;
|
|
289
|
+
|
|
290
|
+
const map = new MapLibreGL.Map({
|
|
291
|
+
container: containerRef.current,
|
|
292
|
+
style: initialStyle,
|
|
293
|
+
renderWorldCopies: false,
|
|
294
|
+
attributionControl: {
|
|
295
|
+
compact: true,
|
|
296
|
+
},
|
|
297
|
+
...props,
|
|
298
|
+
...viewport,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const styleLoadHandler = () => {
|
|
302
|
+
styleSwapInFlightRef.current = false;
|
|
303
|
+
setIsStyleLoaded(true);
|
|
304
|
+
};
|
|
305
|
+
const loadHandler = () => setIsLoaded(true);
|
|
306
|
+
|
|
307
|
+
// Viewport change handler - skip if triggered by internal update
|
|
308
|
+
const handleMove = () => {
|
|
309
|
+
if (internalUpdateRef.current) return;
|
|
310
|
+
onViewportChangeRef.current?.(getViewport(map));
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
map.on("load", loadHandler);
|
|
314
|
+
map.on("style.load", styleLoadHandler);
|
|
315
|
+
map.on("move", handleMove);
|
|
316
|
+
setMapInstance(map);
|
|
317
|
+
|
|
318
|
+
return () => {
|
|
319
|
+
map.off("load", loadHandler);
|
|
320
|
+
map.off("style.load", styleLoadHandler);
|
|
321
|
+
map.off("move", handleMove);
|
|
322
|
+
map.remove();
|
|
323
|
+
setIsLoaded(false);
|
|
324
|
+
setIsStyleLoaded(false);
|
|
325
|
+
setMapInstance(null);
|
|
326
|
+
};
|
|
327
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
// Sync controlled viewport to map
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!mapInstance || !isControlled || !viewport) return;
|
|
333
|
+
if (mapInstance.isMoving()) return;
|
|
334
|
+
|
|
335
|
+
const current = getViewport(mapInstance);
|
|
336
|
+
const next = {
|
|
337
|
+
center: viewport.center ?? current.center,
|
|
338
|
+
zoom: viewport.zoom ?? current.zoom,
|
|
339
|
+
bearing: viewport.bearing ?? current.bearing,
|
|
340
|
+
pitch: viewport.pitch ?? current.pitch,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (
|
|
344
|
+
next.center[0] === current.center[0] &&
|
|
345
|
+
next.center[1] === current.center[1] &&
|
|
346
|
+
next.zoom === current.zoom &&
|
|
347
|
+
next.bearing === current.bearing &&
|
|
348
|
+
next.pitch === current.pitch
|
|
349
|
+
) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
internalUpdateRef.current = true;
|
|
354
|
+
mapInstance.jumpTo(next);
|
|
355
|
+
internalUpdateRef.current = false;
|
|
356
|
+
}, [mapInstance, isControlled, viewport]);
|
|
357
|
+
|
|
358
|
+
// Handle style change: close the gate (so layer children tear down and
|
|
359
|
+
// re-add on the incoming style) - the swap itself is staged to the effect below.
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!mapInstance || !resolvedTheme) return;
|
|
362
|
+
|
|
363
|
+
const newStyle =
|
|
364
|
+
resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light;
|
|
365
|
+
|
|
366
|
+
if (currentStyleRef.current === newStyle) return;
|
|
367
|
+
|
|
368
|
+
currentStyleRef.current = newStyle;
|
|
369
|
+
setIsStyleLoaded(false);
|
|
370
|
+
setPendingStyle(newStyle);
|
|
371
|
+
}, [mapInstance, resolvedTheme, mapStyles]);
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!mapInstance || !pendingStyle) return;
|
|
375
|
+
|
|
376
|
+
setPendingStyle(null);
|
|
377
|
+
styleSwapInFlightRef.current = true;
|
|
378
|
+
// Full reload (no diff) so `style.load` fires deterministically. A
|
|
379
|
+
// successful diff would never fire it, leaving isStyleLoaded stuck false.
|
|
380
|
+
mapInstance.setStyle(pendingStyle, { diff: false });
|
|
381
|
+
}, [mapInstance, pendingStyle]);
|
|
382
|
+
|
|
383
|
+
// Sync projection when the prop changes after mount.
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (!mapInstance || !isStyleLoaded || !projection) return;
|
|
386
|
+
if (styleSwapInFlightRef.current) return;
|
|
387
|
+
mapInstance.setProjection(projection);
|
|
388
|
+
}, [mapInstance, isStyleLoaded, projection]);
|
|
389
|
+
|
|
390
|
+
const contextValue = useMemo(
|
|
391
|
+
() => ({
|
|
392
|
+
map: mapInstance,
|
|
393
|
+
isLoaded: isLoaded && isStyleLoaded,
|
|
394
|
+
resolvedTheme,
|
|
395
|
+
}),
|
|
396
|
+
[mapInstance, isLoaded, isStyleLoaded, resolvedTheme],
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
<MapContext.Provider value={contextValue}>
|
|
401
|
+
<div
|
|
402
|
+
ref={containerRef}
|
|
403
|
+
className={cn("relative h-full w-full", className)}
|
|
404
|
+
>
|
|
405
|
+
{(!isLoaded || loading) && <DefaultLoader />}
|
|
406
|
+
{/* SSR-safe: children render only when map is loaded on client */}
|
|
407
|
+
{mapInstance && children}
|
|
408
|
+
</div>
|
|
409
|
+
</MapContext.Provider>
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
type MarkerContextValue = {
|
|
414
|
+
marker: MapLibreGL.Marker;
|
|
415
|
+
map: MapLibreGL.Map | null;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const MarkerContext = createContext<MarkerContextValue | null>(null);
|
|
419
|
+
|
|
420
|
+
function useMarkerContext() {
|
|
421
|
+
const context = useContext(MarkerContext);
|
|
422
|
+
if (!context) {
|
|
423
|
+
throw new Error("Marker components must be used within MapMarker");
|
|
424
|
+
}
|
|
425
|
+
return context;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
type MapMarkerProps = {
|
|
429
|
+
/** Longitude coordinate for marker position */
|
|
430
|
+
longitude: number;
|
|
431
|
+
/** Latitude coordinate for marker position */
|
|
432
|
+
latitude: number;
|
|
433
|
+
/** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */
|
|
434
|
+
children: ReactNode;
|
|
435
|
+
/** Callback when marker is clicked */
|
|
436
|
+
onClick?: (e: MouseEvent) => void;
|
|
437
|
+
/** Callback when mouse enters marker */
|
|
438
|
+
onMouseEnter?: (e: MouseEvent) => void;
|
|
439
|
+
/** Callback when mouse leaves marker */
|
|
440
|
+
onMouseLeave?: (e: MouseEvent) => void;
|
|
441
|
+
/** Callback when marker drag starts (requires draggable: true) */
|
|
442
|
+
onDragStart?: (lngLat: { lng: number; lat: number }) => void;
|
|
443
|
+
/** Callback during marker drag (requires draggable: true) */
|
|
444
|
+
onDrag?: (lngLat: { lng: number; lat: number }) => void;
|
|
445
|
+
/** Callback when marker drag ends (requires draggable: true) */
|
|
446
|
+
onDragEnd?: (lngLat: { lng: number; lat: number }) => void;
|
|
447
|
+
} & Omit<MarkerOptions, "element">;
|
|
448
|
+
|
|
449
|
+
function MapMarker({
|
|
450
|
+
longitude,
|
|
451
|
+
latitude,
|
|
452
|
+
children,
|
|
453
|
+
onClick,
|
|
454
|
+
onMouseEnter,
|
|
455
|
+
onMouseLeave,
|
|
456
|
+
onDragStart,
|
|
457
|
+
onDrag,
|
|
458
|
+
onDragEnd,
|
|
459
|
+
draggable = false,
|
|
460
|
+
...markerOptions
|
|
461
|
+
}: MapMarkerProps) {
|
|
462
|
+
const { map } = useMap();
|
|
463
|
+
|
|
464
|
+
const callbacksRef = useRef({
|
|
465
|
+
onClick,
|
|
466
|
+
onMouseEnter,
|
|
467
|
+
onMouseLeave,
|
|
468
|
+
onDragStart,
|
|
469
|
+
onDrag,
|
|
470
|
+
onDragEnd,
|
|
471
|
+
});
|
|
472
|
+
callbacksRef.current = {
|
|
473
|
+
onClick,
|
|
474
|
+
onMouseEnter,
|
|
475
|
+
onMouseLeave,
|
|
476
|
+
onDragStart,
|
|
477
|
+
onDrag,
|
|
478
|
+
onDragEnd,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const marker = useMemo(() => {
|
|
482
|
+
const markerInstance = new MapLibreGL.Marker({
|
|
483
|
+
...markerOptions,
|
|
484
|
+
element: document.createElement("div"),
|
|
485
|
+
draggable,
|
|
486
|
+
}).setLngLat([longitude, latitude]);
|
|
487
|
+
|
|
488
|
+
const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
|
|
489
|
+
const handleMouseEnter = (e: MouseEvent) =>
|
|
490
|
+
callbacksRef.current.onMouseEnter?.(e);
|
|
491
|
+
const handleMouseLeave = (e: MouseEvent) =>
|
|
492
|
+
callbacksRef.current.onMouseLeave?.(e);
|
|
493
|
+
|
|
494
|
+
markerInstance.getElement()?.addEventListener("click", handleClick);
|
|
495
|
+
markerInstance
|
|
496
|
+
.getElement()
|
|
497
|
+
?.addEventListener("mouseenter", handleMouseEnter);
|
|
498
|
+
markerInstance
|
|
499
|
+
.getElement()
|
|
500
|
+
?.addEventListener("mouseleave", handleMouseLeave);
|
|
501
|
+
|
|
502
|
+
const handleDragStart = () => {
|
|
503
|
+
const lngLat = markerInstance.getLngLat();
|
|
504
|
+
callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
|
|
505
|
+
};
|
|
506
|
+
const handleDrag = () => {
|
|
507
|
+
const lngLat = markerInstance.getLngLat();
|
|
508
|
+
callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
|
|
509
|
+
};
|
|
510
|
+
const handleDragEnd = () => {
|
|
511
|
+
const lngLat = markerInstance.getLngLat();
|
|
512
|
+
callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
markerInstance.on("dragstart", handleDragStart);
|
|
516
|
+
markerInstance.on("drag", handleDrag);
|
|
517
|
+
markerInstance.on("dragend", handleDragEnd);
|
|
518
|
+
|
|
519
|
+
return markerInstance;
|
|
520
|
+
|
|
521
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
522
|
+
}, []);
|
|
523
|
+
|
|
524
|
+
useEffect(() => {
|
|
525
|
+
if (!map) return;
|
|
526
|
+
|
|
527
|
+
marker.addTo(map);
|
|
528
|
+
|
|
529
|
+
return () => {
|
|
530
|
+
marker.remove();
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
534
|
+
}, [map]);
|
|
535
|
+
|
|
536
|
+
const { offset, rotation, rotationAlignment, pitchAlignment } = markerOptions;
|
|
537
|
+
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
const current = marker.getLngLat();
|
|
540
|
+
if (current.lng !== longitude || current.lat !== latitude) {
|
|
541
|
+
marker.setLngLat([longitude, latitude]);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (marker.isDraggable() !== draggable) {
|
|
545
|
+
marker.setDraggable(draggable);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const currentOffset = marker.getOffset();
|
|
549
|
+
const newOffset = offset ?? [0, 0];
|
|
550
|
+
const [newOffsetX, newOffsetY] = Array.isArray(newOffset)
|
|
551
|
+
? newOffset
|
|
552
|
+
: [newOffset.x, newOffset.y];
|
|
553
|
+
if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {
|
|
554
|
+
marker.setOffset(newOffset);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (marker.getRotation() !== (rotation ?? 0)) {
|
|
558
|
+
marker.setRotation(rotation ?? 0);
|
|
559
|
+
}
|
|
560
|
+
if (marker.getRotationAlignment() !== (rotationAlignment ?? "auto")) {
|
|
561
|
+
marker.setRotationAlignment(rotationAlignment ?? "auto");
|
|
562
|
+
}
|
|
563
|
+
if (marker.getPitchAlignment() !== (pitchAlignment ?? "auto")) {
|
|
564
|
+
marker.setPitchAlignment(pitchAlignment ?? "auto");
|
|
565
|
+
}
|
|
566
|
+
}, [
|
|
567
|
+
marker,
|
|
568
|
+
longitude,
|
|
569
|
+
latitude,
|
|
570
|
+
draggable,
|
|
571
|
+
offset,
|
|
572
|
+
rotation,
|
|
573
|
+
rotationAlignment,
|
|
574
|
+
pitchAlignment,
|
|
575
|
+
]);
|
|
576
|
+
|
|
577
|
+
return (
|
|
578
|
+
<MarkerContext.Provider value={{ marker, map }}>
|
|
579
|
+
{children}
|
|
580
|
+
</MarkerContext.Provider>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
type MarkerContentProps = {
|
|
585
|
+
/** Custom marker content. Defaults to a blue dot if not provided */
|
|
586
|
+
children?: ReactNode;
|
|
587
|
+
/** Additional CSS classes for the marker container */
|
|
588
|
+
className?: string;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
function MarkerContent({ children, className }: MarkerContentProps) {
|
|
592
|
+
const { marker } = useMarkerContext();
|
|
593
|
+
|
|
594
|
+
return createPortal(
|
|
595
|
+
<div className={cn("relative cursor-pointer", className)}>
|
|
596
|
+
{children || <DefaultMarkerIcon />}
|
|
597
|
+
</div>,
|
|
598
|
+
marker.getElement(),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function DefaultMarkerIcon() {
|
|
603
|
+
return (
|
|
604
|
+
<div className="relative h-4 w-4 rounded-full border-2 border-white bg-blue-500 shadow-lg" />
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function PopupCloseButton({ onClick }: { onClick: () => void }) {
|
|
609
|
+
return (
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
onClick={onClick}
|
|
613
|
+
aria-label="Close popup"
|
|
614
|
+
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"
|
|
615
|
+
>
|
|
616
|
+
<IconX className="size-3.5" />
|
|
617
|
+
</button>
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
type MarkerPopupProps = {
|
|
622
|
+
/** Popup content */
|
|
623
|
+
children: ReactNode;
|
|
624
|
+
/** Additional CSS classes for the popup container */
|
|
625
|
+
className?: string;
|
|
626
|
+
/** Show a close button in the popup (default: false) */
|
|
627
|
+
closeButton?: boolean;
|
|
628
|
+
} & Omit<PopupOptions, "className" | "closeButton">;
|
|
629
|
+
|
|
630
|
+
function MarkerPopup({
|
|
631
|
+
children,
|
|
632
|
+
className,
|
|
633
|
+
closeButton = false,
|
|
634
|
+
...popupOptions
|
|
635
|
+
}: MarkerPopupProps) {
|
|
636
|
+
const { marker, map } = useMarkerContext();
|
|
637
|
+
const container = useMemo(() => document.createElement("div"), []);
|
|
638
|
+
const { offset, maxWidth } = popupOptions;
|
|
639
|
+
|
|
640
|
+
const popup = useMemo(() => {
|
|
641
|
+
const popupInstance = new MapLibreGL.Popup({
|
|
642
|
+
offset: 16,
|
|
643
|
+
...popupOptions,
|
|
644
|
+
closeButton: false,
|
|
645
|
+
})
|
|
646
|
+
.setMaxWidth("none")
|
|
647
|
+
.setDOMContent(container);
|
|
648
|
+
|
|
649
|
+
return popupInstance;
|
|
650
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
651
|
+
}, []);
|
|
652
|
+
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
if (!map) return;
|
|
655
|
+
|
|
656
|
+
popup.setDOMContent(container);
|
|
657
|
+
marker.setPopup(popup);
|
|
658
|
+
|
|
659
|
+
return () => {
|
|
660
|
+
marker.setPopup(null);
|
|
661
|
+
};
|
|
662
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
663
|
+
}, [map]);
|
|
664
|
+
|
|
665
|
+
// Sync popup options when they change.
|
|
666
|
+
useEffect(() => {
|
|
667
|
+
popup.setOffset(offset ?? 16);
|
|
668
|
+
if (maxWidth) {
|
|
669
|
+
popup.setMaxWidth(maxWidth);
|
|
670
|
+
}
|
|
671
|
+
}, [popup, offset, maxWidth]);
|
|
672
|
+
|
|
673
|
+
const handleClose = () => popup.remove();
|
|
674
|
+
|
|
675
|
+
return createPortal(
|
|
676
|
+
<div
|
|
677
|
+
className={cn(
|
|
678
|
+
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
|
|
679
|
+
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
|
|
680
|
+
className,
|
|
681
|
+
)}
|
|
682
|
+
>
|
|
683
|
+
{closeButton && <PopupCloseButton onClick={handleClose} />}
|
|
684
|
+
{children}
|
|
685
|
+
</div>,
|
|
686
|
+
container,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
type MarkerTooltipProps = {
|
|
691
|
+
/** Tooltip content */
|
|
692
|
+
children: ReactNode;
|
|
693
|
+
/** Additional CSS classes for the tooltip container */
|
|
694
|
+
className?: string;
|
|
695
|
+
} & Omit<PopupOptions, "className" | "closeButton" | "closeOnClick">;
|
|
696
|
+
|
|
697
|
+
function MarkerTooltip({
|
|
698
|
+
children,
|
|
699
|
+
className,
|
|
700
|
+
...popupOptions
|
|
701
|
+
}: MarkerTooltipProps) {
|
|
702
|
+
const { marker, map } = useMarkerContext();
|
|
703
|
+
const container = useMemo(() => document.createElement("div"), []);
|
|
704
|
+
const { offset, maxWidth } = popupOptions;
|
|
705
|
+
|
|
706
|
+
const tooltip = useMemo(() => {
|
|
707
|
+
const tooltipInstance = new MapLibreGL.Popup({
|
|
708
|
+
offset: 16,
|
|
709
|
+
...popupOptions,
|
|
710
|
+
closeOnClick: true,
|
|
711
|
+
closeButton: false,
|
|
712
|
+
}).setMaxWidth("none");
|
|
713
|
+
|
|
714
|
+
return tooltipInstance;
|
|
715
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
716
|
+
}, []);
|
|
717
|
+
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
if (!map) return;
|
|
720
|
+
|
|
721
|
+
tooltip.setDOMContent(container);
|
|
722
|
+
|
|
723
|
+
const handleMouseEnter = () => {
|
|
724
|
+
tooltip.setLngLat(marker.getLngLat()).addTo(map);
|
|
725
|
+
};
|
|
726
|
+
const handleMouseLeave = () => tooltip.remove();
|
|
727
|
+
|
|
728
|
+
marker.getElement()?.addEventListener("mouseenter", handleMouseEnter);
|
|
729
|
+
marker.getElement()?.addEventListener("mouseleave", handleMouseLeave);
|
|
730
|
+
|
|
731
|
+
return () => {
|
|
732
|
+
marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter);
|
|
733
|
+
marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave);
|
|
734
|
+
tooltip.remove();
|
|
735
|
+
};
|
|
736
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
737
|
+
}, [map]);
|
|
738
|
+
|
|
739
|
+
// Sync tooltip options when they change.
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
tooltip.setOffset(offset ?? 16);
|
|
742
|
+
if (maxWidth) {
|
|
743
|
+
tooltip.setMaxWidth(maxWidth);
|
|
744
|
+
}
|
|
745
|
+
}, [tooltip, offset, maxWidth]);
|
|
746
|
+
|
|
747
|
+
return createPortal(
|
|
748
|
+
<div
|
|
749
|
+
className={cn(
|
|
750
|
+
"bg-foreground text-background pointer-events-none rounded-md px-2 py-1 text-xs text-balance shadow-md",
|
|
751
|
+
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
|
|
752
|
+
className,
|
|
753
|
+
)}
|
|
754
|
+
>
|
|
755
|
+
{children}
|
|
756
|
+
</div>,
|
|
757
|
+
container,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
type MarkerLabelProps = {
|
|
762
|
+
/** Label text content */
|
|
763
|
+
children: ReactNode;
|
|
764
|
+
/** Additional CSS classes for the label */
|
|
765
|
+
className?: string;
|
|
766
|
+
/** Position of the label relative to the marker (default: "top") */
|
|
767
|
+
position?: "top" | "bottom";
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
function MarkerLabel({
|
|
771
|
+
children,
|
|
772
|
+
className,
|
|
773
|
+
position = "top",
|
|
774
|
+
}: MarkerLabelProps) {
|
|
775
|
+
const positionClasses = {
|
|
776
|
+
top: "bottom-full mb-1",
|
|
777
|
+
bottom: "top-full mt-1",
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
return (
|
|
781
|
+
<div
|
|
782
|
+
className={cn(
|
|
783
|
+
"absolute left-1/2 -translate-x-1/2 whitespace-nowrap",
|
|
784
|
+
"text-foreground text-[10px] font-medium",
|
|
785
|
+
positionClasses[position],
|
|
786
|
+
className,
|
|
787
|
+
)}
|
|
788
|
+
>
|
|
789
|
+
{children}
|
|
790
|
+
</div>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
type MapControlsProps = {
|
|
795
|
+
/** Position of the controls on the map (default: "bottom-right") */
|
|
796
|
+
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
797
|
+
/** Show zoom in/out buttons (default: true) */
|
|
798
|
+
showZoom?: boolean;
|
|
799
|
+
/** Show compass button to reset bearing (default: false) */
|
|
800
|
+
showCompass?: boolean;
|
|
801
|
+
/** Show locate button to find user's location (default: false) */
|
|
802
|
+
showLocate?: boolean;
|
|
803
|
+
/** Show fullscreen toggle button (default: false) */
|
|
804
|
+
showFullscreen?: boolean;
|
|
805
|
+
/** Additional CSS classes for the controls container */
|
|
806
|
+
className?: string;
|
|
807
|
+
/** Callback with user coordinates when located */
|
|
808
|
+
onLocate?: (coords: { longitude: number; latitude: number }) => void;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const positionClasses = {
|
|
812
|
+
"top-left": "top-2 left-2",
|
|
813
|
+
"top-right": "top-2 right-2",
|
|
814
|
+
"bottom-left": "bottom-2 left-2",
|
|
815
|
+
"bottom-right": "bottom-10 right-2",
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
function ControlGroup({ children }: { children: React.ReactNode }) {
|
|
819
|
+
return (
|
|
820
|
+
<div 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">
|
|
821
|
+
{children}
|
|
822
|
+
</div>
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function ControlButton({
|
|
827
|
+
onClick,
|
|
828
|
+
label,
|
|
829
|
+
children,
|
|
830
|
+
disabled = false,
|
|
831
|
+
}: {
|
|
832
|
+
onClick: () => void;
|
|
833
|
+
label: string;
|
|
834
|
+
children: React.ReactNode;
|
|
835
|
+
disabled?: boolean;
|
|
836
|
+
}) {
|
|
837
|
+
return (
|
|
838
|
+
<button
|
|
839
|
+
onClick={onClick}
|
|
840
|
+
aria-label={label}
|
|
841
|
+
type="button"
|
|
842
|
+
className={cn(
|
|
843
|
+
"flex size-8 items-center justify-center transition-colors",
|
|
844
|
+
"first:rounded-t-md last:rounded-b-md",
|
|
845
|
+
"hover:bg-accent dark:hover:bg-accent/40",
|
|
846
|
+
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
|
|
847
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
848
|
+
)}
|
|
849
|
+
disabled={disabled}
|
|
850
|
+
>
|
|
851
|
+
{children}
|
|
852
|
+
</button>
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function MapControls({
|
|
857
|
+
position = "bottom-right",
|
|
858
|
+
showZoom = true,
|
|
859
|
+
showCompass = false,
|
|
860
|
+
showLocate = false,
|
|
861
|
+
showFullscreen = false,
|
|
862
|
+
className,
|
|
863
|
+
onLocate,
|
|
864
|
+
}: MapControlsProps) {
|
|
865
|
+
const { map } = useMap();
|
|
866
|
+
const [waitingForLocation, setWaitingForLocation] = useState(false);
|
|
867
|
+
|
|
868
|
+
const handleZoomIn = useCallback(() => {
|
|
869
|
+
map?.zoomTo(map.getZoom() + 1, { duration: 300 });
|
|
870
|
+
}, [map]);
|
|
871
|
+
|
|
872
|
+
const handleZoomOut = useCallback(() => {
|
|
873
|
+
map?.zoomTo(map.getZoom() - 1, { duration: 300 });
|
|
874
|
+
}, [map]);
|
|
875
|
+
|
|
876
|
+
const handleResetBearing = useCallback(() => {
|
|
877
|
+
map?.resetNorthPitch({ duration: 300 });
|
|
878
|
+
}, [map]);
|
|
879
|
+
|
|
880
|
+
const handleLocate = useCallback(() => {
|
|
881
|
+
if (!("geolocation" in navigator)) return;
|
|
882
|
+
setWaitingForLocation(true);
|
|
883
|
+
navigator.geolocation.getCurrentPosition(
|
|
884
|
+
(pos) => {
|
|
885
|
+
const coords = {
|
|
886
|
+
longitude: pos.coords.longitude,
|
|
887
|
+
latitude: pos.coords.latitude,
|
|
888
|
+
};
|
|
889
|
+
map?.flyTo({
|
|
890
|
+
center: [coords.longitude, coords.latitude],
|
|
891
|
+
zoom: 14,
|
|
892
|
+
duration: 1500,
|
|
893
|
+
});
|
|
894
|
+
onLocate?.(coords);
|
|
895
|
+
setWaitingForLocation(false);
|
|
896
|
+
},
|
|
897
|
+
(error) => {
|
|
898
|
+
console.error("Error getting location:", error);
|
|
899
|
+
setWaitingForLocation(false);
|
|
900
|
+
},
|
|
901
|
+
// Without a timeout the spec default is Infinity: a dismissed permission
|
|
902
|
+
// prompt would leave the button disabled forever.
|
|
903
|
+
{ timeout: 10000 },
|
|
904
|
+
);
|
|
905
|
+
}, [map, onLocate]);
|
|
906
|
+
|
|
907
|
+
const handleFullscreen = useCallback(() => {
|
|
908
|
+
const container = map?.getContainer();
|
|
909
|
+
if (!container) return;
|
|
910
|
+
if (document.fullscreenElement) {
|
|
911
|
+
document.exitFullscreen();
|
|
912
|
+
} else {
|
|
913
|
+
container.requestFullscreen();
|
|
914
|
+
}
|
|
915
|
+
}, [map]);
|
|
916
|
+
|
|
917
|
+
return (
|
|
918
|
+
<div
|
|
919
|
+
className={cn(
|
|
920
|
+
"absolute z-10 flex flex-col gap-1.5",
|
|
921
|
+
positionClasses[position],
|
|
922
|
+
className,
|
|
923
|
+
)}
|
|
924
|
+
>
|
|
925
|
+
{showZoom && (
|
|
926
|
+
<ControlGroup>
|
|
927
|
+
<ControlButton onClick={handleZoomIn} label="Zoom in">
|
|
928
|
+
<IconPlus className="size-4" />
|
|
929
|
+
</ControlButton>
|
|
930
|
+
<ControlButton onClick={handleZoomOut} label="Zoom out">
|
|
931
|
+
<IconMinus className="size-4" />
|
|
932
|
+
</ControlButton>
|
|
933
|
+
</ControlGroup>
|
|
934
|
+
)}
|
|
935
|
+
{showCompass && (
|
|
936
|
+
<ControlGroup>
|
|
937
|
+
<CompassButton onClick={handleResetBearing} />
|
|
938
|
+
</ControlGroup>
|
|
939
|
+
)}
|
|
940
|
+
{showLocate && (
|
|
941
|
+
<ControlGroup>
|
|
942
|
+
<ControlButton
|
|
943
|
+
onClick={handleLocate}
|
|
944
|
+
label="Find my location"
|
|
945
|
+
disabled={waitingForLocation}
|
|
946
|
+
>
|
|
947
|
+
{waitingForLocation ? (
|
|
948
|
+
<IconLoader2 className="size-4 animate-spin" />
|
|
949
|
+
) : (
|
|
950
|
+
<IconCurrentLocation className="size-4" />
|
|
951
|
+
)}
|
|
952
|
+
</ControlButton>
|
|
953
|
+
</ControlGroup>
|
|
954
|
+
)}
|
|
955
|
+
{showFullscreen && (
|
|
956
|
+
<ControlGroup>
|
|
957
|
+
<ControlButton onClick={handleFullscreen} label="Toggle fullscreen">
|
|
958
|
+
<IconMaximize className="size-4" />
|
|
959
|
+
</ControlButton>
|
|
960
|
+
</ControlGroup>
|
|
961
|
+
)}
|
|
962
|
+
</div>
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function CompassButton({ onClick }: { onClick: () => void }) {
|
|
967
|
+
const { map } = useMap();
|
|
968
|
+
const compassRef = useRef<SVGSVGElement>(null);
|
|
969
|
+
|
|
970
|
+
useEffect(() => {
|
|
971
|
+
if (!map || !compassRef.current) return;
|
|
972
|
+
|
|
973
|
+
const compass = compassRef.current;
|
|
974
|
+
|
|
975
|
+
const updateRotation = () => {
|
|
976
|
+
const bearing = map.getBearing();
|
|
977
|
+
const pitch = map.getPitch();
|
|
978
|
+
compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
map.on("rotate", updateRotation);
|
|
982
|
+
map.on("pitch", updateRotation);
|
|
983
|
+
updateRotation();
|
|
984
|
+
|
|
985
|
+
return () => {
|
|
986
|
+
map.off("rotate", updateRotation);
|
|
987
|
+
map.off("pitch", updateRotation);
|
|
988
|
+
};
|
|
989
|
+
}, [map]);
|
|
990
|
+
|
|
991
|
+
return (
|
|
992
|
+
<ControlButton onClick={onClick} label="Reset bearing to north">
|
|
993
|
+
<svg
|
|
994
|
+
ref={compassRef}
|
|
995
|
+
viewBox="0 0 24 24"
|
|
996
|
+
className="size-5"
|
|
997
|
+
style={{ transformStyle: "preserve-3d" }}
|
|
998
|
+
>
|
|
999
|
+
<path d="M12 2L16 12H12V2Z" className="fill-red-500" />
|
|
1000
|
+
<path d="M12 2L8 12H12V2Z" className="fill-red-300" />
|
|
1001
|
+
<path d="M12 22L16 12H12V22Z" className="fill-muted-foreground/60" />
|
|
1002
|
+
<path d="M12 22L8 12H12V22Z" className="fill-muted-foreground/30" />
|
|
1003
|
+
</svg>
|
|
1004
|
+
</ControlButton>
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
type MapPopupProps = {
|
|
1009
|
+
/** Longitude coordinate for popup position */
|
|
1010
|
+
longitude: number;
|
|
1011
|
+
/** Latitude coordinate for popup position */
|
|
1012
|
+
latitude: number;
|
|
1013
|
+
/** Callback when popup is closed */
|
|
1014
|
+
onClose?: () => void;
|
|
1015
|
+
/** Popup content */
|
|
1016
|
+
children: ReactNode;
|
|
1017
|
+
/** Additional CSS classes for the popup container */
|
|
1018
|
+
className?: string;
|
|
1019
|
+
/** Show a close button in the popup (default: false) */
|
|
1020
|
+
closeButton?: boolean;
|
|
1021
|
+
} & Omit<PopupOptions, "className" | "closeButton">;
|
|
1022
|
+
|
|
1023
|
+
function MapPopup({
|
|
1024
|
+
longitude,
|
|
1025
|
+
latitude,
|
|
1026
|
+
onClose,
|
|
1027
|
+
children,
|
|
1028
|
+
className,
|
|
1029
|
+
closeButton = false,
|
|
1030
|
+
...popupOptions
|
|
1031
|
+
}: MapPopupProps) {
|
|
1032
|
+
const { map } = useMap();
|
|
1033
|
+
const onCloseRef = useRef(onClose);
|
|
1034
|
+
onCloseRef.current = onClose;
|
|
1035
|
+
const container = useMemo(() => document.createElement("div"), []);
|
|
1036
|
+
const { offset, maxWidth } = popupOptions;
|
|
1037
|
+
|
|
1038
|
+
const popup = useMemo(() => {
|
|
1039
|
+
const popupInstance = new MapLibreGL.Popup({
|
|
1040
|
+
offset: 16,
|
|
1041
|
+
...popupOptions,
|
|
1042
|
+
closeButton: false,
|
|
1043
|
+
})
|
|
1044
|
+
.setMaxWidth("none")
|
|
1045
|
+
.setLngLat([longitude, latitude]);
|
|
1046
|
+
|
|
1047
|
+
return popupInstance;
|
|
1048
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1049
|
+
}, []);
|
|
1050
|
+
|
|
1051
|
+
useEffect(() => {
|
|
1052
|
+
if (!map) return;
|
|
1053
|
+
|
|
1054
|
+
const onCloseProp = () => onCloseRef.current?.();
|
|
1055
|
+
|
|
1056
|
+
popup.on("close", onCloseProp);
|
|
1057
|
+
|
|
1058
|
+
popup.setDOMContent(container);
|
|
1059
|
+
popup.addTo(map);
|
|
1060
|
+
|
|
1061
|
+
return () => {
|
|
1062
|
+
popup.off("close", onCloseProp);
|
|
1063
|
+
if (popup.isOpen()) {
|
|
1064
|
+
popup.remove();
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1068
|
+
}, [map]);
|
|
1069
|
+
|
|
1070
|
+
// Sync popup position and options when they change.
|
|
1071
|
+
useEffect(() => {
|
|
1072
|
+
const current = popup.getLngLat();
|
|
1073
|
+
if (!current || current.lng !== longitude || current.lat !== latitude) {
|
|
1074
|
+
popup.setLngLat([longitude, latitude]);
|
|
1075
|
+
}
|
|
1076
|
+
popup.setOffset(offset ?? 16);
|
|
1077
|
+
if (maxWidth) {
|
|
1078
|
+
popup.setMaxWidth(maxWidth);
|
|
1079
|
+
}
|
|
1080
|
+
}, [popup, longitude, latitude, offset, maxWidth]);
|
|
1081
|
+
|
|
1082
|
+
const handleClose = () => {
|
|
1083
|
+
popup.remove();
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
return createPortal(
|
|
1087
|
+
<div
|
|
1088
|
+
className={cn(
|
|
1089
|
+
"bg-popover text-popover-foreground relative max-w-62 rounded-md border p-3 shadow-md",
|
|
1090
|
+
"animate-in fade-in-0 zoom-in-95 duration-200 ease-out",
|
|
1091
|
+
className,
|
|
1092
|
+
)}
|
|
1093
|
+
>
|
|
1094
|
+
{closeButton && <PopupCloseButton onClick={handleClose} />}
|
|
1095
|
+
{children}
|
|
1096
|
+
</div>,
|
|
1097
|
+
container,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
type MapRouteProps = {
|
|
1102
|
+
/** Optional unique identifier for the route layer */
|
|
1103
|
+
id?: string;
|
|
1104
|
+
/** Array of [longitude, latitude] coordinate pairs defining the route */
|
|
1105
|
+
coordinates: [number, number][];
|
|
1106
|
+
/** Line color as CSS color value (default: "#4285F4") */
|
|
1107
|
+
color?: string;
|
|
1108
|
+
/** Line width in pixels (default: 3) */
|
|
1109
|
+
width?: number;
|
|
1110
|
+
/** Line opacity from 0 to 1 (default: 0.8) */
|
|
1111
|
+
opacity?: number;
|
|
1112
|
+
/** Dash pattern [dash length, gap length] for dashed lines */
|
|
1113
|
+
dashArray?: [number, number];
|
|
1114
|
+
/** Callback when the route line is clicked */
|
|
1115
|
+
onClick?: () => void;
|
|
1116
|
+
/** Callback when mouse enters the route line */
|
|
1117
|
+
onMouseEnter?: () => void;
|
|
1118
|
+
/** Callback when mouse leaves the route line */
|
|
1119
|
+
onMouseLeave?: () => void;
|
|
1120
|
+
/** Whether the route is interactive - shows pointer cursor on hover (default: true) */
|
|
1121
|
+
interactive?: boolean;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
function MapRoute({
|
|
1125
|
+
id: propId,
|
|
1126
|
+
coordinates,
|
|
1127
|
+
color = "#4285F4",
|
|
1128
|
+
width = 3,
|
|
1129
|
+
opacity = 0.8,
|
|
1130
|
+
dashArray,
|
|
1131
|
+
onClick,
|
|
1132
|
+
onMouseEnter,
|
|
1133
|
+
onMouseLeave,
|
|
1134
|
+
interactive = true,
|
|
1135
|
+
}: MapRouteProps) {
|
|
1136
|
+
const { map, isLoaded } = useMap();
|
|
1137
|
+
const autoId = useId();
|
|
1138
|
+
const id = propId ?? autoId;
|
|
1139
|
+
const sourceId = `route-source-${id}`;
|
|
1140
|
+
const layerId = `route-layer-${id}`;
|
|
1141
|
+
|
|
1142
|
+
// Add source and layer on mount
|
|
1143
|
+
useEffect(() => {
|
|
1144
|
+
if (!isLoaded || !map) return;
|
|
1145
|
+
|
|
1146
|
+
map.addSource(sourceId, {
|
|
1147
|
+
type: "geojson",
|
|
1148
|
+
data: {
|
|
1149
|
+
type: "Feature",
|
|
1150
|
+
properties: {},
|
|
1151
|
+
geometry: { type: "LineString", coordinates: [] },
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
map.addLayer({
|
|
1156
|
+
id: layerId,
|
|
1157
|
+
type: "line",
|
|
1158
|
+
source: sourceId,
|
|
1159
|
+
layout: { "line-join": "round", "line-cap": "round" },
|
|
1160
|
+
paint: {
|
|
1161
|
+
"line-color": color,
|
|
1162
|
+
"line-width": width,
|
|
1163
|
+
"line-opacity": opacity,
|
|
1164
|
+
...(dashArray && { "line-dasharray": dashArray }),
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
return () => {
|
|
1169
|
+
try {
|
|
1170
|
+
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
1171
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1172
|
+
} catch {
|
|
1173
|
+
// ignore
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1177
|
+
}, [isLoaded, map]);
|
|
1178
|
+
|
|
1179
|
+
// When coordinates change, update the source data
|
|
1180
|
+
useEffect(() => {
|
|
1181
|
+
if (!isLoaded || !map || coordinates.length < 2) return;
|
|
1182
|
+
|
|
1183
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
1184
|
+
if (source) {
|
|
1185
|
+
source.setData({
|
|
1186
|
+
type: "Feature",
|
|
1187
|
+
properties: {},
|
|
1188
|
+
geometry: { type: "LineString", coordinates },
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}, [isLoaded, map, coordinates, sourceId]);
|
|
1192
|
+
|
|
1193
|
+
useEffect(() => {
|
|
1194
|
+
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
1195
|
+
|
|
1196
|
+
map.setPaintProperty(layerId, "line-color", color);
|
|
1197
|
+
map.setPaintProperty(layerId, "line-width", width);
|
|
1198
|
+
map.setPaintProperty(layerId, "line-opacity", opacity);
|
|
1199
|
+
map.setPaintProperty(layerId, "line-dasharray", dashArray);
|
|
1200
|
+
}, [isLoaded, map, layerId, color, width, opacity, dashArray]);
|
|
1201
|
+
|
|
1202
|
+
// Handle click and hover events
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
if (!isLoaded || !map || !interactive) return;
|
|
1205
|
+
|
|
1206
|
+
const handleClick = () => {
|
|
1207
|
+
onClick?.();
|
|
1208
|
+
};
|
|
1209
|
+
const handleMouseEnter = () => {
|
|
1210
|
+
map.getCanvas().style.cursor = "pointer";
|
|
1211
|
+
onMouseEnter?.();
|
|
1212
|
+
};
|
|
1213
|
+
const handleMouseLeave = () => {
|
|
1214
|
+
map.getCanvas().style.cursor = "";
|
|
1215
|
+
onMouseLeave?.();
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
map.on("click", layerId, handleClick);
|
|
1219
|
+
map.on("mouseenter", layerId, handleMouseEnter);
|
|
1220
|
+
map.on("mouseleave", layerId, handleMouseLeave);
|
|
1221
|
+
|
|
1222
|
+
return () => {
|
|
1223
|
+
map.off("click", layerId, handleClick);
|
|
1224
|
+
map.off("mouseenter", layerId, handleMouseEnter);
|
|
1225
|
+
map.off("mouseleave", layerId, handleMouseLeave);
|
|
1226
|
+
};
|
|
1227
|
+
}, [
|
|
1228
|
+
isLoaded,
|
|
1229
|
+
map,
|
|
1230
|
+
layerId,
|
|
1231
|
+
onClick,
|
|
1232
|
+
onMouseEnter,
|
|
1233
|
+
onMouseLeave,
|
|
1234
|
+
interactive,
|
|
1235
|
+
]);
|
|
1236
|
+
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
type MapGeoJSONData<
|
|
1241
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1242
|
+
> =
|
|
1243
|
+
| GeoJSON.FeatureCollection<GeoJSON.Geometry, P>
|
|
1244
|
+
| GeoJSON.Feature<GeoJSON.Geometry, P>
|
|
1245
|
+
| GeoJSON.Geometry
|
|
1246
|
+
| string;
|
|
1247
|
+
|
|
1248
|
+
type MapFillPaint = NonNullable<MapLibreGL.FillLayerSpecification["paint"]>;
|
|
1249
|
+
type MapLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
|
1250
|
+
|
|
1251
|
+
/** A rendered feature with strongly-typed `properties`. */
|
|
1252
|
+
type MapGeoJSONFeature<
|
|
1253
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1254
|
+
> = Omit<MapLibreGL.MapGeoJSONFeature, "properties"> & { properties: P };
|
|
1255
|
+
|
|
1256
|
+
/** Event payload passed to MapGeoJSON interaction callbacks. */
|
|
1257
|
+
type MapGeoJSONEvent<
|
|
1258
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1259
|
+
> = {
|
|
1260
|
+
/** The feature under the cursor, with its typed GeoJSON properties. */
|
|
1261
|
+
feature: MapGeoJSONFeature<P>;
|
|
1262
|
+
/** Longitude of the cursor at the time of the event. */
|
|
1263
|
+
longitude: number;
|
|
1264
|
+
/** Latitude of the cursor at the time of the event. */
|
|
1265
|
+
latitude: number;
|
|
1266
|
+
/** The underlying MapLibre mouse event for advanced use cases. */
|
|
1267
|
+
originalEvent: MapLibreGL.MapLayerMouseEvent;
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
type MapGeoJSONProps<
|
|
1271
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1272
|
+
> = {
|
|
1273
|
+
/** GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. */
|
|
1274
|
+
data: MapGeoJSONData<P>;
|
|
1275
|
+
/** Optional unique identifier prefix for the source/layers. Auto-generated if not provided. */
|
|
1276
|
+
id?: string;
|
|
1277
|
+
/**
|
|
1278
|
+
* Feature property to promote to the feature `id`. Required for hover
|
|
1279
|
+
* feature-state (`fillHoverPaint`) and stable `onHover`/`onClick` payloads.
|
|
1280
|
+
*/
|
|
1281
|
+
promoteId?: string;
|
|
1282
|
+
/**
|
|
1283
|
+
* Paint for the polygon fill layer. Merged on top of a theme-aware monochrome
|
|
1284
|
+
* surface tone (`fill-color`). Pass `false` to omit the fill layer entirely
|
|
1285
|
+
* (e.g. outlines only).
|
|
1286
|
+
*/
|
|
1287
|
+
fillPaint?: MapFillPaint | false;
|
|
1288
|
+
/**
|
|
1289
|
+
* Paint for the outline layer. Merged on top of a hairline default
|
|
1290
|
+
* (`line-color` = a near-surface neutral, `line-width` = 0.5) for thin
|
|
1291
|
+
* separators. Override `line-color` if your container differs, or pass
|
|
1292
|
+
* `false` to omit the layer.
|
|
1293
|
+
*/
|
|
1294
|
+
linePaint?: MapLinePaint | false;
|
|
1295
|
+
/**
|
|
1296
|
+
* Paint merged onto the fill layer for the feature under the cursor, applied
|
|
1297
|
+
* as a `case` expression keyed on hover feature-state. Requires `promoteId`.
|
|
1298
|
+
*/
|
|
1299
|
+
fillHoverPaint?: MapFillPaint;
|
|
1300
|
+
/** Callback when a feature is clicked. */
|
|
1301
|
+
onClick?: (e: MapGeoJSONEvent<P>) => void;
|
|
1302
|
+
/** Callback fired when the hovered feature changes; `null` when the cursor leaves. */
|
|
1303
|
+
onHover?: (e: MapGeoJSONEvent<P> | null) => void;
|
|
1304
|
+
/** Whether features respond to mouse events (default: false). */
|
|
1305
|
+
interactive?: boolean;
|
|
1306
|
+
/** Optional MapLibre layer id to insert the layers before (z-order control). */
|
|
1307
|
+
beforeId?: string;
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
// Monochrome defaults: a neutral-gray fill (hex of the grayscale chart tokens)
|
|
1311
|
+
// with a fixed near-surface line for thin separators. Colors are hardcoded (not
|
|
1312
|
+
// theme tokens), tuned for a typical light/dark surface. Override via
|
|
1313
|
+
// `fillPaint` / `linePaint`.
|
|
1314
|
+
const GEOJSON_DEFAULT_COLORS = {
|
|
1315
|
+
light: { fill: "#d4d4d4", line: "#ffffff" },
|
|
1316
|
+
dark: { fill: "#404040", line: "#171717" },
|
|
1317
|
+
} satisfies Record<Theme, { fill: string; line: string }>;
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Renders arbitrary GeoJSON as fill + outline layers on the map. Composes like
|
|
1321
|
+
* `MapRoute` / `MapArc` — drop it inside `<Map>` (typically with `blank`) for
|
|
1322
|
+
* choropleths and region/data maps. For full control over expressions and
|
|
1323
|
+
* multiple layers, manage layers directly via `useMap()` instead.
|
|
1324
|
+
*/
|
|
1325
|
+
function MapGeoJSON<
|
|
1326
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1327
|
+
>({
|
|
1328
|
+
data,
|
|
1329
|
+
id: propId,
|
|
1330
|
+
promoteId,
|
|
1331
|
+
fillPaint,
|
|
1332
|
+
linePaint,
|
|
1333
|
+
fillHoverPaint,
|
|
1334
|
+
onClick,
|
|
1335
|
+
onHover,
|
|
1336
|
+
interactive = false,
|
|
1337
|
+
beforeId,
|
|
1338
|
+
}: MapGeoJSONProps<P>) {
|
|
1339
|
+
const { map, isLoaded, resolvedTheme } = useMap();
|
|
1340
|
+
const autoId = useId();
|
|
1341
|
+
const id = propId ?? autoId;
|
|
1342
|
+
const sourceId = `geojson-source-${id}`;
|
|
1343
|
+
const fillLayerId = `geojson-fill-${id}`;
|
|
1344
|
+
const lineLayerId = `geojson-line-${id}`;
|
|
1345
|
+
|
|
1346
|
+
const defaults = GEOJSON_DEFAULT_COLORS[resolvedTheme];
|
|
1347
|
+
|
|
1348
|
+
const showFill = fillPaint !== false;
|
|
1349
|
+
const showLine = linePaint !== false;
|
|
1350
|
+
|
|
1351
|
+
const mergedFillPaint = useMemo(
|
|
1352
|
+
() =>
|
|
1353
|
+
mergeHoverPaint(
|
|
1354
|
+
{ "fill-color": defaults.fill, ...(fillPaint || {}) },
|
|
1355
|
+
fillHoverPaint,
|
|
1356
|
+
),
|
|
1357
|
+
[defaults.fill, fillPaint, fillHoverPaint],
|
|
1358
|
+
);
|
|
1359
|
+
const mergedLinePaint = useMemo(
|
|
1360
|
+
() => ({
|
|
1361
|
+
"line-color": defaults.line,
|
|
1362
|
+
"line-width": 0.5,
|
|
1363
|
+
...(linePaint || {}),
|
|
1364
|
+
}),
|
|
1365
|
+
[defaults.line, linePaint],
|
|
1366
|
+
);
|
|
1367
|
+
const latestRef = useRef({ onClick, onHover });
|
|
1368
|
+
latestRef.current = { onClick, onHover };
|
|
1369
|
+
|
|
1370
|
+
// Add source on mount.
|
|
1371
|
+
useEffect(() => {
|
|
1372
|
+
if (!isLoaded || !map) return;
|
|
1373
|
+
|
|
1374
|
+
map.addSource(sourceId, {
|
|
1375
|
+
type: "geojson",
|
|
1376
|
+
data,
|
|
1377
|
+
...(promoteId ? { promoteId } : {}),
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
return () => {
|
|
1381
|
+
try {
|
|
1382
|
+
if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId);
|
|
1383
|
+
if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId);
|
|
1384
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1385
|
+
} catch {
|
|
1386
|
+
// style may be mid-reload
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1390
|
+
}, [isLoaded, map]);
|
|
1391
|
+
|
|
1392
|
+
// Sync data when it changes.
|
|
1393
|
+
useEffect(() => {
|
|
1394
|
+
if (!isLoaded || !map) return;
|
|
1395
|
+
const source = map.getSource(sourceId) as
|
|
1396
|
+
| MapLibreGL.GeoJSONSource
|
|
1397
|
+
| undefined;
|
|
1398
|
+
source?.setData(data as never);
|
|
1399
|
+
}, [isLoaded, map, data, sourceId]);
|
|
1400
|
+
|
|
1401
|
+
// Sync layers and paint when visibility or styling changes.
|
|
1402
|
+
useEffect(() => {
|
|
1403
|
+
if (!isLoaded || !map) return;
|
|
1404
|
+
|
|
1405
|
+
const source = map.getSource(sourceId);
|
|
1406
|
+
if (!source) return;
|
|
1407
|
+
|
|
1408
|
+
if (showFill && !map.getLayer(fillLayerId)) {
|
|
1409
|
+
map.addLayer(
|
|
1410
|
+
{
|
|
1411
|
+
id: fillLayerId,
|
|
1412
|
+
type: "fill",
|
|
1413
|
+
source: sourceId,
|
|
1414
|
+
paint: mergedFillPaint,
|
|
1415
|
+
},
|
|
1416
|
+
beforeId,
|
|
1417
|
+
);
|
|
1418
|
+
} else if (!showFill && map.getLayer(fillLayerId)) {
|
|
1419
|
+
map.removeLayer(fillLayerId);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (showLine && !map.getLayer(lineLayerId)) {
|
|
1423
|
+
map.addLayer(
|
|
1424
|
+
{
|
|
1425
|
+
id: lineLayerId,
|
|
1426
|
+
type: "line",
|
|
1427
|
+
source: sourceId,
|
|
1428
|
+
paint: mergedLinePaint,
|
|
1429
|
+
},
|
|
1430
|
+
beforeId,
|
|
1431
|
+
);
|
|
1432
|
+
} else if (!showLine && map.getLayer(lineLayerId)) {
|
|
1433
|
+
map.removeLayer(lineLayerId);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
if (showFill && map.getLayer(fillLayerId)) {
|
|
1437
|
+
for (const [key, value] of Object.entries(mergedFillPaint)) {
|
|
1438
|
+
map.setPaintProperty(
|
|
1439
|
+
fillLayerId,
|
|
1440
|
+
key as keyof MapFillPaint,
|
|
1441
|
+
value as never,
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (showLine && map.getLayer(lineLayerId)) {
|
|
1446
|
+
for (const [key, value] of Object.entries(mergedLinePaint)) {
|
|
1447
|
+
map.setPaintProperty(
|
|
1448
|
+
lineLayerId,
|
|
1449
|
+
key as keyof MapLinePaint,
|
|
1450
|
+
value as never,
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}, [
|
|
1455
|
+
isLoaded,
|
|
1456
|
+
map,
|
|
1457
|
+
sourceId,
|
|
1458
|
+
fillLayerId,
|
|
1459
|
+
lineLayerId,
|
|
1460
|
+
showFill,
|
|
1461
|
+
showLine,
|
|
1462
|
+
mergedFillPaint,
|
|
1463
|
+
mergedLinePaint,
|
|
1464
|
+
beforeId,
|
|
1465
|
+
]);
|
|
1466
|
+
|
|
1467
|
+
// Interaction handlers (bound to the fill layer).
|
|
1468
|
+
useEffect(() => {
|
|
1469
|
+
if (!isLoaded || !map || !interactive || !showFill) return;
|
|
1470
|
+
|
|
1471
|
+
let hoveredId: string | number | null = null;
|
|
1472
|
+
|
|
1473
|
+
const setHover = (next: string | number | null) => {
|
|
1474
|
+
if (next === hoveredId) return;
|
|
1475
|
+
const sourceExists = !!map.getSource(sourceId);
|
|
1476
|
+
if (hoveredId != null && sourceExists) {
|
|
1477
|
+
map.setFeatureState(
|
|
1478
|
+
{ source: sourceId, id: hoveredId },
|
|
1479
|
+
{ hover: false },
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
hoveredId = next;
|
|
1483
|
+
if (next != null && sourceExists) {
|
|
1484
|
+
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
1489
|
+
const feature = e.features?.[0];
|
|
1490
|
+
if (!feature) return;
|
|
1491
|
+
map.getCanvas().style.cursor = "pointer";
|
|
1492
|
+
|
|
1493
|
+
const featureId = feature.id;
|
|
1494
|
+
if (featureId === hoveredId) return;
|
|
1495
|
+
setHover(featureId ?? null);
|
|
1496
|
+
latestRef.current.onHover?.({
|
|
1497
|
+
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
1498
|
+
longitude: e.lngLat.lng,
|
|
1499
|
+
latitude: e.lngLat.lat,
|
|
1500
|
+
originalEvent: e,
|
|
1501
|
+
});
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
const handleMouseLeave = () => {
|
|
1505
|
+
setHover(null);
|
|
1506
|
+
map.getCanvas().style.cursor = "";
|
|
1507
|
+
latestRef.current.onHover?.(null);
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
1511
|
+
const feature = e.features?.[0];
|
|
1512
|
+
if (!feature) return;
|
|
1513
|
+
latestRef.current.onClick?.({
|
|
1514
|
+
feature: feature as unknown as MapGeoJSONFeature<P>,
|
|
1515
|
+
longitude: e.lngLat.lng,
|
|
1516
|
+
latitude: e.lngLat.lat,
|
|
1517
|
+
originalEvent: e,
|
|
1518
|
+
});
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
map.on("mousemove", fillLayerId, handleMouseMove);
|
|
1522
|
+
map.on("mouseleave", fillLayerId, handleMouseLeave);
|
|
1523
|
+
map.on("click", fillLayerId, handleClick);
|
|
1524
|
+
|
|
1525
|
+
return () => {
|
|
1526
|
+
map.off("mousemove", fillLayerId, handleMouseMove);
|
|
1527
|
+
map.off("mouseleave", fillLayerId, handleMouseLeave);
|
|
1528
|
+
map.off("click", fillLayerId, handleClick);
|
|
1529
|
+
setHover(null);
|
|
1530
|
+
map.getCanvas().style.cursor = "";
|
|
1531
|
+
};
|
|
1532
|
+
}, [isLoaded, map, fillLayerId, sourceId, interactive, showFill]);
|
|
1533
|
+
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** A single arc to render inside <MapArc data={...}>. */
|
|
1538
|
+
type MapArcDatum = {
|
|
1539
|
+
/** Unique identifier for this arc. Required for hover state tracking and event payloads. */
|
|
1540
|
+
id: string | number;
|
|
1541
|
+
/** Start coordinate as [longitude, latitude]. */
|
|
1542
|
+
from: [number, number];
|
|
1543
|
+
/** End coordinate as [longitude, latitude]. */
|
|
1544
|
+
to: [number, number];
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
/** Event payload passed to MapArc interaction callbacks. */
|
|
1548
|
+
type MapArcEvent<T extends MapArcDatum = MapArcDatum> = {
|
|
1549
|
+
/** The arc datum that was hovered or clicked. */
|
|
1550
|
+
arc: T;
|
|
1551
|
+
/** Longitude of the cursor at the time of the event. */
|
|
1552
|
+
longitude: number;
|
|
1553
|
+
/** Latitude of the cursor at the time of the event. */
|
|
1554
|
+
latitude: number;
|
|
1555
|
+
/** The underlying MapLibre mouse event for advanced use cases. */
|
|
1556
|
+
originalEvent: MapLibreGL.MapMouseEvent;
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
type MapArcLinePaint = NonNullable<MapLibreGL.LineLayerSpecification["paint"]>;
|
|
1560
|
+
type MapArcLineLayout = NonNullable<
|
|
1561
|
+
MapLibreGL.LineLayerSpecification["layout"]
|
|
1562
|
+
>;
|
|
1563
|
+
|
|
1564
|
+
type MapArcProps<T extends MapArcDatum = MapArcDatum> = {
|
|
1565
|
+
/** Array of arcs to render. Each arc must have a unique `id`. */
|
|
1566
|
+
data: T[];
|
|
1567
|
+
/** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */
|
|
1568
|
+
id?: string;
|
|
1569
|
+
/**
|
|
1570
|
+
* How far each arc bows away from a straight line. `0` renders straight
|
|
1571
|
+
* lines; higher values bend further. Negative values bend to the opposite
|
|
1572
|
+
* side. Arcs are computed as a quadratic Bézier in lng/lat space; the
|
|
1573
|
+
* destination longitude is unwrapped relative to the origin so that arcs
|
|
1574
|
+
* cross the antimeridian via the shorter great-circle direction. (default: 0.2)
|
|
1575
|
+
*/
|
|
1576
|
+
curvature?: number;
|
|
1577
|
+
/** Number of samples used to render each curve. Higher = smoother. (default: 64) */
|
|
1578
|
+
samples?: number;
|
|
1579
|
+
/**
|
|
1580
|
+
* MapLibre paint properties for the arc layer. Merged on top of sensible
|
|
1581
|
+
* defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`).
|
|
1582
|
+
* Any value can be a MapLibre expression for per-feature styling, every
|
|
1583
|
+
* field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`.
|
|
1584
|
+
*/
|
|
1585
|
+
paint?: MapArcLinePaint;
|
|
1586
|
+
/** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */
|
|
1587
|
+
layout?: MapArcLineLayout;
|
|
1588
|
+
/**
|
|
1589
|
+
* Paint properties applied to the arc currently under the cursor. Each key
|
|
1590
|
+
* is merged into `paint` as a `case` expression keyed on per-feature hover
|
|
1591
|
+
* state, so only the hovered arc changes appearance.
|
|
1592
|
+
*/
|
|
1593
|
+
hoverPaint?: MapArcLinePaint;
|
|
1594
|
+
/** Callback when an arc is clicked. */
|
|
1595
|
+
onClick?: (e: MapArcEvent<T>) => void;
|
|
1596
|
+
/**
|
|
1597
|
+
* Callback fired when the hovered arc changes. Receives the cursor's
|
|
1598
|
+
* lng/lat at the moment of entry, and `null` when the cursor leaves the
|
|
1599
|
+
* last hovered arc.
|
|
1600
|
+
*/
|
|
1601
|
+
onHover?: (e: MapArcEvent<T> | null) => void;
|
|
1602
|
+
/** Whether arcs respond to mouse events (default: true). */
|
|
1603
|
+
interactive?: boolean;
|
|
1604
|
+
/** Optional MapLibre layer id to insert the arc layers before (z-order control). */
|
|
1605
|
+
beforeId?: string;
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
const DEFAULT_ARC_CURVATURE = 0.2;
|
|
1609
|
+
const DEFAULT_ARC_SAMPLES = 64;
|
|
1610
|
+
const ARC_HIT_MIN_WIDTH = 12;
|
|
1611
|
+
const ARC_HIT_PADDING = 6;
|
|
1612
|
+
|
|
1613
|
+
const DEFAULT_ARC_PAINT: MapArcLinePaint = {
|
|
1614
|
+
"line-color": "#4285F4",
|
|
1615
|
+
"line-width": 2,
|
|
1616
|
+
"line-opacity": 0.85,
|
|
1617
|
+
};
|
|
1618
|
+
|
|
1619
|
+
const DEFAULT_ARC_LAYOUT: MapArcLineLayout = {
|
|
1620
|
+
"line-join": "round",
|
|
1621
|
+
"line-cap": "round",
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
function buildArcCoordinates(
|
|
1625
|
+
from: [number, number],
|
|
1626
|
+
to: [number, number],
|
|
1627
|
+
curvature: number,
|
|
1628
|
+
samples: number,
|
|
1629
|
+
): [number, number][] {
|
|
1630
|
+
const [x0, y0] = from;
|
|
1631
|
+
const [xTo, y2] = to;
|
|
1632
|
+
// Unwrap the destination longitude so |dx| <= 180. This makes arcs that
|
|
1633
|
+
// straddle the antimeridian (e.g. Tokyo -> San Francisco) bow the short way
|
|
1634
|
+
// across the Pacific instead of the long way around the globe. Resulting
|
|
1635
|
+
// longitudes may fall outside [-180, 180]; MapLibre renders them correctly
|
|
1636
|
+
// on the globe projection, and on mercator when world copies are enabled.
|
|
1637
|
+
const rawDx = xTo - x0;
|
|
1638
|
+
const x2 = rawDx > 180 ? xTo - 360 : rawDx < -180 ? xTo + 360 : xTo;
|
|
1639
|
+
const dx = x2 - x0;
|
|
1640
|
+
const dy = y2 - y0;
|
|
1641
|
+
const distance = Math.hypot(dx, dy);
|
|
1642
|
+
|
|
1643
|
+
if (distance === 0 || curvature === 0) return [from, [x2, y2]];
|
|
1644
|
+
|
|
1645
|
+
const mx = (x0 + x2) / 2;
|
|
1646
|
+
const my = (y0 + y2) / 2;
|
|
1647
|
+
const nx = -dy / distance;
|
|
1648
|
+
const ny = dx / distance;
|
|
1649
|
+
const offset = distance * curvature;
|
|
1650
|
+
const cx = mx + nx * offset;
|
|
1651
|
+
const cy = my + ny * offset;
|
|
1652
|
+
|
|
1653
|
+
const points: [number, number][] = [];
|
|
1654
|
+
const segments = Math.max(2, Math.floor(samples));
|
|
1655
|
+
for (let i = 0; i <= segments; i += 1) {
|
|
1656
|
+
const t = i / segments;
|
|
1657
|
+
const inv = 1 - t;
|
|
1658
|
+
const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2;
|
|
1659
|
+
const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2;
|
|
1660
|
+
points.push([x, y]);
|
|
1661
|
+
}
|
|
1662
|
+
return points;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function MapArc<T extends MapArcDatum = MapArcDatum>({
|
|
1666
|
+
data,
|
|
1667
|
+
id: propId,
|
|
1668
|
+
curvature = DEFAULT_ARC_CURVATURE,
|
|
1669
|
+
samples = DEFAULT_ARC_SAMPLES,
|
|
1670
|
+
paint,
|
|
1671
|
+
layout,
|
|
1672
|
+
hoverPaint,
|
|
1673
|
+
onClick,
|
|
1674
|
+
onHover,
|
|
1675
|
+
interactive = true,
|
|
1676
|
+
beforeId,
|
|
1677
|
+
}: MapArcProps<T>) {
|
|
1678
|
+
const { map, isLoaded } = useMap();
|
|
1679
|
+
const autoId = useId();
|
|
1680
|
+
const id = propId ?? autoId;
|
|
1681
|
+
const sourceId = `arc-source-${id}`;
|
|
1682
|
+
const layerId = `arc-layer-${id}`;
|
|
1683
|
+
const hitLayerId = `arc-hit-layer-${id}`;
|
|
1684
|
+
|
|
1685
|
+
const mergedPaint = useMemo(
|
|
1686
|
+
() => mergeHoverPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint),
|
|
1687
|
+
[paint, hoverPaint],
|
|
1688
|
+
);
|
|
1689
|
+
const mergedLayout = useMemo(
|
|
1690
|
+
() => ({ ...DEFAULT_ARC_LAYOUT, ...layout }),
|
|
1691
|
+
[layout],
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
const hitWidth = useMemo(() => {
|
|
1695
|
+
const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"];
|
|
1696
|
+
const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH;
|
|
1697
|
+
return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH);
|
|
1698
|
+
}, [paint]);
|
|
1699
|
+
|
|
1700
|
+
const geoJSON = useMemo<GeoJSON.FeatureCollection<GeoJSON.LineString>>(
|
|
1701
|
+
() => ({
|
|
1702
|
+
type: "FeatureCollection",
|
|
1703
|
+
features: data.map((arc) => {
|
|
1704
|
+
const { from, to, ...properties } = arc;
|
|
1705
|
+
return {
|
|
1706
|
+
type: "Feature",
|
|
1707
|
+
properties,
|
|
1708
|
+
geometry: {
|
|
1709
|
+
type: "LineString",
|
|
1710
|
+
coordinates: buildArcCoordinates(from, to, curvature, samples),
|
|
1711
|
+
},
|
|
1712
|
+
};
|
|
1713
|
+
}),
|
|
1714
|
+
}),
|
|
1715
|
+
[data, curvature, samples],
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
const latestRef = useRef({ data, onClick, onHover });
|
|
1719
|
+
latestRef.current = { data, onClick, onHover };
|
|
1720
|
+
|
|
1721
|
+
// Add source and layers on mount.
|
|
1722
|
+
useEffect(() => {
|
|
1723
|
+
if (!isLoaded || !map) return;
|
|
1724
|
+
|
|
1725
|
+
map.addSource(sourceId, {
|
|
1726
|
+
type: "geojson",
|
|
1727
|
+
data: geoJSON,
|
|
1728
|
+
promoteId: "id",
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
map.addLayer(
|
|
1732
|
+
{
|
|
1733
|
+
id: hitLayerId,
|
|
1734
|
+
type: "line",
|
|
1735
|
+
source: sourceId,
|
|
1736
|
+
layout: DEFAULT_ARC_LAYOUT,
|
|
1737
|
+
paint: {
|
|
1738
|
+
"line-color": "rgba(0, 0, 0, 0)",
|
|
1739
|
+
"line-width": hitWidth,
|
|
1740
|
+
"line-opacity": 1,
|
|
1741
|
+
},
|
|
1742
|
+
},
|
|
1743
|
+
beforeId,
|
|
1744
|
+
);
|
|
1745
|
+
|
|
1746
|
+
map.addLayer(
|
|
1747
|
+
{
|
|
1748
|
+
id: layerId,
|
|
1749
|
+
type: "line",
|
|
1750
|
+
source: sourceId,
|
|
1751
|
+
layout: mergedLayout,
|
|
1752
|
+
paint: mergedPaint,
|
|
1753
|
+
},
|
|
1754
|
+
beforeId,
|
|
1755
|
+
);
|
|
1756
|
+
|
|
1757
|
+
return () => {
|
|
1758
|
+
try {
|
|
1759
|
+
if (map.getLayer(layerId)) map.removeLayer(layerId);
|
|
1760
|
+
if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId);
|
|
1761
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
1762
|
+
} catch {
|
|
1763
|
+
// ignore
|
|
1764
|
+
}
|
|
1765
|
+
};
|
|
1766
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1767
|
+
}, [isLoaded, map]);
|
|
1768
|
+
|
|
1769
|
+
// Sync features when data / curvature / samples change.
|
|
1770
|
+
useEffect(() => {
|
|
1771
|
+
if (!isLoaded || !map) return;
|
|
1772
|
+
const source = map.getSource(sourceId) as
|
|
1773
|
+
| MapLibreGL.GeoJSONSource
|
|
1774
|
+
| undefined;
|
|
1775
|
+
source?.setData(geoJSON);
|
|
1776
|
+
}, [isLoaded, map, geoJSON, sourceId]);
|
|
1777
|
+
|
|
1778
|
+
// Sync paint/layout when they change.
|
|
1779
|
+
useEffect(() => {
|
|
1780
|
+
if (!isLoaded || !map || !map.getLayer(layerId)) return;
|
|
1781
|
+
for (const [key, value] of Object.entries(mergedPaint)) {
|
|
1782
|
+
map.setPaintProperty(
|
|
1783
|
+
layerId,
|
|
1784
|
+
key as keyof MapArcLinePaint,
|
|
1785
|
+
value as never,
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
for (const [key, value] of Object.entries(mergedLayout)) {
|
|
1789
|
+
map.setLayoutProperty(
|
|
1790
|
+
layerId,
|
|
1791
|
+
key as keyof MapArcLineLayout,
|
|
1792
|
+
value as never,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
if (map.getLayer(hitLayerId)) {
|
|
1796
|
+
map.setPaintProperty(hitLayerId, "line-width", hitWidth);
|
|
1797
|
+
}
|
|
1798
|
+
}, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]);
|
|
1799
|
+
|
|
1800
|
+
// Interaction handlers
|
|
1801
|
+
useEffect(() => {
|
|
1802
|
+
if (!isLoaded || !map || !interactive) return;
|
|
1803
|
+
|
|
1804
|
+
let hoveredId: string | number | null = null;
|
|
1805
|
+
|
|
1806
|
+
const setHover = (next: string | number | null) => {
|
|
1807
|
+
if (next === hoveredId) return;
|
|
1808
|
+
const sourceExists = !!map.getSource(sourceId);
|
|
1809
|
+
if (hoveredId != null && sourceExists) {
|
|
1810
|
+
map.setFeatureState(
|
|
1811
|
+
{ source: sourceId, id: hoveredId },
|
|
1812
|
+
{ hover: false },
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
hoveredId = next;
|
|
1816
|
+
if (next != null && sourceExists) {
|
|
1817
|
+
map.setFeatureState({ source: sourceId, id: next }, { hover: true });
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
const findArc = (featureId: string | number | undefined) =>
|
|
1822
|
+
featureId == null
|
|
1823
|
+
? undefined
|
|
1824
|
+
: latestRef.current.data.find(
|
|
1825
|
+
(arc) => String(arc.id) === String(featureId),
|
|
1826
|
+
);
|
|
1827
|
+
|
|
1828
|
+
const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
1829
|
+
const featureId = e.features?.[0]?.id as string | number | undefined;
|
|
1830
|
+
if (featureId == null || featureId === hoveredId) return;
|
|
1831
|
+
|
|
1832
|
+
setHover(featureId);
|
|
1833
|
+
map.getCanvas().style.cursor = "pointer";
|
|
1834
|
+
|
|
1835
|
+
const arc = findArc(featureId);
|
|
1836
|
+
if (arc) {
|
|
1837
|
+
latestRef.current.onHover?.({
|
|
1838
|
+
arc: arc as T,
|
|
1839
|
+
longitude: e.lngLat.lng,
|
|
1840
|
+
latitude: e.lngLat.lat,
|
|
1841
|
+
originalEvent: e,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
const handleMouseLeave = () => {
|
|
1847
|
+
setHover(null);
|
|
1848
|
+
map.getCanvas().style.cursor = "";
|
|
1849
|
+
latestRef.current.onHover?.(null);
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => {
|
|
1853
|
+
const arc = findArc(e.features?.[0]?.id as string | number | undefined);
|
|
1854
|
+
if (!arc) return;
|
|
1855
|
+
latestRef.current.onClick?.({
|
|
1856
|
+
arc: arc as T,
|
|
1857
|
+
longitude: e.lngLat.lng,
|
|
1858
|
+
latitude: e.lngLat.lat,
|
|
1859
|
+
originalEvent: e,
|
|
1860
|
+
});
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
map.on("mousemove", hitLayerId, handleMouseMove);
|
|
1864
|
+
map.on("mouseleave", hitLayerId, handleMouseLeave);
|
|
1865
|
+
map.on("click", hitLayerId, handleClick);
|
|
1866
|
+
|
|
1867
|
+
return () => {
|
|
1868
|
+
map.off("mousemove", hitLayerId, handleMouseMove);
|
|
1869
|
+
map.off("mouseleave", hitLayerId, handleMouseLeave);
|
|
1870
|
+
map.off("click", hitLayerId, handleClick);
|
|
1871
|
+
setHover(null);
|
|
1872
|
+
map.getCanvas().style.cursor = "";
|
|
1873
|
+
};
|
|
1874
|
+
}, [isLoaded, map, hitLayerId, sourceId, interactive]);
|
|
1875
|
+
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
type MapClusterLayerProps<
|
|
1880
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1881
|
+
> = {
|
|
1882
|
+
/** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */
|
|
1883
|
+
data: string | GeoJSON.FeatureCollection<GeoJSON.Point, P>;
|
|
1884
|
+
/** Maximum zoom level to cluster points on (default: 14) */
|
|
1885
|
+
clusterMaxZoom?: number;
|
|
1886
|
+
/** Radius of each cluster when clustering points in pixels (default: 50) */
|
|
1887
|
+
clusterRadius?: number;
|
|
1888
|
+
/** Colors for cluster circles: [small, medium, large] based on point count (default: ["#3b82f6", "#1d4ed8", "#1e3a8a"]) */
|
|
1889
|
+
clusterColors?: [string, string, string];
|
|
1890
|
+
/** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
|
|
1891
|
+
clusterThresholds?: [number, number];
|
|
1892
|
+
/** Color for unclustered individual points (default: "#3b82f6") */
|
|
1893
|
+
pointColor?: string;
|
|
1894
|
+
/** Callback when an unclustered point is clicked */
|
|
1895
|
+
onPointClick?: (
|
|
1896
|
+
feature: GeoJSON.Feature<GeoJSON.Point, P>,
|
|
1897
|
+
coordinates: [number, number],
|
|
1898
|
+
) => void;
|
|
1899
|
+
/** Callback when a cluster is clicked. If not provided, zooms into the cluster */
|
|
1900
|
+
onClusterClick?: (
|
|
1901
|
+
clusterId: number,
|
|
1902
|
+
coordinates: [number, number],
|
|
1903
|
+
pointCount: number,
|
|
1904
|
+
) => void;
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
const DEFAULT_CLUSTER_COLORS: [string, string, string] = [
|
|
1908
|
+
"#3b82f6",
|
|
1909
|
+
"#1d4ed8",
|
|
1910
|
+
"#1e3a8a",
|
|
1911
|
+
];
|
|
1912
|
+
const DEFAULT_CLUSTER_THRESHOLDS: [number, number] = [100, 750];
|
|
1913
|
+
|
|
1914
|
+
function MapClusterLayer<
|
|
1915
|
+
P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties,
|
|
1916
|
+
>({
|
|
1917
|
+
data,
|
|
1918
|
+
clusterMaxZoom = 14,
|
|
1919
|
+
clusterRadius = 50,
|
|
1920
|
+
clusterColors = DEFAULT_CLUSTER_COLORS,
|
|
1921
|
+
clusterThresholds = DEFAULT_CLUSTER_THRESHOLDS,
|
|
1922
|
+
pointColor = "#3b82f6",
|
|
1923
|
+
onPointClick,
|
|
1924
|
+
onClusterClick,
|
|
1925
|
+
}: MapClusterLayerProps<P>) {
|
|
1926
|
+
const { map, isLoaded } = useMap();
|
|
1927
|
+
const id = useId();
|
|
1928
|
+
const sourceId = `cluster-source-${id}`;
|
|
1929
|
+
const clusterLayerId = `clusters-${id}`;
|
|
1930
|
+
const clusterCountLayerId = `cluster-count-${id}`;
|
|
1931
|
+
const unclusteredLayerId = `unclustered-point-${id}`;
|
|
1932
|
+
|
|
1933
|
+
const stylePropsRef = useRef({
|
|
1934
|
+
clusterColors,
|
|
1935
|
+
clusterThresholds,
|
|
1936
|
+
pointColor,
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
// Add source and layers on mount
|
|
1940
|
+
useEffect(() => {
|
|
1941
|
+
if (!isLoaded || !map) return;
|
|
1942
|
+
|
|
1943
|
+
// Add clustered GeoJSON source
|
|
1944
|
+
map.addSource(sourceId, {
|
|
1945
|
+
type: "geojson",
|
|
1946
|
+
data,
|
|
1947
|
+
cluster: true,
|
|
1948
|
+
clusterMaxZoom,
|
|
1949
|
+
clusterRadius,
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
// Add cluster circles layer
|
|
1953
|
+
map.addLayer({
|
|
1954
|
+
id: clusterLayerId,
|
|
1955
|
+
type: "circle",
|
|
1956
|
+
source: sourceId,
|
|
1957
|
+
filter: ["has", "point_count"],
|
|
1958
|
+
paint: {
|
|
1959
|
+
"circle-color": [
|
|
1960
|
+
"step",
|
|
1961
|
+
["get", "point_count"],
|
|
1962
|
+
clusterColors[0],
|
|
1963
|
+
clusterThresholds[0],
|
|
1964
|
+
clusterColors[1],
|
|
1965
|
+
clusterThresholds[1],
|
|
1966
|
+
clusterColors[2],
|
|
1967
|
+
],
|
|
1968
|
+
"circle-radius": [
|
|
1969
|
+
"step",
|
|
1970
|
+
["get", "point_count"],
|
|
1971
|
+
20,
|
|
1972
|
+
clusterThresholds[0],
|
|
1973
|
+
30,
|
|
1974
|
+
clusterThresholds[1],
|
|
1975
|
+
40,
|
|
1976
|
+
],
|
|
1977
|
+
"circle-stroke-width": 0.75,
|
|
1978
|
+
"circle-stroke-color": "#fff",
|
|
1979
|
+
"circle-opacity": 0.85,
|
|
1980
|
+
},
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
// Add cluster count text layer
|
|
1984
|
+
map.addLayer({
|
|
1985
|
+
id: clusterCountLayerId,
|
|
1986
|
+
type: "symbol",
|
|
1987
|
+
source: sourceId,
|
|
1988
|
+
filter: ["has", "point_count"],
|
|
1989
|
+
layout: {
|
|
1990
|
+
"text-field": "{point_count_abbreviated}",
|
|
1991
|
+
"text-font": ["Open Sans Semibold"],
|
|
1992
|
+
"text-size": 12,
|
|
1993
|
+
},
|
|
1994
|
+
paint: {
|
|
1995
|
+
"text-color": "#fff",
|
|
1996
|
+
},
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
// Add unclustered point layer
|
|
2000
|
+
map.addLayer({
|
|
2001
|
+
id: unclusteredLayerId,
|
|
2002
|
+
type: "circle",
|
|
2003
|
+
source: sourceId,
|
|
2004
|
+
filter: ["!", ["has", "point_count"]],
|
|
2005
|
+
paint: {
|
|
2006
|
+
"circle-color": pointColor,
|
|
2007
|
+
"circle-radius": 5,
|
|
2008
|
+
"circle-stroke-width": 2,
|
|
2009
|
+
"circle-stroke-color": "#fff",
|
|
2010
|
+
},
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
return () => {
|
|
2014
|
+
try {
|
|
2015
|
+
if (map.getLayer(clusterCountLayerId))
|
|
2016
|
+
map.removeLayer(clusterCountLayerId);
|
|
2017
|
+
if (map.getLayer(unclusteredLayerId))
|
|
2018
|
+
map.removeLayer(unclusteredLayerId);
|
|
2019
|
+
if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);
|
|
2020
|
+
if (map.getSource(sourceId)) map.removeSource(sourceId);
|
|
2021
|
+
} catch {
|
|
2022
|
+
// ignore
|
|
2023
|
+
}
|
|
2024
|
+
};
|
|
2025
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2026
|
+
}, [isLoaded, map, sourceId]);
|
|
2027
|
+
|
|
2028
|
+
// Update source data when data prop changes (only for non-URL data)
|
|
2029
|
+
useEffect(() => {
|
|
2030
|
+
if (!isLoaded || !map || typeof data === "string") return;
|
|
2031
|
+
|
|
2032
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
2033
|
+
if (source) {
|
|
2034
|
+
source.setData(data);
|
|
2035
|
+
}
|
|
2036
|
+
}, [isLoaded, map, data, sourceId]);
|
|
2037
|
+
|
|
2038
|
+
// Update layer styles when props change
|
|
2039
|
+
useEffect(() => {
|
|
2040
|
+
if (!isLoaded || !map) return;
|
|
2041
|
+
|
|
2042
|
+
const prev = stylePropsRef.current;
|
|
2043
|
+
const colorsChanged =
|
|
2044
|
+
prev.clusterColors !== clusterColors ||
|
|
2045
|
+
prev.clusterThresholds !== clusterThresholds;
|
|
2046
|
+
|
|
2047
|
+
// Update cluster layer colors and sizes
|
|
2048
|
+
if (map.getLayer(clusterLayerId) && colorsChanged) {
|
|
2049
|
+
map.setPaintProperty(clusterLayerId, "circle-color", [
|
|
2050
|
+
"step",
|
|
2051
|
+
["get", "point_count"],
|
|
2052
|
+
clusterColors[0],
|
|
2053
|
+
clusterThresholds[0],
|
|
2054
|
+
clusterColors[1],
|
|
2055
|
+
clusterThresholds[1],
|
|
2056
|
+
clusterColors[2],
|
|
2057
|
+
]);
|
|
2058
|
+
map.setPaintProperty(clusterLayerId, "circle-radius", [
|
|
2059
|
+
"step",
|
|
2060
|
+
["get", "point_count"],
|
|
2061
|
+
20,
|
|
2062
|
+
clusterThresholds[0],
|
|
2063
|
+
30,
|
|
2064
|
+
clusterThresholds[1],
|
|
2065
|
+
40,
|
|
2066
|
+
]);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// Update unclustered point layer color
|
|
2070
|
+
if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {
|
|
2071
|
+
map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };
|
|
2075
|
+
}, [
|
|
2076
|
+
isLoaded,
|
|
2077
|
+
map,
|
|
2078
|
+
clusterLayerId,
|
|
2079
|
+
unclusteredLayerId,
|
|
2080
|
+
clusterColors,
|
|
2081
|
+
clusterThresholds,
|
|
2082
|
+
pointColor,
|
|
2083
|
+
]);
|
|
2084
|
+
|
|
2085
|
+
// Handle click events
|
|
2086
|
+
useEffect(() => {
|
|
2087
|
+
if (!isLoaded || !map) return;
|
|
2088
|
+
|
|
2089
|
+
// Cluster click handler - zoom into cluster
|
|
2090
|
+
const handleClusterClick = async (
|
|
2091
|
+
e: MapLibreGL.MapMouseEvent & {
|
|
2092
|
+
features?: MapLibreGL.MapGeoJSONFeature[];
|
|
2093
|
+
},
|
|
2094
|
+
) => {
|
|
2095
|
+
const features = map.queryRenderedFeatures(e.point, {
|
|
2096
|
+
layers: [clusterLayerId],
|
|
2097
|
+
});
|
|
2098
|
+
if (!features.length) return;
|
|
2099
|
+
|
|
2100
|
+
const feature = features[0];
|
|
2101
|
+
const clusterId = feature.properties?.cluster_id as number;
|
|
2102
|
+
const pointCount = feature.properties?.point_count as number;
|
|
2103
|
+
const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [
|
|
2104
|
+
number,
|
|
2105
|
+
number,
|
|
2106
|
+
];
|
|
2107
|
+
|
|
2108
|
+
if (onClusterClick) {
|
|
2109
|
+
onClusterClick(clusterId, coordinates, pointCount);
|
|
2110
|
+
} else {
|
|
2111
|
+
// Default behavior: zoom to cluster expansion zoom
|
|
2112
|
+
const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;
|
|
2113
|
+
const zoom = await source.getClusterExpansionZoom(clusterId);
|
|
2114
|
+
map.easeTo({
|
|
2115
|
+
center: coordinates,
|
|
2116
|
+
zoom,
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
// Unclustered point click handler
|
|
2122
|
+
const handlePointClick = (
|
|
2123
|
+
e: MapLibreGL.MapMouseEvent & {
|
|
2124
|
+
features?: MapLibreGL.MapGeoJSONFeature[];
|
|
2125
|
+
},
|
|
2126
|
+
) => {
|
|
2127
|
+
if (!onPointClick || !e.features?.length) return;
|
|
2128
|
+
|
|
2129
|
+
const feature = e.features[0];
|
|
2130
|
+
const coordinates = (
|
|
2131
|
+
feature.geometry as GeoJSON.Point
|
|
2132
|
+
).coordinates.slice() as [number, number];
|
|
2133
|
+
|
|
2134
|
+
// Handle world copies
|
|
2135
|
+
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
|
|
2136
|
+
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
onPointClick(
|
|
2140
|
+
feature as unknown as GeoJSON.Feature<GeoJSON.Point, P>,
|
|
2141
|
+
coordinates,
|
|
2142
|
+
);
|
|
2143
|
+
};
|
|
2144
|
+
|
|
2145
|
+
// Cursor style handlers
|
|
2146
|
+
const handleMouseEnterCluster = () => {
|
|
2147
|
+
map.getCanvas().style.cursor = "pointer";
|
|
2148
|
+
};
|
|
2149
|
+
const handleMouseLeaveCluster = () => {
|
|
2150
|
+
map.getCanvas().style.cursor = "";
|
|
2151
|
+
};
|
|
2152
|
+
const handleMouseEnterPoint = () => {
|
|
2153
|
+
if (onPointClick) {
|
|
2154
|
+
map.getCanvas().style.cursor = "pointer";
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
const handleMouseLeavePoint = () => {
|
|
2158
|
+
map.getCanvas().style.cursor = "";
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
map.on("click", clusterLayerId, handleClusterClick);
|
|
2162
|
+
map.on("click", unclusteredLayerId, handlePointClick);
|
|
2163
|
+
map.on("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
|
2164
|
+
map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
|
2165
|
+
map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
|
2166
|
+
map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
|
2167
|
+
|
|
2168
|
+
return () => {
|
|
2169
|
+
map.off("click", clusterLayerId, handleClusterClick);
|
|
2170
|
+
map.off("click", unclusteredLayerId, handlePointClick);
|
|
2171
|
+
map.off("mouseenter", clusterLayerId, handleMouseEnterCluster);
|
|
2172
|
+
map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster);
|
|
2173
|
+
map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint);
|
|
2174
|
+
map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint);
|
|
2175
|
+
};
|
|
2176
|
+
}, [
|
|
2177
|
+
isLoaded,
|
|
2178
|
+
map,
|
|
2179
|
+
clusterLayerId,
|
|
2180
|
+
unclusteredLayerId,
|
|
2181
|
+
sourceId,
|
|
2182
|
+
onClusterClick,
|
|
2183
|
+
onPointClick,
|
|
2184
|
+
]);
|
|
2185
|
+
|
|
2186
|
+
return null;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
export {
|
|
2190
|
+
Map,
|
|
2191
|
+
useMap,
|
|
2192
|
+
MapMarker,
|
|
2193
|
+
MarkerContent,
|
|
2194
|
+
MarkerPopup,
|
|
2195
|
+
MarkerTooltip,
|
|
2196
|
+
MarkerLabel,
|
|
2197
|
+
MapPopup,
|
|
2198
|
+
MapControls,
|
|
2199
|
+
MapRoute,
|
|
2200
|
+
MapArc,
|
|
2201
|
+
MapGeoJSON,
|
|
2202
|
+
MapClusterLayer,
|
|
2203
|
+
};
|
|
2204
|
+
|
|
2205
|
+
export type {
|
|
2206
|
+
MapRef,
|
|
2207
|
+
MapViewport,
|
|
2208
|
+
MapStyleOption,
|
|
2209
|
+
MapArcDatum,
|
|
2210
|
+
MapArcEvent,
|
|
2211
|
+
MapGeoJSONData,
|
|
2212
|
+
MapGeoJSONFeature,
|
|
2213
|
+
MapGeoJSONEvent,
|
|
2214
|
+
MapProps,
|
|
2215
|
+
MapMarkerProps,
|
|
2216
|
+
MarkerContentProps,
|
|
2217
|
+
MarkerPopupProps,
|
|
2218
|
+
MarkerTooltipProps,
|
|
2219
|
+
MarkerLabelProps,
|
|
2220
|
+
MapControlsProps,
|
|
2221
|
+
MapPopupProps,
|
|
2222
|
+
MapRouteProps,
|
|
2223
|
+
MapArcProps,
|
|
2224
|
+
MapGeoJSONProps,
|
|
2225
|
+
MapClusterLayerProps,
|
|
2226
|
+
};
|