feat: task 2.1 MapView singleton + mapReady gate

- pnpm add maplibre-gl + -D @types/geojson.
- src/map/core/styles.ts: defaultStyle (OSM raster bootstrap; 2.2
  replaces with the basemap-switcher descriptor table).
- src/map/core/map-view.tsx: module-level Map singleton lazily created
  on first <MapView> mount, attached to a class="trm-map-host" detached
  <div> that React refs append/remove on mount/unmount. Style-data
  lifecycle flips mapReady false on every styledata event, polls
  loaded() at 33ms intervals, flips ready true once the style is
  loaded — the canonical MapLibre style-swap dance.
- Exports getMap()/getMapReady()/subscribeMapReady()/useMapReady (via
  useSyncExternalStore for SSR-safe + concurrent-safe reads). getMap()
  throws if called pre-mount; the explicit failure mode beats a
  null-able top-level export.
- src/routes/_authed/monitor.tsx: new /monitor route, full-viewport
  <MapView /> for 2.1 (no children — subsequent tasks plug in here).
- src/routes/_authed/index.tsx: home-page card now links to /monitor.
- eslint.config.js: override for src/map/** + src/live/** disables
  react-refresh/only-export-components. Same pattern as the existing
  overrides for shadcn primitives and route files.

Deviation: spec sketched a top-level `map` constant export; implemented
as `getMap(): MapLibreMap` (a function) so the singleton stays lazy
until <MapView> mounts. Top-level constant would either force eager
init (breaks SSR/tests) or be nullable (footgun). The function form
throws a clear error if called pre-mount.

Bundle: /monitor lazy chunk is 1MB raw / 274KB gzipped (MapLibre + CSS).
Other routes unaffected. Vite chunk-size warning is harmless.
This commit is contained in:
2026-05-02 21:05:56 +02:00
parent 05543529e4
commit 87a738313e
17 changed files with 522 additions and 70 deletions
@@ -190,4 +190,30 @@ For 2.1, no children — the map renders the basemap and nothing else. Subsequen
## Done ## Done
(Filled in when the task lands.) Three new files + small updates to existing surfaces:
- **`src/map/core/styles.ts`** — `defaultStyle` exporting an OSM raster `StyleSpecification` (single raster source + raster layer + glyphs URL for label rendering on future symbol layers). 2.2 will replace this with the descriptor table + switcher.
- **`src/map/core/map-view.tsx`** — singleton + `<MapView>` + `mapReady` gate. Exports `getMap()` (throws if called pre-mount), `getMapReady()`, `subscribeMapReady()`, `useMapReady()` (via `useSyncExternalStore`). The singleton's container has class `trm-map-host` for CSS targeting if global resets ever bite. Style-data lifecycle handler (`onStyleData`) flips ready false → polls `loaded()` → flips ready true; that's the canonical MapLibre style-swap dance.
- **`src/routes/_authed/monitor.tsx`** — new route at `/monitor` rendering `<MapView />` full-viewport. No children for 2.1; subsequent tasks plug components in here.
- **`src/routes/_authed/index.tsx`** — home-page card now renders a `<Link to="/monitor">Open live map →</Link>`.
- **`eslint.config.js`** — added override for `src/map/**` and `src/live/**` disabling `react-refresh/only-export-components`. These modules intentionally co-export non-component utilities (singletons, factories, hooks) alongside the React component. Same pattern as the existing override for `src/ui/primitives/**` and `src/routes/**`.
**Deps installed:** `maplibre-gl@5.24.0` (runtime), `@types/geojson@7946.0.16` (devDep).
**Deviation flagged:**
The spec sketched the singleton's accessor as `map` (a top-level export). Implemented as `getMap(): MapLibreMap` instead — a function call rather than an exported constant. Two reasons:
1. The singleton is created lazily on first `<MapView>` mount, not at module import. A top-level `map` export would either force eager initialisation (breaks SSR / tests) or be `null` until something mounts (footgun).
2. `getMap()` throws a clear error if called before `<MapView>` mounts — a missing pre-condition that's easy to surface and easy to read in stack traces. A `null`-able export forces every consumer to add their own null check.
Side-effect-only `Map*` components (2.5+) call `getMap()` from inside their `useEffect` *after* `useMapReady()` returns true, by which point the singleton exists and the style is loaded.
**Smoke check (local `pnpm dev`):**
- `/monitor` renders a full-viewport OSM map.
- Pan / zoom / scroll-zoom all work.
- `/monitor``/``/monitor` keeps the map responsive (singleton survives navigation).
- Browser console: `(await import('@/map/core/map-view')).getMapReady()` returns `true` after the page settles.
**Bundle impact:** the `monitor` route is a new lazy chunk (~1MB raw / 274KB gzipped — MapLibre + its CSS). It's loaded only when the route is visited; all other routes are unaffected. Vite emits a chunk-size warning >500KB; expected and harmless. The home page's bundle is unchanged.
Landed in `PENDING_SHA`.
@@ -74,7 +74,9 @@ export const BASEMAP_STYLES: BasemapDescriptor[] = [
id: 'esri-satellite', id: 'esri-satellite',
label: 'Satellite', label: 'Satellite',
style: styleCustom({ style: styleCustom({
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
attribution: 'Tiles © Esri', attribution: 'Tiles © Esri',
maxZoom: 19, maxZoom: 19,
}), }),
@@ -37,7 +37,11 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
interface LiveClient { interface LiveClient {
connect(): void; // idempotent connect(): void; // idempotent
close(): void; close(): void;
subscribe(topic: string): Promise<{ ok: true; snapshot: PositionEntry[] } | { ok: false; code: string; message?: string }>; subscribe(
topic: string,
): Promise<
{ ok: true; snapshot: PositionEntry[] } | { ok: false; code: string; message?: string }
>;
unsubscribe(topic: string): Promise<void>; unsubscribe(topic: string): Promise<void>;
onPosition(handler: (msg: PositionEntry & { topic: string }) => void): () => void; onPosition(handler: (msg: PositionEntry & { topic: string }) => void): () => void;
} }
@@ -50,6 +54,7 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
- `Coalescer.push(p: PositionEntry): void` — non-blocking; writes to the buffer. - `Coalescer.push(p: PositionEntry): void` — non-blocking; writes to the buffer.
- Internally: per-device map of latest-position; rAF loop flushes to the consumer once per frame; clears the buffer. - Internally: per-device map of latest-position; rAF loop flushes to the consumer once per frame; clears the buffer.
- **`src/live/position-store.ts`** — Zustand store: - **`src/live/position-store.ts`** — Zustand store:
```ts ```ts
type PositionState = { type PositionState = {
latestByDevice: Map<string, PositionEntry>; latestByDevice: Map<string, PositionEntry>;
@@ -65,7 +70,9 @@ This is the throughput-discipline core. It runs whether or not the map is mounte
selectDevice(deviceId: string | null): void; selectDevice(deviceId: string | null): void;
}; };
``` ```
Trail ring buffer is bounded by `MAX_TRAIL_LENGTH` (default 200, see `MaxTrailLength` config below). Trail ring buffer is bounded by `MAX_TRAIL_LENGTH` (default 200, see `MaxTrailLength` config below).
- **`src/live/connection-store.ts`** — Zustand store: - **`src/live/connection-store.ts`** — Zustand store:
```ts ```ts
type ConnectionState = { type ConnectionState = {
@@ -104,7 +111,10 @@ function createLiveClient({ url }: { url: string }): LiveClient {
let state: ClientState = { kind: 'idle' }; let state: ClientState = { kind: 'idle' };
const subscriptions = new Set<string>(); const subscriptions = new Set<string>();
const positionHandlers = new Set<(msg: PositionEntry & { topic: string }) => void>(); const positionHandlers = new Set<(msg: PositionEntry & { topic: string }) => void>();
const pendingSubscribes = new Map<string, { resolve: (r: SubscribeResult) => void; reject: (e: unknown) => void }>(); const pendingSubscribes = new Map<
string,
{ resolve: (r: SubscribeResult) => void; reject: (e: unknown) => void }
>();
function connect() { function connect() {
if (state.kind === 'connecting' || state.kind === 'connected') return; if (state.kind === 'connecting' || state.kind === 'connected') return;
@@ -132,8 +142,7 @@ function createLiveClient({ url }: { url: string }): LiveClient {
function scheduleReconnect() { function scheduleReconnect() {
if (state.kind === 'closed') return; if (state.kind === 'closed') return;
const attempt = const attempt = state.kind === 'reconnecting' ? state.attempt + 1 : 1;
state.kind === 'reconnecting' ? state.attempt + 1 : 1;
const delay = Math.min( const delay = Math.min(
RECONNECT_BACKOFF_MS[attempt - 1] ?? RECONNECT_CEILING_MS, RECONNECT_BACKOFF_MS[attempt - 1] ?? RECONNECT_CEILING_MS,
RECONNECT_CEILING_MS, RECONNECT_CEILING_MS,
@@ -330,7 +339,7 @@ Mount inside `<AuthBootstrap>` so it only connects when authenticated. On logout
- **Map rendering.** That's 2.5 / 2.6 — they read from the store. - **Map rendering.** That's 2.5 / 2.6 — they read from the store.
- **Event picking.** 2.7 — calls `client.subscribe('event:<id>')` and handles the snapshot. - **Event picking.** 2.7 — calls `client.subscribe('event:<id>')` and handles the snapshot.
- **Connection-status UI.** 2.9 reads from `connection-store` and renders chips / banners. - **Connection-status UI.** 2.9 reads from `connection-store` and renders chips / banners.
- **Backpressure / drop-oldest.** Defer until measured; the rAF coalescer caps the *flush* rate, not the *receive* rate. If receive ever overwhelms the buffer, add a per-device queue cap. - **Backpressure / drop-oldest.** Defer until measured; the rAF coalescer caps the _flush_ rate, not the _receive_ rate. If receive ever overwhelms the buffer, add a per-device queue cap.
## Acceptance criteria ## Acceptance criteria
@@ -348,7 +357,7 @@ Mount inside `<AuthBootstrap>` so it only connects when authenticated. On logout
- **rAF in background tabs.** Browsers throttle rAF when the tab is backgrounded. The coalescer effectively pauses; positions buffer until the tab is foregrounded again. Acceptable — operators backgrounding the tab don't need real-time updates. - **rAF in background tabs.** Browsers throttle rAF when the tab is backgrounded. The coalescer effectively pauses; positions buffer until the tab is foregrounded again. Acceptable — operators backgrounding the tab don't need real-time updates.
- **Zustand `Map<>` reactivity.** Zustand subscribers fire on reference changes, not deep equality. Wrapping `latest` and `trails` in `new Map(...)` per update is the right pattern; selectors derive specific deviceIds via `usePositionStore((s) => s.latestByDevice.get(deviceId))`. - **Zustand `Map<>` reactivity.** Zustand subscribers fire on reference changes, not deep equality. Wrapping `latest` and `trails` in `new Map(...)` per update is the right pattern; selectors derive specific deviceIds via `usePositionStore((s) => s.latestByDevice.get(deviceId))`.
- **Trail-direction colour.** If we later add speed-coloured per-segment trails (2.6's open question), the trail entries need `speed` carried through. Already in `PositionEntry`. Good. - **Trail-direction colour.** If we later add speed-coloured per-segment trails (2.6's open question), the trail entries need `speed` carried through. Already in `PositionEntry`. Good.
- **Cookie auth on WS.** Browser sends the session cookie automatically with the WS upgrade *only if same-origin*. Verify the reverse-proxy + Vite-dev-proxy paths preserve same-origin (they do — `/ws-live` is on the page's origin). Worth a smoke test. - **Cookie auth on WS.** Browser sends the session cookie automatically with the WS upgrade _only if same-origin_. Verify the reverse-proxy + Vite-dev-proxy paths preserve same-origin (they do — `/ws-live` is on the page's origin). Worth a smoke test.
## Done ## Done
+8 -4
View File
@@ -9,7 +9,7 @@
Render the trailing path of every (or only the selected) device as a polyline. Reads from the per-device ring buffer in the position store (2.4 capped at 200 points). Visible as a thin line behind the moving marker — operators see "where this racer has been in the last few minutes." Render the trailing path of every (or only the selected) device as a polyline. Reads from the per-device ring buffer in the position store (2.4 capped at 200 points). Visible as a thin line behind the moving marker — operators see "where this racer has been in the last few minutes."
After this task lands, the live map shows motion *history*, not just current position. That's what makes "live tracking" feel live. After this task lands, the live map shows motion _history_, not just current position. That's what makes "live tracking" feel live.
## Deliverables ## Deliverables
@@ -59,7 +59,11 @@ The third arg to `addLayer` puts the line layer below the position symbols; trai
### Feature builder — flat colour per device ### Feature builder — flat colour per device
```ts ```ts
function buildTrailFeature(deviceId: string, points: PositionEntry[], device?: Device): Feature | null { function buildTrailFeature(
deviceId: string,
points: PositionEntry[],
device?: Device,
): Feature | null {
if (points.length < 2) return null; if (points.length < 2) return null;
return { return {
type: 'Feature', type: 'Feature',
@@ -111,7 +115,7 @@ useEffect(() => {
### Speed-coloured per-segment (deferred decision) ### Speed-coloured per-segment (deferred decision)
Traccar's replay mode renders one line *segment* per consecutive position pair, coloured by the second point's speed. For live trails this would mean N-1 features per device per update — heavier but visually richer. Operators glance at colour to read "fast / slow / stopped." Traccar's replay mode renders one line _segment_ per consecutive position pair, coloured by the second point's speed. For live trails this would mean N-1 features per device per update — heavier but visually richer. Operators glance at colour to read "fast / slow / stopped."
If we decide to ship this in v1: replace `LineString` with multiple two-point `LineString`s and colour per segment. The setData payload grows; the line layer's `'line-color'` becomes `['get', 'segmentColor']` instead of the device's flat colour. If we decide to ship this in v1: replace `LineString` with multiple two-point `LineString`s and colour per segment. The setData payload grows; the line layer's `'line-color'` becomes `['get', 'segmentColor']` instead of the device's flat colour.
@@ -136,7 +140,7 @@ If operators want a longer-trail mode: a slider in the prefs (`50 / 200 / 500 /
- [ ] With the dogfood seed and synthetic positions, `/monitor` shows a polyline trailing each device as it moves. - [ ] With the dogfood seed and synthetic positions, `/monitor` shows a polyline trailing each device as it moves.
- [ ] Switching the trail-mode toggle (none / selected / all) immediately changes which trails render. None → empty layer. Selected → only the selected device. All → every device. - [ ] Switching the trail-mode toggle (none / selected / all) immediately changes which trails render. None → empty layer. Selected → only the selected device. All → every device.
- [ ] Trail follows the device's most recent N positions; old points fall off the front as new ones append (verify trail is always exactly the configured length once steady-state). - [ ] Trail follows the device's most recent N positions; old points fall off the front as new ones append (verify trail is always exactly the configured length once steady-state).
- [ ] Trails sit *underneath* markers (verifiable visually — markers paint over the polyline). - [ ] Trails sit _underneath_ markers (verifiable visually — markers paint over the polyline).
- [ ] Style swap → trails reappear after the basemap change. - [ ] Style swap → trails reappear after the basemap change.
- [ ] No `setData` warnings (e.g. coordinate-array mismatch). - [ ] No `setData` warnings (e.g. coordinate-array mismatch).
@@ -14,6 +14,7 @@ After this task, the live map is operator-driven — no hardcoded event id, no d
## Deliverables ## Deliverables
- **`src/data/events.ts`** — TanStack Query hook + types: - **`src/data/events.ts`** — TanStack Query hook + types:
```ts ```ts
export type EventSummary = { export type EventSummary = {
id: string; id: string;
@@ -27,7 +28,9 @@ After this task, the live map is operator-driven — no hardcoded event id, no d
export function useUserEvents(): UseQueryResult<EventSummary[]>; export function useUserEvents(): UseQueryResult<EventSummary[]>;
``` ```
Fetches `/items/events?fields=id,name,slug,discipline,starts_at,ends_at,organization_id&sort=-starts_at`. Directus's RLS handles "what events does this user have access to" via the cookie. Stale time 5 minutes. Fetches `/items/events?fields=id,name,slug,discipline,starts_at,ends_at,organization_id&sort=-starts_at`. Directus's RLS handles "what events does this user have access to" via the cookie. Stale time 5 minutes.
- **`src/ui/components/event-picker.tsx`** — `<EventPicker />` component. Top-of-page dropdown showing the events list. Selected event highlights. On select, calls a callback (passed in by the monitor route). - **`src/ui/components/event-picker.tsx`** — `<EventPicker />` component. Top-of-page dropdown showing the events list. Selected event highlights. On select, calls a callback (passed in by the monitor route).
- **`src/routes/_authed/monitor.tsx`** updated — orchestrates the picker → subscribe → snapshot → store flow: - **`src/routes/_authed/monitor.tsx`** updated — orchestrates the picker → subscribe → snapshot → store flow:
```tsx ```tsx
@@ -107,7 +110,8 @@ export function useActiveEventOrchestration() {
const clearStore = usePositionStore((s) => s.clearForEvent); const clearStore = usePositionStore((s) => s.clearForEvent);
const activeEventId = usePositionStore((s) => s.activeEventId); const activeEventId = usePositionStore((s) => s.activeEventId);
return useCallback(async (eventId: string | null) => { return useCallback(
async (eventId: string | null) => {
const client = getLiveClient(); const client = getLiveClient();
// Tear down the previous subscription. // Tear down the previous subscription.
@@ -127,7 +131,9 @@ export function useActiveEventOrchestration() {
} }
setActiveEventInStore(eventId, result.snapshot); setActiveEventInStore(eventId, result.snapshot);
localStorage.setItem('trm-active-event-id', eventId); localStorage.setItem('trm-active-event-id', eventId);
}, [activeEventId, clearStore, setActiveEventInStore]); },
[activeEventId, clearStore, setActiveEventInStore],
);
} }
``` ```
+7 -1
View File
@@ -108,7 +108,13 @@ Two behaviours:
### `<MapCamera coordinates>` — imperative one-shot ### `<MapCamera coordinates>` — imperative one-shot
```tsx ```tsx
function MapCamera({ coordinates, padding }: { coordinates: [number, number][]; padding?: number }) { function MapCamera({
coordinates,
padding,
}: {
coordinates: [number, number][];
padding?: number;
}) {
useEffect(() => { useEffect(() => {
if (coordinates.length === 0) return; if (coordinates.length === 0) return;
if (coordinates.length === 1) { if (coordinates.length === 1) {
@@ -7,12 +7,12 @@
## Goal ## Goal
Show operators *just enough* about system state so they can answer two questions at a glance: Show operators _just enough_ about system state so they can answer two questions at a glance:
1. "Is the SPA still connected to live data?" — global WS status. 1. "Is the SPA still connected to live data?" — global WS status.
2. "Is this specific device still reporting?" — per-device last-seen age. 2. "Is this specific device still reporting?" — per-device last-seen age.
Not noisy. Subtle UI; not banners and modal warnings. The design ethos: operators trust the map until something goes wrong, at which point they need a quick read of *what* went wrong without a wall of red. Not noisy. Subtle UI; not banners and modal warnings. The design ethos: operators trust the map until something goes wrong, at which point they need a quick read of _what_ went wrong without a wall of red.
## Deliverables ## Deliverables
@@ -134,7 +134,7 @@ useEffect(() => {
- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean. - [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` clean.
- [ ] On `/monitor`, the connection chip top-right shows "Live" with a green dot during normal operation. - [ ] On `/monitor`, the connection chip top-right shows "Live" with a green dot during normal operation.
- [ ] Disconnecting the network: the chip flips to "Reconnecting…" within a few seconds; on reconnect, back to "Live"; on permanent disconnect (close + no reconnect for 30s+), shows "Offline · last live HH:MM:SS". - [ ] Disconnecting the network: the chip flips to "Reconnecting…" within a few seconds; on reconnect, back to "Live"; on permanent disconnect (close + no reconnect for 30s+), shows "Offline · last live HH:MM:SS".
- [ ] A device that hasn't reported in 5+ minutes appears noticeably faded on the map (not invisible — operators still see *where it was last seen*). - [ ] A device that hasn't reported in 5+ minutes appears noticeably faded on the map (not invisible — operators still see _where it was last seen_).
- [ ] On a fresh page load with no positions yet, the chip says "Connecting…" briefly, then "Live" once the snapshot arrives. - [ ] On a fresh page load with no positions yet, the chip says "Connecting…" briefly, then "Live" once the snapshot arrives.
- [ ] No banner / toast spam during normal operation. - [ ] No banner / toast spam during normal operation.
+4 -4
View File
@@ -47,8 +47,8 @@ When Phase 2 is done:
## Tasks ## Tasks
| # | Task | Status | | # | Task | Status |
| --- | ----------------------------------------------------------------------------------------------------- | ------ | | --- | ------------------------------------------------------------------------------------------ | ------ |
| 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | | | 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | 🟩 |
| 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | ⬜ | | 2.2 | [Tile-source switcher](./02-tile-source-switcher.md) | ⬜ |
| 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | ⬜ | | 2.3 | [Sprite preload (racing categories)](./03-sprite-preload.md) | ⬜ |
| 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | ⬜ | | 2.4 | [WS client + rAF coalescer + Zustand position store](./04-ws-client-and-position-store.md) | ⬜ |
@@ -95,9 +95,9 @@ spa/
## Tech stack additions ## Tech stack additions
- **`maplibre-gl`** — the rendering engine. Imported directly (no `react-map-gl` wrapper — see [[maps-architecture]] for why). - **`maplibre-gl`** — the rendering engine. Imported directly (no `react-map-gl` wrapper — see [[maps-architecture]] for why).
- **`maplibre-google-maps`** *(optional, runtime-config-gated)* — protocol adapter for Google's Map Tiles API. Loaded only if `googleMapsKey` is present in the runtime config. - **`maplibre-google-maps`** _(optional, runtime-config-gated)_ — protocol adapter for Google's Map Tiles API. Loaded only if `googleMapsKey` is present in the runtime config.
- **`@types/geojson`** — devDep for typing `Feature` / `FeatureCollection`. - **`@types/geojson`** — devDep for typing `Feature` / `FeatureCollection`.
- **`pmtiles`** *(optional, defer)* — for offline tile archives in remote terrain. Out of scope for v1 of Phase 2. - **`pmtiles`** _(optional, defer)_ — for offline tile archives in remote terrain. Out of scope for v1 of Phase 2.
No new test infra (Vitest is Phase 3.6). No new test infra (Vitest is Phase 3.6).
+9
View File
@@ -45,4 +45,13 @@ export default defineConfig([
'react-refresh/only-export-components': 'off', 'react-refresh/only-export-components': 'off',
}, },
}, },
{
// Map and live modules co-export non-component utilities (singletons,
// factories, hooks) alongside React components. The fast-refresh rule
// doesn't apply usefully here.
files: ['src/map/**/*.{ts,tsx}', 'src/live/**/*.{ts,tsx}'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
]); ]);
+2
View File
@@ -22,6 +22,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"maplibre-gl": "^5.24.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
@@ -37,6 +38,7 @@
"@tanstack/react-router-devtools": "^1.166.13", "@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/router-cli": "^1.166.40", "@tanstack/router-cli": "^1.166.40",
"@tanstack/router-plugin": "^1.167.32", "@tanstack/router-plugin": "^1.167.32",
"@types/geojson": "^7946.0.16",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
+203
View File
@@ -29,6 +29,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.14.0 specifier: ^1.14.0
version: 1.14.0(react@19.2.5) version: 1.14.0(react@19.2.5)
maplibre-gl:
specifier: ^5.24.0
version: 5.24.0
radix-ui: radix-ui:
specifier: ^1.4.3 specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -69,6 +72,9 @@ importers:
'@tanstack/router-plugin': '@tanstack/router-plugin':
specifier: ^1.167.32 specifier: ^1.167.32
version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1)) version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(jiti@2.6.1))
'@types/geojson':
specifier: ^7946.0.16
version: 7946.0.16
'@types/node': '@types/node':
specifier: ^24.12.2 specifier: ^24.12.2
version: 24.12.2 version: 24.12.2
@@ -308,6 +314,42 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mapbox/jsonlint-lines-primitives@2.0.2':
resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==}
engines: {node: '>= 0.6'}
'@mapbox/point-geometry@1.1.0':
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
'@mapbox/tiny-sdf@2.1.0':
resolution: {integrity: sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==}
'@mapbox/unitbezier@0.0.1':
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
'@mapbox/vector-tile@2.0.4':
resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==}
'@mapbox/whoots-js@3.1.0':
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
engines: {node: '>=6.0.0'}
'@maplibre/geojson-vt@5.0.4':
resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==}
'@maplibre/geojson-vt@6.1.0':
resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==}
'@maplibre/maplibre-gl-style-spec@24.8.2':
resolution: {integrity: sha512-bAFyH5a53/PAlTPftFBl/lr6jIcjWQtiVnNl5nobuGPt9duXRPjuFGetNG2fLzOhJyCk3BwB3xERdS1l/D4h4w==}
hasBin: true
'@maplibre/mlt@1.1.9':
resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==}
'@maplibre/vt-pbf@4.3.0':
resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==}
'@napi-rs/wasm-runtime@1.1.4': '@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies: peerDependencies:
@@ -1322,6 +1364,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1336,6 +1381,9 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/supercluster@7.1.3':
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
'@typescript-eslint/eslint-plugin@8.59.1': '@typescript-eslint/eslint-plugin@8.59.1':
resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1531,6 +1579,9 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
electron-to-chromium@1.5.349: electron-to-chromium@1.5.349:
resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
@@ -1679,6 +1730,9 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
gl-matrix@3.4.4:
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -1765,11 +1819,17 @@ packages:
json-stable-stringify-without-jsonify@1.0.1: json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
json-stringify-pretty-compact@4.0.0:
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
json5@2.2.3: json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
kdbush@4.0.2:
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1866,13 +1926,23 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
maplibre-gl@5.24.0:
resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
minimatch@10.2.5: minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
murmurhash-js@1.0.0:
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
nanoid@3.3.12: nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1911,6 +1981,10 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pbf@4.0.1:
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
hasBin: true
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1926,6 +2000,9 @@ packages:
resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
potpack@2.1.0:
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1939,10 +2016,16 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
protocol-buffers-schema@3.6.1:
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
radix-ui@1.4.3: radix-ui@1.4.3:
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
peerDependencies: peerDependencies:
@@ -2009,11 +2092,17 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
resolve-protobuf-schema@2.1.0:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
rolldown@1.0.0-rc.17: rolldown@1.0.0-rc.17:
resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -2056,6 +2145,9 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
supercluster@8.0.1:
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
synckit@0.11.12: synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@@ -2074,6 +2166,9 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyqueue@3.0.0:
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
@@ -2475,6 +2570,52 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mapbox/jsonlint-lines-primitives@2.0.2': {}
'@mapbox/point-geometry@1.1.0': {}
'@mapbox/tiny-sdf@2.1.0': {}
'@mapbox/unitbezier@0.0.1': {}
'@mapbox/vector-tile@2.0.4':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@types/geojson': 7946.0.16
pbf: 4.0.1
'@mapbox/whoots-js@3.1.0': {}
'@maplibre/geojson-vt@5.0.4': {}
'@maplibre/geojson-vt@6.1.0':
dependencies:
kdbush: 4.0.2
'@maplibre/maplibre-gl-style-spec@24.8.2':
dependencies:
'@mapbox/jsonlint-lines-primitives': 2.0.2
'@mapbox/unitbezier': 0.0.1
json-stringify-pretty-compact: 4.0.0
minimist: 1.2.8
quickselect: 3.0.0
rw: 1.3.3
tinyqueue: 3.0.0
'@maplibre/mlt@1.1.9':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@maplibre/vt-pbf@4.3.0':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@mapbox/vector-tile': 2.0.4
'@maplibre/geojson-vt': 5.0.4
'@types/geojson': 7946.0.16
'@types/supercluster': 7.1.3
pbf: 4.0.1
supercluster: 8.0.1
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
@@ -3484,6 +3625,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@24.12.2': '@types/node@24.12.2':
@@ -3498,6 +3641,10 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/supercluster@7.1.3':
dependencies:
'@types/geojson': 7946.0.16
'@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)': '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3))(eslint@10.3.0(jiti@2.6.1))(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -3711,6 +3858,8 @@ snapshots:
diff@8.0.4: {} diff@8.0.4: {}
earcut@3.0.2: {}
electron-to-chromium@1.5.349: {} electron-to-chromium@1.5.349: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -3859,6 +4008,8 @@ snapshots:
get-nonce@1.0.1: {} get-nonce@1.0.1: {}
gl-matrix@3.4.4: {}
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@@ -3917,8 +4068,12 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-pretty-compact@4.0.0: {}
json5@2.2.3: {} json5@2.2.3: {}
kdbush@4.0.2: {}
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -3993,12 +4148,38 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
maplibre-gl@5.24.0:
dependencies:
'@mapbox/jsonlint-lines-primitives': 2.0.2
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.1.0
'@mapbox/unitbezier': 0.0.1
'@mapbox/vector-tile': 2.0.4
'@mapbox/whoots-js': 3.1.0
'@maplibre/geojson-vt': 6.1.0
'@maplibre/maplibre-gl-style-spec': 24.8.2
'@maplibre/mlt': 1.1.9
'@maplibre/vt-pbf': 4.3.0
'@types/geojson': 7946.0.16
earcut: 3.0.2
gl-matrix: 3.4.4
kdbush: 4.0.2
murmurhash-js: 1.0.0
pbf: 4.0.1
potpack: 2.1.0
quickselect: 3.0.0
tinyqueue: 3.0.0
minimatch@10.2.5: minimatch@10.2.5:
dependencies: dependencies:
brace-expansion: 5.0.5 brace-expansion: 5.0.5
minimist@1.2.8: {}
ms@2.1.3: {} ms@2.1.3: {}
murmurhash-js@1.0.0: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@@ -4030,6 +4211,10 @@ snapshots:
pathe@2.0.3: {} pathe@2.0.3: {}
pbf@4.0.1:
dependencies:
resolve-protobuf-schema: 2.1.0
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.2: {} picomatch@2.3.2: {}
@@ -4042,6 +4227,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
potpack@2.1.0: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1: prettier-linter-helpers@1.0.1:
@@ -4050,8 +4237,12 @@ snapshots:
prettier@3.8.3: {} prettier@3.8.3: {}
protocol-buffers-schema@3.6.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
quickselect@3.0.0: {}
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -4159,6 +4350,10 @@ snapshots:
require-directory@2.1.1: {} require-directory@2.1.1: {}
resolve-protobuf-schema@2.1.0:
dependencies:
protocol-buffers-schema: 3.6.1
rolldown@1.0.0-rc.17: rolldown@1.0.0-rc.17:
dependencies: dependencies:
'@oxc-project/types': 0.127.0 '@oxc-project/types': 0.127.0
@@ -4180,6 +4375,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
rw@1.3.3: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}
semver@6.3.1: {} semver@6.3.1: {}
@@ -4210,6 +4407,10 @@ snapshots:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
supercluster@8.0.1:
dependencies:
kdbush: 4.0.2
synckit@0.11.12: synckit@0.11.12:
dependencies: dependencies:
'@pkgr/core': 0.2.9 '@pkgr/core': 0.2.9
@@ -4225,6 +4426,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinyqueue@3.0.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react';
import maplibregl, { type Map as MapLibreMap } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { defaultStyle } from './styles';
// ---------- Singleton ----------------------------------------------------
let _map: MapLibreMap | null = null;
let _container: HTMLDivElement | null = null;
function getOrCreateMap(): MapLibreMap {
if (_map && _container) return _map;
_container = document.createElement('div');
_container.className = 'trm-map-host';
_container.style.width = '100%';
_container.style.height = '100%';
_map = new maplibregl.Map({
container: _container,
style: defaultStyle,
attributionControl: { compact: true },
});
_map.on('styledata', onStyleData);
return _map;
}
/**
* Read-only access to the singleton. Throws if called before `<MapView>`
* has mounted (and therefore before the singleton is created).
*
* Side-effect-only `Map*` components should call this inside their
* `useEffect` *after* the `mapReady` gate has flipped true — by which
* point the singleton exists and the style has loaded.
*/
export function getMap(): MapLibreMap {
if (!_map) {
throw new Error(
'getMap() called before <MapView> mounted. Wrap the call in a Map* component or wait for useMapReady().',
);
}
return _map;
}
// ---------- mapReady gate -----------------------------------------------
let _ready = false;
const _readyListeners = new Set<(ready: boolean) => void>();
function setReady(value: boolean): void {
if (_ready === value) return;
_ready = value;
for (const cb of _readyListeners) cb(value);
}
export function getMapReady(): boolean {
return _ready;
}
export function subscribeMapReady(cb: (ready: boolean) => void): () => void {
_readyListeners.add(cb);
return () => {
_readyListeners.delete(cb);
};
}
export function useMapReady(): boolean {
return useSyncExternalStore(
subscribeMapReady,
getMapReady,
() => false, // SSR fallback
);
}
/**
* Style-data lifecycle handler.
*
* `styledata` fires for every style mutation (initial load, setStyle,
* source updates). We treat it as "the style might have changed; re-check
* loaded() and gate the children". `setReady(false)` first so children
* unmount and clean up their custom sources/layers; once `loaded()` is
* true, flip back to ready and let them remount.
*/
function onStyleData(): void {
setReady(false);
const check = (): void => {
if (_map?.loaded()) {
setReady(true);
} else {
setTimeout(check, 33);
}
};
check();
}
// ---------- <MapView> ----------------------------------------------------
/**
* The canonical entry point for any view that uses the map.
*
* Mounts the singleton's detached `<div>` into a React-managed ref on
* mount; detaches on unmount. The map instance itself survives navigation
* — its WebGL context, registered sprites, and basemap state all persist.
*
* Children render only when `mapReady` is true, so side-effect-only
* `Map*` components can safely call `getMap().addSource(...)` from their
* `useEffect` setup without racing the style load.
*/
export function MapView({ children }: { children?: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const ready = useMapReady();
useEffect(() => {
const map = getOrCreateMap();
const container = _container;
const host = ref.current;
if (!container || !host) return;
host.appendChild(container);
map.resize();
const onWindowResize = (): void => {
map.resize();
};
window.addEventListener('resize', onWindowResize);
return () => {
window.removeEventListener('resize', onWindowResize);
// Detach but DON'T destroy the map — singleton lives on.
if (container.parentNode === host) {
host.removeChild(container);
}
};
}, []);
return (
<div ref={ref} className="absolute inset-0">
{ready && children}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { StyleSpecification } from 'maplibre-gl';
/**
* Bootstrap basemap style used until task 2.2 (basemap switcher) lands.
*
* Plain OSM raster tiles. Free, attribution-required, polite User-Agent
* recommended for any production load. For dogfood-scale traffic this is
* fine.
*/
export const defaultStyle: StyleSpecification = {
version: 8,
sources: {
raster: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
minzoom: 0,
maxzoom: 19,
},
},
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf',
layers: [{ id: 'raster', type: 'raster', source: 'raster' }],
};
+9 -4
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import { useAuthStore } from '@/auth'; import { useAuthStore } from '@/auth';
import { LogoutButton } from '@/ui/components/logout-button'; import { LogoutButton } from '@/ui/components/logout-button';
import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card';
@@ -40,11 +40,16 @@ function HomePage() {
<CardHeader> <CardHeader>
<CardTitle>Live monitoring map</CardTitle> <CardTitle>Live monitoring map</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The live-position map lands in Phase 2 once the Processor's WebSocket endpoint is Watch registered devices report their positions in real time.
shipped (Phase 1.5 already complete on the processor side).
</p> </p>
<Link
to="/monitor"
className="inline-flex items-center gap-1 text-sm font-medium underline underline-offset-4"
>
Open live map
</Link>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+17
View File
@@ -0,0 +1,17 @@
import { createFileRoute } from '@tanstack/react-router';
import { MapView } from '@/map/core/map-view';
export const Route = createFileRoute('/_authed/monitor')({
component: MonitorPage,
});
function MonitorPage() {
return (
// 3.5rem ≈ 56 px reserved for a future top-bar (Phase 3 chrome).
// For now the parent _authed layout has no top-bar, so the map can
// fill the full viewport — adjust when chrome lands.
<div className="relative h-[calc(100vh-0rem)] w-full">
<MapView />
</div>
);
}