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
(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`.
@@ -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,
}),
@@ -75,10 +75,10 @@ async function prepareSprite(
```ts
const COLOR_MAP: Record<SpriteColor, string> = {
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
};
```
@@ -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<void>;
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<string, PositionEntry>;
trailsByDevice: Map<string, PositionEntry[]>;
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 `<LiveBootstrap>` React component that creates the singleton client (using `runtimeConfig.liveWsUrl`) and wires the coalescer to the store. Mounted alongside `<AuthBootstrap>` 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<string>();
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() {
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 `<AuthBootstrap>` 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:<id>')` 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 `<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.
- **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
@@ -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
<MapView>
<MapPositions />
<MapTrails /> {/* 2.6 */}
<MapDefaultCamera /> {/* 2.8 */}
<MapSelectedDevice /> {/* 2.8 */}
<MapTrails /> {/* 2.6 */}
<MapDefaultCamera /> {/* 2.8 */}
<MapSelectedDevice /> {/* 2.8 */}
</MapView>
```
+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."
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).
+25 -19
View File
@@ -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<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.
- **`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:
```tsx
@@ -46,7 +49,7 @@ After this task, the live map is operator-driven — no hardcoded event id, no d
<MapSelectedDevice />
</MapView>
<BasemapSwitcher />
<ConnectionChip /> {/* 2.9 */}
<ConnectionChip /> {/* 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],
);
}
```
+7 -1
View File
@@ -108,7 +108,13 @@ Two behaviours:
### `<MapCamera coordinates>` — 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) {
@@ -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.
+13 -13
View File
@@ -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).
+9
View File
@@ -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',
},
},
]);
+2
View File
@@ -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",
+203
View File
@@ -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
+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 { 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() {
<CardHeader>
<CardTitle>Live monitoring map</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
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.
</p>
<Link
to="/monitor"
className="inline-flex items-center gap-1 text-sm font-medium underline underline-offset-4"
>
Open live map
</Link>
</CardContent>
</Card>
</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>
);
}