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:
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user