feat: task 2.4 WS client + rAF coalescer + Zustand position store
Eight files under src/live/:
- constants.ts: throughput-discipline numbers (MAX_TRAIL_LENGTH=200,
reconnect backoff [1s/2s/4s/8s/16s, 30s ceiling], STALE_CONNECTION_MS).
- protocol.ts: zod discriminatedUnion('type', ...) for inbound
(subscribed / unsubscribed / position / error). PositionEntry +
SubscribeResult types per processor-ws-contract.
- connection-store.ts: Zustand store with status state machine.
- position-store.ts: Zustand store with latestByDevice + trailsByDevice
Maps, applySnapshot/applyPositions/clearForEvent/selectDevice
actions. Ring-buffer cap on trails; same-coordinate dedup.
- coalescer.ts: ~30-line rAF coalescer. Per-device buffer; flushes once
per animation frame regardless of receive rate.
- ws-client.ts: state machine (idle / connecting / connected /
reconnecting / closed) with exponential backoff, re-subscribe on
reconnect, stale-connection check, subscribe correlation via
pending-map + 5s timeout. URL resolution helper toAbsoluteWsUrl()
for same-origin path inputs.
- bootstrap.tsx: <LiveBootstrap> creates the client when authenticated,
wires positions through the coalescer to the position store, tears
down on logout. getLiveClient() exposes the singleton for 2.7.
- index.ts: barrel re-exports.
main.tsx wraps <App /> in <LiveBootstrap> alongside <AuthBootstrap>.
Deviations:
1. Skipped the setStatus helper inside createLiveClient; conditional
Parameters<> generics were hostile. Direct
useConnectionStore.getState().setStatus(...) at the ~6 call sites.
2. subscribe() adds to the subscriptions Set even when not connected
(so it replays on reconnect). Caller handles 'not-connected' by
waiting for connection-store status transition.
3. onPosition returns an unsubscribe fn (Set-based). Multi-handler is
free; lets future debug panels/tests attach.
Bundle: src/live/ adds ~15KB raw to the main bundle (mostly zod's
discriminated-union runtime). Total 393KB / 120KB gz.
This commit is contained in:
@@ -156,10 +156,11 @@ For 2.3, just include it in the registry and it'll be there when 2.5 needs it.
|
||||
|
||||
**Deviations from spec:**
|
||||
|
||||
1. Spec sketched the direction sprite as part of the same composition (background + tinted icon). Implemented as two separate sprite types: category sprites have the plate, direction sprites are the arrow alone (no plate). Reason: the direction sprite is rendered as a *separate* symbol layer in 2.5, overlaid on top of the device sprite — drawing a plate under the arrow would create a double-plate visual. The spec's example expression `'icon-image': '{category}-{color}'` for one symbol layer + `'icon-image': 'direction-{color}'` for the direction layer is what 2.5 will actually consume.
|
||||
1. Spec sketched the direction sprite as part of the same composition (background + tinted icon). Implemented as two separate sprite types: category sprites have the plate, direction sprites are the arrow alone (no plate). Reason: the direction sprite is rendered as a _separate_ symbol layer in 2.5, overlaid on top of the device sprite — drawing a plate under the arrow would create a double-plate visual. The spec's example expression `'icon-image': '{category}-{color}'` for one symbol layer + `'icon-image': 'direction-{color}'` for the direction layer is what 2.5 will actually consume.
|
||||
2. Spec showed sample colours as `success: 'green'`. Used the design system's actual semantic palette (`#2E8C4A` green, `#E8412B` race-flag red, `#0E0E0C` ink, `#2563C8` info blue) directly. When 3.8 lands, these get rebound to TRM design tokens via CSS variables.
|
||||
|
||||
**Smoke check (local `pnpm dev`):**
|
||||
|
||||
- App boots; `getSpriteRegistry().size` returns `32` after the page settles.
|
||||
- `/monitor` map renders; switching basemaps doesn't break sprites (visible via the mapReady flow's "preload then install" sequence in dev tools console).
|
||||
- No "Image with id X is missing" warnings.
|
||||
|
||||
@@ -361,4 +361,32 @@ Mount inside `<AuthBootstrap>` so it only connects when authenticated. On logout
|
||||
|
||||
## Done
|
||||
|
||||
(Filled in when the task lands.)
|
||||
Eight files under `src/live/`:
|
||||
|
||||
- **`constants.ts`** — `MAX_TRAIL_LENGTH=200`, `RECONNECT_BACKOFF_MS=[1s, 2s, 4s, 8s, 16s]`, `RECONNECT_CEILING_MS=30s`, `SUBSCRIBE_TIMEOUT_MS=5s`, `STALE_CONNECTION_MS=60s`. Single source of truth for the throughput discipline.
|
||||
- **`protocol.ts`** — zod schemas for inbound (`subscribed` / `unsubscribed` / `position` / `error`) via `discriminatedUnion('type', ...)`, `OutboundMessage` type union, `PositionEntry` / `SubscribeResult` types per [[processor-ws-contract]].
|
||||
- **`connection-store.ts`** — Zustand store with `status` / `attempt` / `lastConnectedAt` / `lastErrorMessage`. `setStatus(status, opts)` updates with sensible defaults (resets `attempt` on `'connected'`, stamps `lastConnectedAt`).
|
||||
- **`position-store.ts`** — Zustand store with `latestByDevice: Map`, `trailsByDevice: Map`, `selectedDeviceId`, `activeEventId`. Actions: `applySnapshot` (wipes + seeds), `applyPositions` (per-device update with same-coordinate dedup; ring-buffer cap via `MAX_TRAIL_LENGTH`), `clearForEvent`, `selectDevice`. Each update creates a new `Map` reference so Zustand selectors fire only for changed devices (Map.get returns the same reference if the entry didn't change).
|
||||
- **`coalescer.ts`** — `createCoalescer(onFlush)` with `push` and `cancel`. ~30 lines. Per-device buffer; `requestAnimationFrame` flushes the latest snapshot once per frame; `cancelAnimationFrame` on cancel. The throughput-discipline core.
|
||||
- **`ws-client.ts`** — `createLiveClient({ url })` returns a `LiveClient` with `connect / close / subscribe / unsubscribe / onPosition`. State machine: `idle` / `connecting` / `connected` / `reconnecting` / `closed`. Reconnect with exponential backoff capped at 30s. Re-subscribes to all active topics on reconnect. Stale-connection check via `setInterval` halves `STALE_CONNECTION_MS`; closes if no message in window. Subscribe correlation via 5s-timeout pending map. URL resolution helper `toAbsoluteWsUrl()` derives `ws(s)://...` from same-origin path inputs (`/ws-live` → `ws://localhost:5173/ws-live`).
|
||||
- **`bootstrap.tsx`** — `<LiveBootstrap>` React component. Watches auth status; creates the singleton client + coalescer when `'authenticated'`, tears down on any other status (closes WS, cancels coalescer, calls `clearForEvent` on the position store). `getLiveClient()` exposes the singleton for the event picker (2.7).
|
||||
- **`index.ts`** — barrel re-exports.
|
||||
|
||||
**`src/main.tsx`** updated — `<LiveBootstrap>` wraps `<App />`, sandwiched between `<AuthBootstrap>` and the route tree. Connection only happens for authenticated users.
|
||||
|
||||
**Deviations from spec:**
|
||||
|
||||
1. Spec sketched a `setStatus` helper inside `createLiveClient` for shorter call sites. Tried it; the conditional-types-on-Parameters generic was hostile. Switched to direct `useConnectionStore.getState().setStatus(...)` call sites — verbose but readable, and there are only ~6 of them.
|
||||
2. `subscribe()` returns `{ ok: false, code: 'not-connected' }` if the client isn't currently connected, **but still adds the topic to the `subscriptions` Set** so it gets replayed when the WS opens. Spec sketched `subscriptions.add` only on success; the replay-on-reconnect contract works whether the user's first subscribe fired during a connecting state or a connected state. Document expectation: caller (event picker in 2.7) handles `not-connected` by treating it as "subscription will activate when the connection comes back" — typically by listening on the connection-store's `'connected'` transition.
|
||||
3. `onPosition` returns an unsubscribe function (Set-add + delete). Spec showed it as a single-handler API; multi-handler support is no extra cost and lets future code (a debug panel, tests) attach without fighting the singleton handler.
|
||||
|
||||
**Smoke check (local `pnpm dev`):**
|
||||
- App boots; no WS connection until login.
|
||||
- After login: `useConnectionStore.getState()` shows `status: 'connecting'` then either `'connected'` (if Phase 1.5 stage processor is reachable) or `'reconnecting'` with backoff (if the proxy isn't routing `/ws-live` yet).
|
||||
- `usePositionStore.getState()` shows the empty initial state — `activeEventId: null`, empty Maps.
|
||||
- Logging out closes the WS and clears the position store.
|
||||
- DevTools network tab: WS handshake to `ws://localhost:5173/ws-live` after login; carries the auth cookie automatically (same-origin).
|
||||
|
||||
**Bundle:** `src/live/` adds ~15KB raw (the bulk is zod's discriminated-union runtime, plus the WS state machine). Loaded eagerly because `<LiveBootstrap>` is in `main.tsx`. Total main bundle 393KB / 120KB gzipped (up from 376KB / 115KB after 2.3).
|
||||
|
||||
Landed in `PENDING_SHA`.
|
||||
|
||||
@@ -51,7 +51,7 @@ When Phase 2 is done:
|
||||
| 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.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) | ⬜ |
|
||||
|
||||
Reference in New Issue
Block a user