@voila.dev/ui-map 1.1.9
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/package.json +55 -0
- package/src/components/globe-view-implementation.tsx +293 -0
- package/src/components/globe-view.tsx +93 -0
- package/src/components/map-view-implementation.tsx +176 -0
- package/src/components/map-view.tsx +100 -0
- package/src/components/radius-map.tsx +131 -0
- package/src/css-modules.d.ts +4 -0
- package/src/lib/geo-circle.ts +83 -0
- package/src/styles.css +10 -0
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voila.dev/ui-map",
|
|
3
|
+
"version": "1.1.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Maps and a globe on free vector tiles. No API key, no bundle tax.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://ui.voila.dev/components/map-view",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/voila-voila-dev/ui.git",
|
|
11
|
+
"directory": "packages/ui-map"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react",
|
|
15
|
+
"map",
|
|
16
|
+
"maplibre",
|
|
17
|
+
"tailwindcss"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"!src/**/*.test.ts",
|
|
25
|
+
"!src/**/*.test.tsx",
|
|
26
|
+
"!src/**/*.stories.ts",
|
|
27
|
+
"!src/**/*.stories.tsx"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": [
|
|
30
|
+
"**/*.css"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
"./styles.css": "./src/styles.css",
|
|
34
|
+
"./components/*": "./src/components/*.tsx"
|
|
35
|
+
},
|
|
36
|
+
"imports": {
|
|
37
|
+
"#/*": "./src/*"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"check": "biome check",
|
|
41
|
+
"check-types": "tsc --noEmit",
|
|
42
|
+
"format": "biome format",
|
|
43
|
+
"lint": "biome lint",
|
|
44
|
+
"test": "vitest run"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@voila.dev/ui": "0.0.0",
|
|
48
|
+
"maplibre-gl": "5.24.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "^19.0.0",
|
|
52
|
+
"react-dom": "^19.0.0",
|
|
53
|
+
"tailwindcss": "^4.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import maplibregl from "maplibre-gl";
|
|
2
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import type { GlobeViewProps } from "#/components/globe-view.tsx";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_DARK_STYLE_URL,
|
|
8
|
+
DEFAULT_STYLE_URL,
|
|
9
|
+
} from "#/components/map-view-implementation.tsx";
|
|
10
|
+
|
|
11
|
+
/** A whole hemisphere in frame — the sensible default for a globe. */
|
|
12
|
+
const DEFAULT_CENTER: readonly [number, number] = [2.3522, 30];
|
|
13
|
+
const DEFAULT_ZOOM = 1.4;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a CSS colour expression (`var(--primary)`, `color-mix(…)`) to the
|
|
17
|
+
* `rgba()` string MapLibre's style parser accepts. The probe element gives the
|
|
18
|
+
* expression the right custom-property scope; the 1×1 canvas normalises
|
|
19
|
+
* whatever colour space the computed value serialises to.
|
|
20
|
+
*/
|
|
21
|
+
function resolveCssColor(
|
|
22
|
+
scope: HTMLElement,
|
|
23
|
+
expression: string,
|
|
24
|
+
fallback: string,
|
|
25
|
+
): string {
|
|
26
|
+
const probe = document.createElement("span");
|
|
27
|
+
probe.style.display = "none";
|
|
28
|
+
probe.style.color = expression;
|
|
29
|
+
scope.appendChild(probe);
|
|
30
|
+
const computed = getComputedStyle(probe).color;
|
|
31
|
+
probe.remove();
|
|
32
|
+
const canvas = document.createElement("canvas");
|
|
33
|
+
canvas.width = 1;
|
|
34
|
+
canvas.height = 1;
|
|
35
|
+
const context = canvas.getContext("2d");
|
|
36
|
+
if (context === null) {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
context.fillStyle = fallback;
|
|
40
|
+
context.fillStyle = computed;
|
|
41
|
+
context.fillRect(0, 0, 1, 1);
|
|
42
|
+
const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
|
|
43
|
+
return `rgba(${r}, ${g}, ${b}, ${((a ?? 0) / 255).toFixed(3)})`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The default WebGL-less fallback: a static globe outline in `--muted`. */
|
|
47
|
+
function StaticGlobeOutline() {
|
|
48
|
+
return (
|
|
49
|
+
<svg
|
|
50
|
+
aria-hidden
|
|
51
|
+
viewBox="0 0 100 100"
|
|
52
|
+
className="mx-auto aspect-square h-full max-w-full text-muted"
|
|
53
|
+
>
|
|
54
|
+
<title>Globe unavailable</title>
|
|
55
|
+
<circle
|
|
56
|
+
cx="50"
|
|
57
|
+
cy="50"
|
|
58
|
+
r="48"
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
strokeWidth="1.5"
|
|
62
|
+
/>
|
|
63
|
+
<ellipse
|
|
64
|
+
cx="50"
|
|
65
|
+
cy="50"
|
|
66
|
+
rx="24"
|
|
67
|
+
ry="48"
|
|
68
|
+
fill="none"
|
|
69
|
+
stroke="currentColor"
|
|
70
|
+
/>
|
|
71
|
+
<ellipse
|
|
72
|
+
cx="50"
|
|
73
|
+
cy="50"
|
|
74
|
+
rx="44"
|
|
75
|
+
ry="48"
|
|
76
|
+
fill="none"
|
|
77
|
+
stroke="currentColor"
|
|
78
|
+
strokeWidth="0.5"
|
|
79
|
+
/>
|
|
80
|
+
<line x1="2" y1="50" x2="98" y2="50" stroke="currentColor" />
|
|
81
|
+
<path d="M 7 26 A 60 60 0 0 1 93 26" fill="none" stroke="currentColor" />
|
|
82
|
+
<path d="M 7 74 A 60 60 0 0 0 93 74" fill="none" stroke="currentColor" />
|
|
83
|
+
</svg>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The MapLibre-backed body of `GlobeView`, loaded through `React.lazy` from
|
|
89
|
+
* `globe-view.tsx` so the ~270 kB runtime never rides along with a page that
|
|
90
|
+
* merely links to it. Import `GlobeView` instead of using this directly.
|
|
91
|
+
*/
|
|
92
|
+
export function GlobeViewImplementation({
|
|
93
|
+
styleUrl,
|
|
94
|
+
center = DEFAULT_CENTER,
|
|
95
|
+
zoom = DEFAULT_ZOOM,
|
|
96
|
+
spin = 0,
|
|
97
|
+
markers = [],
|
|
98
|
+
options,
|
|
99
|
+
className,
|
|
100
|
+
onReady,
|
|
101
|
+
unavailableFallback,
|
|
102
|
+
...props
|
|
103
|
+
}: GlobeViewProps) {
|
|
104
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
105
|
+
const onReadyRef = useRef(onReady);
|
|
106
|
+
onReadyRef.current = onReady;
|
|
107
|
+
// Initial view, style, rotation and markers are captured on mount only;
|
|
108
|
+
// later prop changes don't re-center, restyle or re-pin.
|
|
109
|
+
const initialRef = useRef({ center, zoom, styleUrl, spin, markers, options });
|
|
110
|
+
const [unavailable, setUnavailable] = useState(false);
|
|
111
|
+
const [loaded, setLoaded] = useState(false);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const container = containerRef.current;
|
|
115
|
+
if (container === null) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const initial = initialRef.current;
|
|
119
|
+
const themedStyleUrl = () =>
|
|
120
|
+
container.closest(".dark") === null
|
|
121
|
+
? DEFAULT_STYLE_URL
|
|
122
|
+
: DEFAULT_DARK_STYLE_URL;
|
|
123
|
+
const defaultStyleUrl = themedStyleUrl();
|
|
124
|
+
const prefersReducedMotion = window.matchMedia(
|
|
125
|
+
"(prefers-reduced-motion: reduce)",
|
|
126
|
+
).matches;
|
|
127
|
+
let instance: maplibregl.Map;
|
|
128
|
+
try {
|
|
129
|
+
instance = new maplibregl.Map({
|
|
130
|
+
...(prefersReducedMotion ? { fadeDuration: 0 } : undefined),
|
|
131
|
+
...initial.options,
|
|
132
|
+
container,
|
|
133
|
+
style: initial.styleUrl ?? defaultStyleUrl,
|
|
134
|
+
center: [initial.center[0], initial.center[1]],
|
|
135
|
+
zoom: initial.zoom,
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
// WebGL is required and isn't available in this environment.
|
|
139
|
+
setUnavailable(true);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Markers are DOM overlays, so they survive `setStyle` and are added once.
|
|
144
|
+
for (const marker of initial.markers) {
|
|
145
|
+
const element = document.createElement("span");
|
|
146
|
+
element.className = "relative flex size-3";
|
|
147
|
+
if (marker.pulse) {
|
|
148
|
+
const ring = document.createElement("span");
|
|
149
|
+
ring.className =
|
|
150
|
+
"absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60 motion-reduce:animate-none";
|
|
151
|
+
element.appendChild(ring);
|
|
152
|
+
}
|
|
153
|
+
const dot = document.createElement("span");
|
|
154
|
+
dot.className =
|
|
155
|
+
"relative inline-flex size-3 rounded-full bg-primary shadow-[0_0_10px_2px] shadow-primary/50";
|
|
156
|
+
element.appendChild(dot);
|
|
157
|
+
// Without subpixel positioning MapLibre rounds each marker to whole
|
|
158
|
+
// pixels, which reads as trembling while the globe auto-rotates.
|
|
159
|
+
new maplibregl.Marker({ element, subpixelPositioning: true })
|
|
160
|
+
.setLngLat([marker.lngLat[0], marker.lngLat[1]])
|
|
161
|
+
.addTo(instance);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Projection and sky are style-level state: a theme switch's `setStyle`
|
|
165
|
+
// drops them, so `handleReady` re-applies both on every style load.
|
|
166
|
+
const handleReady = () => {
|
|
167
|
+
instance.setProjection({ type: "globe" });
|
|
168
|
+
const dark = container.closest(".dark") !== null;
|
|
169
|
+
// Indigo `--primary` glow in dark mode, a soft slate halo in light —
|
|
170
|
+
// resolved from the live token values so a rebrand retints the globe.
|
|
171
|
+
const halo = resolveCssColor(
|
|
172
|
+
container,
|
|
173
|
+
dark
|
|
174
|
+
? "color-mix(in oklab, var(--primary) 65%, transparent)"
|
|
175
|
+
: "color-mix(in oklab, var(--muted-foreground) 45%, transparent)",
|
|
176
|
+
dark ? "rgba(99, 102, 241, 0.65)" : "rgba(100, 116, 139, 0.45)",
|
|
177
|
+
);
|
|
178
|
+
const fog = resolveCssColor(
|
|
179
|
+
container,
|
|
180
|
+
dark
|
|
181
|
+
? "color-mix(in oklab, var(--primary) 25%, transparent)"
|
|
182
|
+
: "color-mix(in oklab, var(--muted-foreground) 20%, transparent)",
|
|
183
|
+
dark ? "rgba(99, 102, 241, 0.25)" : "rgba(100, 116, 139, 0.2)",
|
|
184
|
+
);
|
|
185
|
+
instance.setSky({
|
|
186
|
+
// Transparent space, so the section's `bg-background` shows through.
|
|
187
|
+
"sky-color": "rgba(0, 0, 0, 0)",
|
|
188
|
+
"horizon-color": halo,
|
|
189
|
+
"fog-color": fog,
|
|
190
|
+
"sky-horizon-blend": 0.8,
|
|
191
|
+
"horizon-fog-blend": 0.5,
|
|
192
|
+
"fog-ground-blend": 0.9,
|
|
193
|
+
"atmosphere-blend": 0.8,
|
|
194
|
+
});
|
|
195
|
+
setLoaded(true);
|
|
196
|
+
onReadyRef.current?.(instance);
|
|
197
|
+
};
|
|
198
|
+
if (instance.isStyleLoaded()) {
|
|
199
|
+
handleReady();
|
|
200
|
+
} else {
|
|
201
|
+
instance.once("load", handleReady);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Same live theme following as `MapView`.
|
|
205
|
+
let themeObserver: MutationObserver | undefined;
|
|
206
|
+
if (initial.styleUrl === undefined) {
|
|
207
|
+
let appliedStyleUrl = defaultStyleUrl;
|
|
208
|
+
themeObserver = new MutationObserver(() => {
|
|
209
|
+
const nextStyleUrl = themedStyleUrl();
|
|
210
|
+
if (nextStyleUrl === appliedStyleUrl) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
appliedStyleUrl = nextStyleUrl;
|
|
214
|
+
instance.setStyle(nextStyleUrl);
|
|
215
|
+
instance.once("style.load", handleReady);
|
|
216
|
+
});
|
|
217
|
+
themeObserver.observe(document.documentElement, {
|
|
218
|
+
attributes: true,
|
|
219
|
+
attributeFilter: ["class"],
|
|
220
|
+
subtree: true,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Auto-rotation: a rAF loop nudging the longitude, paused while the
|
|
225
|
+
// pointer is down so a drag never fights the spin, skipped entirely under
|
|
226
|
+
// reduced motion. `jumpTo` (not `easeTo`) keeps each step animation-free.
|
|
227
|
+
let frame = 0;
|
|
228
|
+
let interacting = false;
|
|
229
|
+
const handlePointerDown = () => {
|
|
230
|
+
interacting = true;
|
|
231
|
+
};
|
|
232
|
+
const handlePointerUp = () => {
|
|
233
|
+
interacting = false;
|
|
234
|
+
};
|
|
235
|
+
if (initial.spin !== 0 && !prefersReducedMotion) {
|
|
236
|
+
container.addEventListener("pointerdown", handlePointerDown);
|
|
237
|
+
window.addEventListener("pointerup", handlePointerUp);
|
|
238
|
+
let last: number | undefined;
|
|
239
|
+
const step = (now: number) => {
|
|
240
|
+
if (last !== undefined && !interacting) {
|
|
241
|
+
const current = instance.getCenter();
|
|
242
|
+
instance.jumpTo({
|
|
243
|
+
center: [
|
|
244
|
+
current.lng + (initial.spin * (now - last)) / 1000,
|
|
245
|
+
current.lat,
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
last = now;
|
|
250
|
+
frame = requestAnimationFrame(step);
|
|
251
|
+
};
|
|
252
|
+
frame = requestAnimationFrame(step);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const resizeObserver = new ResizeObserver(() => instance.resize());
|
|
256
|
+
resizeObserver.observe(container);
|
|
257
|
+
return () => {
|
|
258
|
+
cancelAnimationFrame(frame);
|
|
259
|
+
container.removeEventListener("pointerdown", handlePointerDown);
|
|
260
|
+
window.removeEventListener("pointerup", handlePointerUp);
|
|
261
|
+
themeObserver?.disconnect();
|
|
262
|
+
resizeObserver.disconnect();
|
|
263
|
+
instance.remove();
|
|
264
|
+
};
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
data-slot="globe-view"
|
|
270
|
+
className={cn(
|
|
271
|
+
"h-[70vh] w-full overflow-hidden",
|
|
272
|
+
"dark:[&_.maplibregl-ctrl]:hue-rotate-180 dark:[&_.maplibregl-ctrl]:invert",
|
|
273
|
+
className,
|
|
274
|
+
)}
|
|
275
|
+
{...props}
|
|
276
|
+
>
|
|
277
|
+
{unavailable ? (
|
|
278
|
+
<div data-slot="globe-view-fallback" className="h-full w-full p-4">
|
|
279
|
+
{unavailableFallback ?? <StaticGlobeOutline />}
|
|
280
|
+
</div>
|
|
281
|
+
) : (
|
|
282
|
+
<div
|
|
283
|
+
ref={containerRef}
|
|
284
|
+
data-slot="globe-view-canvas"
|
|
285
|
+
data-loaded={loaded}
|
|
286
|
+
// Keep this className identical across renders — MapLibre adds its
|
|
287
|
+
// own class to the node imperatively (see MapView for the story).
|
|
288
|
+
className="h-full w-full"
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Skeleton } from "@voila.dev/ui/components/skeleton";
|
|
2
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
3
|
+
import type maplibregl from "maplibre-gl";
|
|
4
|
+
import { type ComponentProps, lazy, type ReactNode, Suspense } from "react";
|
|
5
|
+
|
|
6
|
+
/** A point of interest on the globe, rendered as a token-coloured dot. */
|
|
7
|
+
export type GlobeMarker = {
|
|
8
|
+
/** `[longitude, latitude]` in WGS84 degrees. */
|
|
9
|
+
readonly lngLat: readonly [number, number];
|
|
10
|
+
/** Adds a slow expanding ring behind the dot. */
|
|
11
|
+
readonly pulse?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `MapView`'s sibling, same contract: free key-less OpenFreeMap basemaps
|
|
16
|
+
* ("liberty" light / "dark" dark) following the `.dark` subtree live, both
|
|
17
|
+
* overridable via `styleUrl`. The differences are the projection — MapLibre's
|
|
18
|
+
* native globe — plus an optional auto-rotation and declarative markers.
|
|
19
|
+
*/
|
|
20
|
+
export type GlobeViewProps = Omit<ComponentProps<"div">, "children"> & {
|
|
21
|
+
/**
|
|
22
|
+
* Vector style URL. Read once, on mount; defaults to the OpenFreeMap
|
|
23
|
+
* "liberty" basemap — or its "dark" variant while inside a `.dark` subtree,
|
|
24
|
+
* following theme switches. An explicit URL opts out of theme following.
|
|
25
|
+
*/
|
|
26
|
+
readonly styleUrl?: string;
|
|
27
|
+
/** Initial center `[longitude, latitude]`. Read once, on mount. */
|
|
28
|
+
readonly center?: readonly [number, number];
|
|
29
|
+
/** Initial zoom. Read once, on mount. */
|
|
30
|
+
readonly zoom?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Auto-rotation speed in degrees of longitude per second; `0` (the default)
|
|
33
|
+
* disables it. Rotation pauses while the pointer is down and is skipped
|
|
34
|
+
* entirely under `prefers-reduced-motion`. Read once, on mount.
|
|
35
|
+
*/
|
|
36
|
+
readonly spin?: number;
|
|
37
|
+
/** Markers to place, coloured from the token palette. Read once, on mount. */
|
|
38
|
+
readonly markers?: ReadonlyArray<GlobeMarker>;
|
|
39
|
+
/**
|
|
40
|
+
* Extra MapLibre constructor options (`cooperativeGestures`,
|
|
41
|
+
* `attributionControl`…). Read once, on mount.
|
|
42
|
+
*/
|
|
43
|
+
readonly options?: Omit<
|
|
44
|
+
maplibregl.MapOptions,
|
|
45
|
+
"container" | "style" | "center" | "zoom"
|
|
46
|
+
>;
|
|
47
|
+
/**
|
|
48
|
+
* Called once the basemap, globe projection and atmosphere are ready, with
|
|
49
|
+
* the live MapLibre instance. A theme switch swaps the basemap style (which
|
|
50
|
+
* drops custom sources/layers) and calls this again, so the setup must
|
|
51
|
+
* tolerate re-runs. The instance is removed on unmount.
|
|
52
|
+
*/
|
|
53
|
+
readonly onReady?: (map: maplibregl.Map) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Rendered in place of the globe when WebGL is unavailable
|
|
56
|
+
* (sandboxed/headless browsers, disabled GPU). Defaults to a static globe
|
|
57
|
+
* outline drawn in `--muted`.
|
|
58
|
+
*/
|
|
59
|
+
readonly unavailableFallback?: ReactNode;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Same discipline as `MapView`: `maplibre-gl` weighs ~270 kB gzipped, so the
|
|
63
|
+
// rendering body lives in its own module and only downloads once a globe
|
|
64
|
+
// actually mounts. Type-only imports of `maplibre-gl` stay free.
|
|
65
|
+
const GlobeViewImplementation = lazy(() =>
|
|
66
|
+
import("#/components/globe-view-implementation.tsx").then((module) => ({
|
|
67
|
+
default: module.GlobeViewImplementation,
|
|
68
|
+
})),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A 3D globe on MapLibre's native globe projection and the same free, key-less
|
|
73
|
+
* vector tiles as `MapView`. The atmosphere halo is tinted from the token
|
|
74
|
+
* palette (`--primary` in dark mode, a soft slate in light), the space behind
|
|
75
|
+
* the globe stays transparent so the surrounding section's background shows
|
|
76
|
+
* through, and the MapLibre runtime is code-split behind a pulsing skeleton.
|
|
77
|
+
*/
|
|
78
|
+
export function GlobeView({ className, ...props }: GlobeViewProps) {
|
|
79
|
+
return (
|
|
80
|
+
<Suspense
|
|
81
|
+
fallback={
|
|
82
|
+
<div
|
|
83
|
+
data-slot="globe-view"
|
|
84
|
+
className={cn("h-[70vh] w-full overflow-hidden", className)}
|
|
85
|
+
>
|
|
86
|
+
<Skeleton className="mx-auto aspect-square h-full max-w-full rounded-full" />
|
|
87
|
+
</div>
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
<GlobeViewImplementation className={className} {...props} />
|
|
91
|
+
</Suspense>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import maplibregl from "maplibre-gl";
|
|
2
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
3
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import type { MapViewProps } from "#/components/map-view.tsx";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
|
|
8
|
+
export const DEFAULT_DARK_STYLE_URL =
|
|
9
|
+
"https://tiles.openfreemap.org/styles/dark";
|
|
10
|
+
|
|
11
|
+
/** Metropolitan France — a sensible default frame for the whole dataset. */
|
|
12
|
+
const DEFAULT_CENTER: readonly [number, number] = [2.3522, 48.8566];
|
|
13
|
+
const DEFAULT_ZOOM = 5;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The MapLibre-backed body of `MapView`. This module owns the only runtime
|
|
17
|
+
* import of `maplibre-gl` (and its stylesheet) in the package, so it is loaded
|
|
18
|
+
* through `React.lazy` from `map-view.tsx` — keeping the heavy dependency out
|
|
19
|
+
* of every bundle that merely links to the map component. Import `MapView`
|
|
20
|
+
* from `#/components/map-view.tsx` instead of using this directly.
|
|
21
|
+
*/
|
|
22
|
+
export function MapViewImplementation({
|
|
23
|
+
styleUrl,
|
|
24
|
+
center = DEFAULT_CENTER,
|
|
25
|
+
zoom = DEFAULT_ZOOM,
|
|
26
|
+
options,
|
|
27
|
+
className,
|
|
28
|
+
onReady,
|
|
29
|
+
onMoveEnd,
|
|
30
|
+
unavailableFallback,
|
|
31
|
+
...props
|
|
32
|
+
}: MapViewProps) {
|
|
33
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
34
|
+
const onReadyRef = useRef(onReady);
|
|
35
|
+
onReadyRef.current = onReady;
|
|
36
|
+
const onMoveEndRef = useRef(onMoveEnd);
|
|
37
|
+
onMoveEndRef.current = onMoveEnd;
|
|
38
|
+
// Initial view, style and options are captured on mount only; later prop
|
|
39
|
+
// changes don't re-center or restyle.
|
|
40
|
+
const initialRef = useRef({ center, zoom, styleUrl, options });
|
|
41
|
+
// Set when the basemap can't initialize (no WebGL: sandboxed/headless
|
|
42
|
+
// browsers, disabled GPU); we render a fallback instead of crashing.
|
|
43
|
+
const [unavailable, setUnavailable] = useState(false);
|
|
44
|
+
// Tiles stream in for a few seconds after mount; pulse the box until the
|
|
45
|
+
// style is ready so the wait doesn't read as a broken blank map.
|
|
46
|
+
const [loaded, setLoaded] = useState(false);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const container = containerRef.current;
|
|
50
|
+
if (container === null) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const initial = initialRef.current;
|
|
54
|
+
// Mirrors the CSS `dark` variant (`.dark *`): default to the dark basemap
|
|
55
|
+
// under a themed subtree when no explicit style was given.
|
|
56
|
+
const themedStyleUrl = () =>
|
|
57
|
+
container.closest(".dark") === null
|
|
58
|
+
? DEFAULT_STYLE_URL
|
|
59
|
+
: DEFAULT_DARK_STYLE_URL;
|
|
60
|
+
const defaultStyleUrl = themedStyleUrl();
|
|
61
|
+
// MapLibre already skips ease/fly camera animations under reduced motion,
|
|
62
|
+
// but tile crossfades are opt-out only; mirror the preference here.
|
|
63
|
+
const prefersReducedMotion = window.matchMedia(
|
|
64
|
+
"(prefers-reduced-motion: reduce)",
|
|
65
|
+
).matches;
|
|
66
|
+
let instance: maplibregl.Map;
|
|
67
|
+
try {
|
|
68
|
+
instance = new maplibregl.Map({
|
|
69
|
+
...(prefersReducedMotion ? { fadeDuration: 0 } : undefined),
|
|
70
|
+
...initial.options,
|
|
71
|
+
container,
|
|
72
|
+
style: initial.styleUrl ?? defaultStyleUrl,
|
|
73
|
+
center: [initial.center[0], initial.center[1]],
|
|
74
|
+
zoom: initial.zoom,
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
// WebGL is required and isn't available in this environment. Surface a
|
|
78
|
+
// fallback rather than letting the throw bubble out of the effect.
|
|
79
|
+
setUnavailable(true);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
instance.addControl(new maplibregl.NavigationControl(), "top-right");
|
|
83
|
+
|
|
84
|
+
const emitMove = () => {
|
|
85
|
+
const bounds = instance.getBounds();
|
|
86
|
+
onMoveEndRef.current?.(
|
|
87
|
+
{
|
|
88
|
+
west: bounds.getWest(),
|
|
89
|
+
south: bounds.getSouth(),
|
|
90
|
+
east: bounds.getEast(),
|
|
91
|
+
north: bounds.getNorth(),
|
|
92
|
+
},
|
|
93
|
+
instance.getZoom(),
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
const handleReady = () => {
|
|
97
|
+
setLoaded(true);
|
|
98
|
+
onReadyRef.current?.(instance);
|
|
99
|
+
emitMove();
|
|
100
|
+
};
|
|
101
|
+
if (instance.isStyleLoaded()) {
|
|
102
|
+
handleReady();
|
|
103
|
+
} else {
|
|
104
|
+
instance.once("load", handleReady);
|
|
105
|
+
}
|
|
106
|
+
instance.on("moveend", emitMove);
|
|
107
|
+
|
|
108
|
+
// Follow theme switches: the `.dark` class can land after mount (theme
|
|
109
|
+
// providers and Storybook's themes addon apply it in an effect) or toggle
|
|
110
|
+
// live. Swap the basemap when it does and replay `handleReady` once the new
|
|
111
|
+
// style is in, so consumers re-add the sources/layers the swap dropped.
|
|
112
|
+
let themeObserver: MutationObserver | undefined;
|
|
113
|
+
if (initial.styleUrl === undefined) {
|
|
114
|
+
let appliedStyleUrl = defaultStyleUrl;
|
|
115
|
+
themeObserver = new MutationObserver(() => {
|
|
116
|
+
const nextStyleUrl = themedStyleUrl();
|
|
117
|
+
if (nextStyleUrl === appliedStyleUrl) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
appliedStyleUrl = nextStyleUrl;
|
|
121
|
+
instance.setStyle(nextStyleUrl);
|
|
122
|
+
instance.once("style.load", handleReady);
|
|
123
|
+
});
|
|
124
|
+
themeObserver.observe(document.documentElement, {
|
|
125
|
+
attributes: true,
|
|
126
|
+
attributeFilter: ["class"],
|
|
127
|
+
subtree: true,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The container may finish sizing after construction (flex/grid, sidebar
|
|
132
|
+
// toggles); keep the canvas matched so tiles fill the box.
|
|
133
|
+
const resizeObserver = new ResizeObserver(() => instance.resize());
|
|
134
|
+
resizeObserver.observe(container);
|
|
135
|
+
return () => {
|
|
136
|
+
themeObserver?.disconnect();
|
|
137
|
+
resizeObserver.disconnect();
|
|
138
|
+
instance.remove();
|
|
139
|
+
};
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
data-slot="map-view"
|
|
145
|
+
className={cn(
|
|
146
|
+
"h-[70vh] w-full overflow-hidden rounded-lg border",
|
|
147
|
+
// MapLibre ships white control chrome (zoom buttons, attribution);
|
|
148
|
+
// invert it — hue-rotated back so link colors survive — when the map
|
|
149
|
+
// sits in a dark subtree.
|
|
150
|
+
"dark:[&_.maplibregl-ctrl]:hue-rotate-180 dark:[&_.maplibregl-ctrl]:invert",
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
{...props}
|
|
154
|
+
>
|
|
155
|
+
{unavailable ? (
|
|
156
|
+
<div
|
|
157
|
+
data-slot="map-view-fallback"
|
|
158
|
+
className="flex h-full w-full items-center justify-center p-4 text-center text-muted-foreground text-sm"
|
|
159
|
+
>
|
|
160
|
+
{unavailableFallback}
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<div
|
|
164
|
+
ref={containerRef}
|
|
165
|
+
data-slot="map-view-canvas"
|
|
166
|
+
data-loaded={loaded}
|
|
167
|
+
// The className must stay identical across renders: MapLibre adds
|
|
168
|
+
// its own `maplibregl-map` class (relative + overflow clipping) to
|
|
169
|
+
// this node imperatively, and any className change makes React
|
|
170
|
+
// rewrite the attribute and drop it — the map then escapes the box.
|
|
171
|
+
className="h-full w-full data-[loaded=false]:animate-pulse data-[loaded=false]:bg-muted motion-reduce:animate-none"
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Skeleton } from "@voila.dev/ui/components/skeleton";
|
|
2
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
3
|
+
import type maplibregl from "maplibre-gl";
|
|
4
|
+
import { type ComponentProps, lazy, type ReactNode, Suspense } from "react";
|
|
5
|
+
|
|
6
|
+
/** A geographic bounding box in WGS84 (EPSG:4326) degrees. */
|
|
7
|
+
export type MapBounds = {
|
|
8
|
+
readonly west: number;
|
|
9
|
+
readonly south: number;
|
|
10
|
+
readonly east: number;
|
|
11
|
+
readonly north: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Free, key-less vector basemaps. "liberty" is the light default; the "dark"
|
|
16
|
+
* variant is selected automatically while the component sits inside a `.dark`
|
|
17
|
+
* subtree, and the basemap restyles live when that class toggles. Both are
|
|
18
|
+
* overridable via the `styleUrl` prop (e.g. a MapTiler/Mapbox style for richer
|
|
19
|
+
* cartography). The URL constants live in `map-view-implementation.tsx` with
|
|
20
|
+
* the rest of the runtime map code.
|
|
21
|
+
*/
|
|
22
|
+
export type MapViewProps = Omit<ComponentProps<"div">, "children"> & {
|
|
23
|
+
/**
|
|
24
|
+
* Vector style URL. Read once, on mount; defaults to the OpenFreeMap
|
|
25
|
+
* "liberty" basemap — or its "dark" variant while inside a `.dark` subtree,
|
|
26
|
+
* following theme switches. An explicit URL opts out of theme following.
|
|
27
|
+
*/
|
|
28
|
+
readonly styleUrl?: string;
|
|
29
|
+
/** Initial center `[longitude, latitude]`. Read once, on mount. */
|
|
30
|
+
readonly center?: readonly [number, number];
|
|
31
|
+
/** Initial zoom. Read once, on mount. */
|
|
32
|
+
readonly zoom?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Extra MapLibre constructor options (`maxBounds`, `cooperativeGestures`,
|
|
35
|
+
* `attributionControl`…). Read once, on mount.
|
|
36
|
+
*/
|
|
37
|
+
readonly options?: Omit<
|
|
38
|
+
maplibregl.MapOptions,
|
|
39
|
+
"container" | "style" | "center" | "zoom"
|
|
40
|
+
>;
|
|
41
|
+
/**
|
|
42
|
+
* Called once the basemap and its style are ready, with the live MapLibre
|
|
43
|
+
* instance — add sources, layers and markers here. A theme switch swaps the
|
|
44
|
+
* basemap style (which drops custom sources/layers) and calls this again, so
|
|
45
|
+
* the setup must tolerate re-runs. The instance is removed on unmount, so
|
|
46
|
+
* don't retain it past the component's lifetime.
|
|
47
|
+
*/
|
|
48
|
+
readonly onReady?: (map: maplibregl.Map) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Called after the view settles — once on load and after every pan/zoom — with
|
|
51
|
+
* the current bounds and zoom. Drives viewport-scoped data fetching.
|
|
52
|
+
*/
|
|
53
|
+
readonly onMoveEnd?: (bounds: MapBounds, zoom: number) => void;
|
|
54
|
+
/**
|
|
55
|
+
* Rendered in place of the map when the basemap can't initialize because the
|
|
56
|
+
* environment has no WebGL (sandboxed/headless browsers, disabled GPU). This
|
|
57
|
+
* package is translation-agnostic, so consumers pass their own localized node.
|
|
58
|
+
*/
|
|
59
|
+
readonly unavailableFallback?: ReactNode;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// `maplibre-gl` weighs ~270 kB gzipped, so the rendering body lives in its own
|
|
63
|
+
// module and only downloads once a map actually mounts. Type-only imports of
|
|
64
|
+
// `maplibre-gl` (like the one above) stay free — they're erased at compile time.
|
|
65
|
+
const MapViewImplementation = lazy(() =>
|
|
66
|
+
import("#/components/map-view-implementation.tsx").then((module) => ({
|
|
67
|
+
default: module.MapViewImplementation,
|
|
68
|
+
})),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Thin, reusable MapLibre GL basemap: it owns the canvas, lifecycle, navigation
|
|
73
|
+
* control and resize handling, and surfaces the instance (`onReady`) and view
|
|
74
|
+
* changes (`onMoveEnd`). It is deliberately presentational and data-agnostic, so
|
|
75
|
+
* any feature — the admin map's clustered entities, a single provider-profile
|
|
76
|
+
* pin, an event venue — composes its own sources/layers on top.
|
|
77
|
+
*
|
|
78
|
+
* The MapLibre runtime is code-split: while its chunk downloads, a skeleton
|
|
79
|
+
* with the same frame pulses in place, matching the tile-streaming placeholder
|
|
80
|
+
* the loaded map shows next.
|
|
81
|
+
*/
|
|
82
|
+
export function MapView({ className, ...props }: MapViewProps) {
|
|
83
|
+
return (
|
|
84
|
+
<Suspense
|
|
85
|
+
fallback={
|
|
86
|
+
<div
|
|
87
|
+
data-slot="map-view"
|
|
88
|
+
className={cn(
|
|
89
|
+
"h-[70vh] w-full overflow-hidden rounded-lg border",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
<Skeleton className="h-full w-full rounded-none" />
|
|
94
|
+
</div>
|
|
95
|
+
}
|
|
96
|
+
>
|
|
97
|
+
<MapViewImplementation className={className} {...props} />
|
|
98
|
+
</Suspense>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { cn } from "@voila.dev/ui/lib/utils";
|
|
2
|
+
import type maplibregl from "maplibre-gl";
|
|
3
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { MapView } from "#/components/map-view.tsx";
|
|
5
|
+
import {
|
|
6
|
+
circleBounds,
|
|
7
|
+
circlePolygon,
|
|
8
|
+
type GeoPoint,
|
|
9
|
+
} from "#/lib/geo-circle.ts";
|
|
10
|
+
|
|
11
|
+
const SOURCE_ID = "radius-map";
|
|
12
|
+
const FILL_LAYER = `${SOURCE_ID}-fill`;
|
|
13
|
+
const LINE_LAYER = `${SOURCE_ID}-line`;
|
|
14
|
+
const CENTER_LAYER = `${SOURCE_ID}-center`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The searched area, drawn: a geodesic circle of `radiusKm` around the chosen
|
|
18
|
+
* place, with a dot on the place itself. The viewport follows the circle so the
|
|
19
|
+
* whole area stays in frame while the radius slider moves.
|
|
20
|
+
*/
|
|
21
|
+
export function RadiusMap({
|
|
22
|
+
center,
|
|
23
|
+
radiusKm,
|
|
24
|
+
color = "#1e3a8a",
|
|
25
|
+
className,
|
|
26
|
+
unavailableFallback,
|
|
27
|
+
}: {
|
|
28
|
+
readonly center: GeoPoint;
|
|
29
|
+
readonly radiusKm: number;
|
|
30
|
+
/** Circle fill/stroke colour. Defaults to the kit's deep blue. */
|
|
31
|
+
readonly color?: string;
|
|
32
|
+
readonly className?: string;
|
|
33
|
+
/** Shown when the environment has no WebGL; this package ships no copy. */
|
|
34
|
+
readonly unavailableFallback?: ReactNode;
|
|
35
|
+
}) {
|
|
36
|
+
const [map, setMap] = useState<maplibregl.Map | null>(null);
|
|
37
|
+
|
|
38
|
+
// `onReady` replays after a theme-driven basemap swap, which drops custom
|
|
39
|
+
// sources/layers: re-add them each time they are missing.
|
|
40
|
+
const handleReady = useCallback(
|
|
41
|
+
(instance: maplibregl.Map) => {
|
|
42
|
+
if (instance.getSource(SOURCE_ID) === undefined) {
|
|
43
|
+
instance.addSource(SOURCE_ID, {
|
|
44
|
+
type: "geojson",
|
|
45
|
+
data: { type: "FeatureCollection", features: [] },
|
|
46
|
+
});
|
|
47
|
+
instance.addLayer({
|
|
48
|
+
id: FILL_LAYER,
|
|
49
|
+
type: "fill",
|
|
50
|
+
source: SOURCE_ID,
|
|
51
|
+
filter: ["==", ["geometry-type"], "Polygon"],
|
|
52
|
+
paint: { "fill-color": color, "fill-opacity": 0.12 },
|
|
53
|
+
});
|
|
54
|
+
instance.addLayer({
|
|
55
|
+
id: LINE_LAYER,
|
|
56
|
+
type: "line",
|
|
57
|
+
source: SOURCE_ID,
|
|
58
|
+
filter: ["==", ["geometry-type"], "Polygon"],
|
|
59
|
+
paint: { "line-color": color, "line-width": 1.5 },
|
|
60
|
+
});
|
|
61
|
+
instance.addLayer({
|
|
62
|
+
id: CENTER_LAYER,
|
|
63
|
+
type: "circle",
|
|
64
|
+
source: SOURCE_ID,
|
|
65
|
+
filter: ["==", ["geometry-type"], "Point"],
|
|
66
|
+
paint: {
|
|
67
|
+
"circle-color": color,
|
|
68
|
+
"circle-radius": 5,
|
|
69
|
+
"circle-stroke-color": "#ffffff",
|
|
70
|
+
"circle-stroke-width": 2,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
setMap(instance);
|
|
75
|
+
},
|
|
76
|
+
[color],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (map === null) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const apply = () => {
|
|
84
|
+
const source = map.getSource(SOURCE_ID) as
|
|
85
|
+
| maplibregl.GeoJSONSource
|
|
86
|
+
| undefined;
|
|
87
|
+
if (source === undefined) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
source.setData({
|
|
91
|
+
type: "FeatureCollection",
|
|
92
|
+
features: [
|
|
93
|
+
circlePolygon(center, radiusKm),
|
|
94
|
+
{
|
|
95
|
+
type: "Feature",
|
|
96
|
+
geometry: {
|
|
97
|
+
type: "Point",
|
|
98
|
+
coordinates: [center.longitude, center.latitude],
|
|
99
|
+
},
|
|
100
|
+
properties: {},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
const bounds = circleBounds(center, radiusKm);
|
|
105
|
+
map.fitBounds(
|
|
106
|
+
[
|
|
107
|
+
[bounds.west, bounds.south],
|
|
108
|
+
[bounds.east, bounds.north],
|
|
109
|
+
],
|
|
110
|
+
{ padding: 24, animate: false },
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
if (map.isStyleLoaded()) {
|
|
114
|
+
apply();
|
|
115
|
+
} else {
|
|
116
|
+
// "idle" (unlike "load") also fires after a theme-driven style swap.
|
|
117
|
+
map.once("idle", apply);
|
|
118
|
+
}
|
|
119
|
+
}, [map, center, radiusKm]);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<MapView
|
|
123
|
+
className={cn("h-52 overflow-hidden rounded-lg border", className)}
|
|
124
|
+
center={[center.longitude, center.latitude]}
|
|
125
|
+
zoom={9}
|
|
126
|
+
options={{ attributionControl: false }}
|
|
127
|
+
onReady={handleReady}
|
|
128
|
+
unavailableFallback={unavailableFallback}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** A WGS84 point. Structural, so any `{ latitude, longitude }` fits. */
|
|
2
|
+
export type GeoPoint = {
|
|
3
|
+
readonly latitude: number;
|
|
4
|
+
readonly longitude: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const EARTH_RADIUS_KM = 6371;
|
|
8
|
+
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180;
|
|
9
|
+
const toDegrees = (radians: number): number => (radians * 180) / Math.PI;
|
|
10
|
+
|
|
11
|
+
/** Structural GeoJSON Polygon feature, assignable to a MapLibre geojson source. */
|
|
12
|
+
export type CirclePolygonFeature = {
|
|
13
|
+
readonly type: "Feature";
|
|
14
|
+
readonly geometry: {
|
|
15
|
+
readonly type: "Polygon";
|
|
16
|
+
readonly coordinates: [number, number][][];
|
|
17
|
+
};
|
|
18
|
+
readonly properties: Record<string, never>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A geodesic circle around `center` as a closed GeoJSON Polygon ring
|
|
23
|
+
* (`segments` + 1 points, first == last), for MapLibre fill/line layers such
|
|
24
|
+
* as the intervention-radius overlay. Each ring point is projected with the
|
|
25
|
+
* direct great-circle formula, so the drawn radius stays true at any latitude
|
|
26
|
+
* (a naive degree offset would flatten the circle away from the equator).
|
|
27
|
+
*/
|
|
28
|
+
export const circlePolygon = (
|
|
29
|
+
center: GeoPoint,
|
|
30
|
+
radiusKm: number,
|
|
31
|
+
segments = 64,
|
|
32
|
+
): CirclePolygonFeature => {
|
|
33
|
+
const angularDistance = radiusKm / EARTH_RADIUS_KM;
|
|
34
|
+
const centerLatitude = toRadians(center.latitude);
|
|
35
|
+
const centerLongitude = toRadians(center.longitude);
|
|
36
|
+
const ring: [number, number][] = [];
|
|
37
|
+
for (let step = 0; step <= segments; step++) {
|
|
38
|
+
const bearing = (step / segments) * 2 * Math.PI;
|
|
39
|
+
const pointLatitude = Math.asin(
|
|
40
|
+
Math.sin(centerLatitude) * Math.cos(angularDistance) +
|
|
41
|
+
Math.cos(centerLatitude) *
|
|
42
|
+
Math.sin(angularDistance) *
|
|
43
|
+
Math.cos(bearing),
|
|
44
|
+
);
|
|
45
|
+
const pointLongitude =
|
|
46
|
+
centerLongitude +
|
|
47
|
+
Math.atan2(
|
|
48
|
+
Math.sin(bearing) *
|
|
49
|
+
Math.sin(angularDistance) *
|
|
50
|
+
Math.cos(centerLatitude),
|
|
51
|
+
Math.cos(angularDistance) -
|
|
52
|
+
Math.sin(centerLatitude) * Math.sin(pointLatitude),
|
|
53
|
+
);
|
|
54
|
+
ring.push([toDegrees(pointLongitude), toDegrees(pointLatitude)]);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
type: "Feature",
|
|
58
|
+
geometry: { type: "Polygon", coordinates: [ring] },
|
|
59
|
+
properties: {},
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The circle's bounding box (WGS84 degrees), for `fitBounds`. Derived from the
|
|
65
|
+
* ring points so it shares the geodesic math above.
|
|
66
|
+
*/
|
|
67
|
+
export const circleBounds = (
|
|
68
|
+
center: GeoPoint,
|
|
69
|
+
radiusKm: number,
|
|
70
|
+
): { west: number; south: number; east: number; north: number } => {
|
|
71
|
+
const ring = circlePolygon(center, radiusKm).geometry.coordinates[0];
|
|
72
|
+
let west = Number.POSITIVE_INFINITY;
|
|
73
|
+
let south = Number.POSITIVE_INFINITY;
|
|
74
|
+
let east = Number.NEGATIVE_INFINITY;
|
|
75
|
+
let north = Number.NEGATIVE_INFINITY;
|
|
76
|
+
for (const [longitude, latitude] of ring) {
|
|
77
|
+
west = Math.min(west, longitude);
|
|
78
|
+
south = Math.min(south, latitude);
|
|
79
|
+
east = Math.max(east, longitude);
|
|
80
|
+
north = Math.max(north, latitude);
|
|
81
|
+
}
|
|
82
|
+
return { west, south, east, north };
|
|
83
|
+
};
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tailwind must scan this package or none of its classes are generated — the
|
|
3
|
+
* components ship as .tsx source, not as compiled CSS.
|
|
4
|
+
*
|
|
5
|
+
* `@source` resolves relative to the file that declares it, so this works
|
|
6
|
+
* wherever the package ends up: node_modules in an app, a symlink in a
|
|
7
|
+
* workspace. Consumers import this file and add nothing of their own.
|
|
8
|
+
*/
|
|
9
|
+
@source "./";
|
|
10
|
+
@source not "./**/*.test.*";
|