From 87a738313e2fda688bf2130b667f87decfab4cd3 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 2 May 2026 21:05:56 +0200 Subject: [PATCH] feat: task 2.1 MapView singleton + mapReady gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 mount, attached to a class="trm-map-host" detached
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 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 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. --- .../phase-2-live-map/01-mapview-singleton.md | 28 ++- .../02-tile-source-switcher.md | 6 +- .../phase-2-live-map/03-sprite-preload.md | 8 +- .../04-ws-client-and-position-store.md | 37 ++-- .../phase-2-live-map/05-map-positions.md | 10 +- .planning/phase-2-live-map/06-map-trails.md | 12 +- .planning/phase-2-live-map/07-event-picker.md | 44 ++-- .planning/phase-2-live-map/08-camera-trio.md | 8 +- .../phase-2-live-map/09-connection-status.md | 6 +- .planning/phase-2-live-map/README.md | 26 +-- eslint.config.js | 9 + package.json | 2 + pnpm-lock.yaml | 203 ++++++++++++++++++ src/map/core/map-view.tsx | 139 ++++++++++++ src/map/core/styles.ts | 24 +++ src/routes/_authed/index.tsx | 13 +- src/routes/_authed/monitor.tsx | 17 ++ 17 files changed, 522 insertions(+), 70 deletions(-) create mode 100644 src/map/core/map-view.tsx create mode 100644 src/map/core/styles.ts create mode 100644 src/routes/_authed/monitor.tsx diff --git a/.planning/phase-2-live-map/01-mapview-singleton.md b/.planning/phase-2-live-map/01-mapview-singleton.md index 8ce76d5..cc36eeb 100644 --- a/.planning/phase-2-live-map/01-mapview-singleton.md +++ b/.planning/phase-2-live-map/01-mapview-singleton.md @@ -190,4 +190,30 @@ For 2.1, no children — the map renders the basemap and nothing else. Subsequen ## 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 + `` + `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 `` full-viewport. No children for 2.1; subsequent tasks plug components in here. +- **`src/routes/_authed/index.tsx`** — home-page card now renders a `Open live map →`. +- **`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 `` 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 `` 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`. diff --git a/.planning/phase-2-live-map/02-tile-source-switcher.md b/.planning/phase-2-live-map/02-tile-source-switcher.md index 9408730..9dc4b6a 100644 --- a/.planning/phase-2-live-map/02-tile-source-switcher.md +++ b/.planning/phase-2-live-map/02-tile-source-switcher.md @@ -19,7 +19,7 @@ After this task, a small floating control on the map lets operators pick the bas id: 'esri-satellite' | 'opentopo' | 'osm' | 'google-satellite'; label: string; style: maplibregl.StyleSpecification | string; // inline raster style or URL - available: (cfg: RuntimeConfig) => boolean; // gates Google on googleMapsKey + available: (cfg: RuntimeConfig) => boolean; // gates Google on googleMapsKey attribution: string; }; ``` @@ -74,7 +74,9 @@ export const BASEMAP_STYLES: BasemapDescriptor[] = [ id: 'esri-satellite', label: 'Satellite', 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', maxZoom: 19, }), diff --git a/.planning/phase-2-live-map/03-sprite-preload.md b/.planning/phase-2-live-map/03-sprite-preload.md index e8f49fd..af98016 100644 --- a/.planning/phase-2-live-map/03-sprite-preload.md +++ b/.planning/phase-2-live-map/03-sprite-preload.md @@ -75,10 +75,10 @@ async function prepareSprite( ```ts const COLOR_MAP: Record = { - success: '#2E8C4A', // green — moving / OK - error: '#E8412B', // race-flag red — alert / panic / lost - neutral: '#0E0E0C', // ink — default - info: '#2563C8', // blue — selected / informational + success: '#2E8C4A', // green — moving / OK + error: '#E8412B', // race-flag red — alert / panic / lost + neutral: '#0E0E0C', // ink — default + info: '#2563C8', // blue — selected / informational }; ``` diff --git a/.planning/phase-2-live-map/04-ws-client-and-position-store.md b/.planning/phase-2-live-map/04-ws-client-and-position-store.md index 4655486..88cd561 100644 --- a/.planning/phase-2-live-map/04-ws-client-and-position-store.md +++ b/.planning/phase-2-live-map/04-ws-client-and-position-store.md @@ -23,7 +23,7 @@ This is the throughput-discipline core. It runs whether or not the map is mounte deviceId: string; lat: number; lon: number; - ts: number; // epoch ms + ts: number; // epoch ms speed?: number; course?: number; accuracy?: number; @@ -35,9 +35,13 @@ This is the throughput-discipline core. It runs whether or not the map is mounte - `LiveClient` interface: ```ts interface LiveClient { - connect(): void; // idempotent + connect(): void; // idempotent 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; onPosition(handler: (msg: PositionEntry & { topic: string }) => void): () => void; } @@ -50,27 +54,30 @@ 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. - 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: + ```ts type PositionState = { latestByDevice: Map; trailsByDevice: Map; selectedDeviceId: string | null; - activeEventId: string | null; // 2.7 sets this + activeEventId: string | null; // 2.7 sets this }; type PositionActions = { applySnapshot(eventId: string, entries: PositionEntry[]): void; - applyPositions(entries: PositionEntry[]): void; // called by coalescer flush - clearForEvent(): void; // on event switch + applyPositions(entries: PositionEntry[]): void; // called by coalescer flush + clearForEvent(): void; // on event switch selectDevice(deviceId: string | null): void; }; ``` + Trail ring buffer is bounded by `MAX_TRAIL_LENGTH` (default 200, see `MaxTrailLength` config below). + - **`src/live/connection-store.ts`** — Zustand store: ```ts type ConnectionState = { status: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; - attempt: number; // current reconnect attempt (0 when connected) + attempt: number; // current reconnect attempt (0 when connected) lastConnectedAt: number | null; lastErrorMessage: string | null; }; @@ -78,8 +85,8 @@ This is the throughput-discipline core. It runs whether or not the map is mounte - **`src/live/index.ts`** — barrel re-exports + a `` React component that creates the singleton client (using `runtimeConfig.liveWsUrl`) and wires the coalescer to the store. Mounted alongside `` in `main.tsx`. - **Constants** — `src/live/constants.ts`: ```ts - export const MAX_TRAIL_LENGTH = 200; // points per device - export const RAF_BUDGET_MS = 16; // soft target; rAF naturally caps to ~60Hz + export const MAX_TRAIL_LENGTH = 200; // points per device + export const RAF_BUDGET_MS = 16; // soft target; rAF naturally caps to ~60Hz export const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 16000]; export const RECONNECT_CEILING_MS = 30000; export const HEARTBEAT_INTERVAL_MS = 60000; @@ -104,7 +111,10 @@ function createLiveClient({ url }: { url: string }): LiveClient { let state: ClientState = { kind: 'idle' }; const subscriptions = new Set(); const positionHandlers = new Set<(msg: PositionEntry & { topic: string }) => void>(); - const pendingSubscribes = new Map void; reject: (e: unknown) => void }>(); + const pendingSubscribes = new Map< + string, + { resolve: (r: SubscribeResult) => void; reject: (e: unknown) => void } + >(); function connect() { if (state.kind === 'connecting' || state.kind === 'connected') return; @@ -132,8 +142,7 @@ function createLiveClient({ url }: { url: string }): LiveClient { function scheduleReconnect() { if (state.kind === 'closed') return; - const attempt = - state.kind === 'reconnecting' ? state.attempt + 1 : 1; + const attempt = state.kind === 'reconnecting' ? state.attempt + 1 : 1; const delay = Math.min( RECONNECT_BACKOFF_MS[attempt - 1] ?? RECONNECT_CEILING_MS, RECONNECT_CEILING_MS, @@ -330,7 +339,7 @@ Mount inside `` so it only connects when authenticated. On logout - **Map rendering.** That's 2.5 / 2.6 — they read from the store. - **Event picking.** 2.7 — calls `client.subscribe('event:')` and handles the snapshot. - **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 @@ -348,7 +357,7 @@ Mount inside `` 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. - **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. -- **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 diff --git a/.planning/phase-2-live-map/05-map-positions.md b/.planning/phase-2-live-map/05-map-positions.md index fce6b8a..d310541 100644 --- a/.planning/phase-2-live-map/05-map-positions.md +++ b/.planning/phase-2-live-map/05-map-positions.md @@ -215,8 +215,8 @@ function buildPositionFeature(p: PositionEntry, device?: Device): Feature { function inferStatus(p: PositionEntry): 'success' | 'error' | 'neutral' | 'info' { // Phase 2 heuristic — refine in Phase 3.4 with real domain rules. - if ((p.speed ?? 0) > 1) return 'success'; // moving - return 'neutral'; // stopped / idle + if ((p.speed ?? 0) > 1) return 'success'; // moving + return 'neutral'; // stopped / idle } ``` @@ -228,9 +228,9 @@ Phase 2's status inference is a placeholder; Phase 3.4 (per-device detail) will // In src/routes/_authed/monitor.tsx - {/* 2.6 */} - {/* 2.8 */} - {/* 2.8 */} + {/* 2.6 */} + {/* 2.8 */} + {/* 2.8 */} ``` diff --git a/.planning/phase-2-live-map/06-map-trails.md b/.planning/phase-2-live-map/06-map-trails.md index 814f212..01490b6 100644 --- a/.planning/phase-2-live-map/06-map-trails.md +++ b/.planning/phase-2-live-map/06-map-trails.md @@ -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." -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 @@ -59,7 +59,11 @@ The third arg to `addLayer` puts the line layer below the position symbols; trai ### Feature builder — flat colour per device ```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; return { type: 'Feature', @@ -111,7 +115,7 @@ useEffect(() => { ### 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. @@ -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. - [ ] 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). -- [ ] 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. - [ ] No `setData` warnings (e.g. coordinate-array mismatch). diff --git a/.planning/phase-2-live-map/07-event-picker.md b/.planning/phase-2-live-map/07-event-picker.md index e1d3c0c..41fbed0 100644 --- a/.planning/phase-2-live-map/07-event-picker.md +++ b/.planning/phase-2-live-map/07-event-picker.md @@ -14,6 +14,7 @@ After this task, the live map is operator-driven — no hardcoded event id, no d ## Deliverables - **`src/data/events.ts`** — TanStack Query hook + types: + ```ts export type EventSummary = { 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; ``` + 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`** — `` 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: ```tsx @@ -46,7 +49,7 @@ After this task, the live map is operator-driven — no hardcoded event id, no d - {/* 2.9 */} + {/* 2.9 */} ); } @@ -107,27 +110,30 @@ export function useActiveEventOrchestration() { const clearStore = usePositionStore((s) => s.clearForEvent); const activeEventId = usePositionStore((s) => s.activeEventId); - return useCallback(async (eventId: string | null) => { - const client = getLiveClient(); + return useCallback( + async (eventId: string | null) => { + const client = getLiveClient(); - // Tear down the previous subscription. - if (activeEventId) { - await client.unsubscribe(`event:${activeEventId}`).catch(() => {}); - clearStore(); - } + // Tear down the previous subscription. + if (activeEventId) { + await client.unsubscribe(`event:${activeEventId}`).catch(() => {}); + clearStore(); + } - if (!eventId) return; + if (!eventId) return; - // Subscribe to the new one. - const result = await client.subscribe(`event:${eventId}`); - if (!result.ok) { - // Surface to UI (toast or inline error). - console.warn('subscribe failed', result); - return; - } - setActiveEventInStore(eventId, result.snapshot); - localStorage.setItem('trm-active-event-id', eventId); - }, [activeEventId, clearStore, setActiveEventInStore]); + // Subscribe to the new one. + const result = await client.subscribe(`event:${eventId}`); + if (!result.ok) { + // Surface to UI (toast or inline error). + console.warn('subscribe failed', result); + return; + } + setActiveEventInStore(eventId, result.snapshot); + localStorage.setItem('trm-active-event-id', eventId); + }, + [activeEventId, clearStore, setActiveEventInStore], + ); } ``` diff --git a/.planning/phase-2-live-map/08-camera-trio.md b/.planning/phase-2-live-map/08-camera-trio.md index 55aa96c..3571c60 100644 --- a/.planning/phase-2-live-map/08-camera-trio.md +++ b/.planning/phase-2-live-map/08-camera-trio.md @@ -108,7 +108,13 @@ Two behaviours: ### `` — imperative one-shot ```tsx -function MapCamera({ coordinates, padding }: { coordinates: [number, number][]; padding?: number }) { +function MapCamera({ + coordinates, + padding, +}: { + coordinates: [number, number][]; + padding?: number; +}) { useEffect(() => { if (coordinates.length === 0) return; if (coordinates.length === 1) { diff --git a/.planning/phase-2-live-map/09-connection-status.md b/.planning/phase-2-live-map/09-connection-status.md index 0da8dac..a7c233f 100644 --- a/.planning/phase-2-live-map/09-connection-status.md +++ b/.planning/phase-2-live-map/09-connection-status.md @@ -7,12 +7,12 @@ ## 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. 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 @@ -134,7 +134,7 @@ useEffect(() => { - [ ] `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. - [ ] 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. - [ ] No banner / toast spam during normal operation. diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md index 321a847..60b8893 100644 --- a/.planning/phase-2-live-map/README.md +++ b/.planning/phase-2-live-map/README.md @@ -46,17 +46,17 @@ When Phase 2 is done: ## Tasks -| # | Task | Status | -| --- | ----------------------------------------------------------------------------------------------------- | ------ | -| 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | ⬜ | -| 2.2 | [Tile-source switcher](./02-tile-source-switcher.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.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | ⬜ | -| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ | -| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ | -| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ | -| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ | +| # | Task | Status | +| --- | ------------------------------------------------------------------------------------------ | ------ | +| 2.1 | [MapView singleton + mapReady gate](./01-mapview-singleton.md) | 🟩 | +| 2.2 | [Tile-source switcher](./02-tile-source-switcher.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.5 | [MapPositions (clustered + selected sources)](./05-map-positions.md) | ⬜ | +| 2.6 | [MapTrails (bounded ring buffer, polyline rendering)](./06-map-trails.md) | ⬜ | +| 2.7 | [Event picker (subscription driver)](./07-event-picker.md) | ⬜ | +| 2.8 | [Camera control trio](./08-camera-trio.md) | ⬜ | +| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | ⬜ | ## Files modified @@ -95,9 +95,9 @@ spa/ ## Tech stack additions - **`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`. -- **`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). diff --git a/eslint.config.js b/eslint.config.js index ac4d6d4..af2f628 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,4 +45,13 @@ export default defineConfig([ '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', + }, + }, ]); diff --git a/package.json b/package.json index 03d5528..5c3fbb0 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.14.0", + "maplibre-gl": "^5.24.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -37,6 +38,7 @@ "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/router-cli": "^1.166.40", "@tanstack/router-plugin": "^1.167.32", + "@types/geojson": "^7946.0.16", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c85f8ef..dd2222c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5) + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 radix-ui: 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) @@ -69,6 +72,9 @@ importers: '@tanstack/router-plugin': 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)) + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/node': specifier: ^24.12.2 version: 24.12.2 @@ -308,6 +314,42 @@ packages: '@jridgewell/trace-mapping@0.3.31': 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': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1322,6 +1364,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1336,6 +1381,9 @@ packages: '@types/react@19.2.14': 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': resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1531,6 +1579,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + electron-to-chromium@1.5.349: resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} @@ -1679,6 +1730,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1765,11 +1819,17 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1866,13 +1926,23 @@ packages: magic-string@0.30.21: 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: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1911,6 +1981,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1926,6 +2000,9 @@ packages: resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1939,10 +2016,16 @@ packages: engines: {node: '>=14'} hasBin: true + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -2009,11 +2092,17 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2056,6 +2145,9 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2074,6 +2166,9 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2475,6 +2570,52 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@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)': dependencies: '@emnapi/core': 1.10.0 @@ -3484,6 +3625,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} '@types/node@24.12.2': @@ -3498,6 +3641,10 @@ snapshots: dependencies: 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)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3711,6 +3858,8 @@ snapshots: diff@8.0.4: {} + earcut@3.0.2: {} + electron-to-chromium@1.5.349: {} emoji-regex@8.0.0: {} @@ -3859,6 +4008,8 @@ snapshots: get-nonce@1.0.1: {} + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3917,8 +4068,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@4.0.0: {} + json5@2.2.3: {} + kdbush@4.0.2: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3993,12 +4148,38 @@ snapshots: dependencies: '@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: dependencies: brace-expansion: 5.0.5 + minimist@1.2.8: {} + ms@2.1.3: {} + murmurhash-js@1.0.0: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} @@ -4030,6 +4211,10 @@ snapshots: pathe@2.0.3: {} + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -4042,6 +4227,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + potpack@2.1.0: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -4050,8 +4237,12 @@ snapshots: prettier@3.8.3: {} + protocol-buffers-schema@3.6.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): dependencies: '@radix-ui/primitive': 1.1.3 @@ -4159,6 +4350,10 @@ snapshots: require-directory@2.1.1: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -4180,6 +4375,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + rw@1.3.3: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4210,6 +4407,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -4225,6 +4426,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyqueue@3.0.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/src/map/core/map-view.tsx b/src/map/core/map-view.tsx new file mode 100644 index 0000000..64fcc51 --- /dev/null +++ b/src/map/core/map-view.tsx @@ -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 `` + * 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 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(); +} + +// ---------- ---------------------------------------------------- + +/** + * The canonical entry point for any view that uses the map. + * + * Mounts the singleton's detached `
` 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(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 ( +
+ {ready && children} +
+ ); +} diff --git a/src/map/core/styles.ts b/src/map/core/styles.ts new file mode 100644 index 0000000..674e59c --- /dev/null +++ b/src/map/core/styles.ts @@ -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' }], +}; diff --git a/src/routes/_authed/index.tsx b/src/routes/_authed/index.tsx index 059337a..31212ba 100644 --- a/src/routes/_authed/index.tsx +++ b/src/routes/_authed/index.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; import { useAuthStore } from '@/auth'; import { LogoutButton } from '@/ui/components/logout-button'; import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'; @@ -40,11 +40,16 @@ function HomePage() { Live monitoring map - +

- The live-position map lands in Phase 2 once the Processor's WebSocket endpoint is - shipped (Phase 1.5 — already complete on the processor side). + Watch registered devices report their positions in real time.

+ + Open live map → +
diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx new file mode 100644 index 0000000..4c28b65 --- /dev/null +++ b/src/routes/_authed/monitor.tsx @@ -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. +
+ +
+ ); +}