@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 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,4 @@
1
+ // Side-effect CSS imports (e.g. maplibre-gl's stylesheet) carry no type
2
+ // declarations; the app bundlers get this from `vite/client`, but this package
3
+ // typechecks standalone, so declare the module shape here.
4
+ declare module "*.css" {}
@@ -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.*";