- src/map/core/map-pref-store.ts: mapFollow boolean (default true) +
setter. Persisted via trm-map-prefs.
- src/map/core/camera/default-camera.tsx: <MapDefaultCamera /> fits
the snapshot's bounds once per activeEventId change. Uses a
fittedFor ref; resets to null when activeEventId becomes null.
Single-point events centre+zoom 12; multi-point fitBounds with
padding capped at min(canvas*0.1, 80).
- src/map/core/camera/selected-device.tsx: <MapSelectedDevice />
pans+zooms on selection change, smooth pans on subsequent position
updates with mapFollow on. Separate effect listens for user-gesture
movestart (originalEvent truthy) and flips mapFollow off.
- src/map/core/camera/map-camera.tsx: <MapCamera coordinates> imperative
one-shot. Phase 2 doesn't use it; primitive for replay (Phase 4) and
future "fit to all" UI.
- src/map/core/follow-toggle.tsx: <FollowToggle /> icon-button using
Lucide Locate/LocateOff. Sits below the trails toggle.
- src/routes/_authed/monitor.tsx: renders FollowToggle, MapDefaultCamera,
MapSelectedDevice.
Deviations:
- Manual-pan listener uses an inline { originalEvent?: unknown } type
instead of MapLibre's MapMouseEvent|MapTouchEvent union (typing
friction across version bumps).
- MapDefaultCamera clears fittedFor on activeEventId === null so
re-selecting the same event later re-fits.
Bundle: main 395KB / 120KB gz, no measurable change.
- src/data/events.ts: useUserEvents() TanStack Query (5-min stale,
sort -starts_at). EventSummary type is a Pick of EventRow.
- src/live/active-event.ts: useActiveEventOrchestration() returns the
swap fn — unsubscribe previous + clearForEvent + subscribe new +
applySnapshot on success + persist to localStorage. Out-of-order
safety via per-call version counter. Plus readSavedActiveEventId().
- src/ui/components/event-picker.tsx: <EventPicker> dropdown.
useState + click-outside; rows show name + date + discipline.
- src/live/index.ts: re-exports active-event helpers.
- src/routes/_authed/monitor.tsx: auto-select effect (one-shot via
initializedRef, gated on events loaded + WS connected); renders
<EventPicker> wired to setActiveEvent.
Deviations:
1. Vanilla div + useState dropdown instead of shadcn Popover —
no new shadcn primitive add; easy to swap later for keyboard nav.
2. Auto-select gated on connectionStatus === 'connected' so the
subscribe call gets the snapshot path (not 'not-connected').
3. Logout-clears-saved-event-id deferred to a small Phase 1.8
follow-up; documented in task risks.
Bundle: 395KB / 120KB gz (~1KB up from 2.6).
- src/map/core/map-pref-store.ts: trailMode preference
('none' | 'selected' | 'all', default 'selected') + setter, persisted.
- src/map/layers/map-trails.tsx: <MapTrails /> side-effect-only.
Single GeoJSON source + single line layer. Builds one LineString
Feature per device whose trail has >= 2 points; filtered by mode.
Per-device flat colour via deterministic deviceId hash mod a
6-entry palette.
- src/map/core/trails-toggle.tsx: <TrailsToggle /> floating card
below <BasemapSwitcher />. Three buttons (None / Selected / All).
- src/routes/_authed/monitor.tsx: renders <TrailsToggle /> +
<MapTrails /> *before* <MapPositions /> so the line layer is added
to the map first and renders underneath the symbol layers.
Speed-coloured-per-segment deferred to Phase 3 polish per the task
spec's open-question decision; flat-colour-per-device for v1.
Bundle: main 394KB / 120KB gz — no change from 2.5.
- src/data/devices.ts: useDevicesById() — TanStack Query (5-min stale)
returning Map<deviceId, Device>. deviceLabel() formats human-readable
titles ("FMB920 #3458").
- src/map/layers/map-positions.tsx: <MapPositions /> side-effect-only.
Two GeoJSON sources (clustered non-selected + unclustered selected).
Five layers: non-selected symbol + direction + cluster-bubble,
selected symbol + direction. Click handlers: marker -> selectDevice,
cluster -> getClusterExpansionZoom + easeTo. Hover toggles cursor.
- src/routes/_authed/monitor.tsx: renders <MapPositions /> inside
<MapView>.
Schema overhaul in src/auth/client.ts:
- Made Schema SDK-compatible. Each entry is an *array* of row types
(devices: DeviceRow[], not just DeviceRow). RegularCollections<Schema>
filters on array-like values; non-arrays collapse to never which
broke readItems('devices', ...) with keyof Schema = never.
- Spelled out the composed client type as DirectusClient<Schema> &
RestClient<Schema> & AuthenticationClient<Schema> — without the
explicit annotation Schema didn't flow through .with(...) chain to
request() call sites.
- Added DeviceRow + EventRow types; exported via @/auth.
useDevicesById uses readItems<Schema, 'devices', Query<Schema, DeviceRow>>
— explicit generics because the SDK doesn't infer Schema from the
receiver's type at call sites.
Deviations:
1. Spec referenced device.kind for category — Phase 1 schema doesn't
have it yet; everything maps to 'default'. Refine when kind lands.
2. Cluster bubble uses 'default-neutral' sprite instead of a dedicated
'cluster-background' (not in 2.3's registry). Swap in 3.8.
3. getClusterExpansionZoom is Promise-based in maplibre-gl 5.x (was
callback-style); used .then().
Bundle: main bundle 394KB / 120KB gz, ~1KB up from 2.4. /monitor
chunk includes the new layer module (~10KB).
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.
- src/map/assets/icons/: 9 placeholder SVGs (background plate, direction
arrow, 7 categories: rally-car / quad / ssv / motorcycle / runner /
hiker / default). Hand-authored simple silhouettes; replaced in
Phase 3.8 with the branded set.
- src/map/core/categories.ts: SpriteCategory/SpriteColor types,
mapCategoryToSprite() normaliser, inferColor() helper.
- src/map/core/sprite-preload.ts: idempotent preloadSprites() with
memoised promise, whenSpritesReady() alias, getSpriteRegistry()
read-only access, installSprites(map) for the mapReady flow.
Composition pipeline draws the plate + composites a tinted icon
centred on it (category sprites) or tints the arrow alone (direction
sprites). Tint via canvas globalCompositeOperation: 'source-in'.
- src/main.tsx: void preloadSprites() fired at boot; the promise is
memoised so mapReady flow awaits the same instance.
- src/map/core/map-view.tsx: onStyleData() awaits whenSpritesReady()
AND _map.loaded() before installing sprites and flipping mapReady
true. Sprites reinstall on every style swap.
Registry: 7 categories x 4 colours + 4 direction-only entries = 32
total. ~160KB in memory.
Deviations:
1. Direction sprites have no plate (it's a separate symbol layer in
2.5 overlaid on the device sprite; double-plate would look wrong).
2. Hardcoded the design-system palette (#2E8C4A / #E8412B / #0E0E0C /
#2563C8) directly. When 3.8 lands, these rebind to TRM tokens via
CSS variables.
- pnpm add maplibre-google-maps (runtime; lazy-imported on Google use).
- src/map/core/styles.ts: BasemapDescriptor model with four entries
(Esri Satellite / OpenTopoMap / OSM / Google Satellite).
Google entry gated on cfg.googleMapsKey via available(cfg).
styleCustom() builds a single-raster-source MapLibre style with a
glyphs URL for future label rendering.
ensureGoogleProtocol() lazy-loads maplibre-google-maps + addProtocol
on first use; _googleRegistered guards re-registration.
- src/map/core/map-pref-store.ts: Zustand + persist middleware
(key: trm-map-prefs, version: 1). Holds basemapId + setBasemap.
- src/map/core/basemap-switcher.tsx: floating control top-right of
<MapView>. Filters by available(cfg), highlights active, applies via
getMap().setStyle(). Mount-time bootstrap (no UI state) + user-driven
onClick (with switching state) split to satisfy React 19's
react-hooks/set-state-in-effect rule.
- src/routes/_authed/monitor.tsx: renders <BasemapSwitcher /> as a
child of <MapView>.
- src/vite-env.d.ts: ambient module declaration for
maplibre-google-maps (no .d.ts ships in the package).
Deviations:
- BasemapDescriptor.style is buildStyle(cfg): StyleSpecification, not
the spec's StyleSpecification|string union. Google needs the cfg key
at build time; uniform function shape across all entries is cleaner.
- Bootstrap apply path doesn't use the switching UI state (no visual
feedback needed during the silent first-paint flip from OSM to user
preference). User-click path keeps setSwitching for the in-flight
indicator.
Bundle: MapLibre is now its own chunk (1MB / 273KB gz), shared across
maplibre consumers. Main bundle 371KB / 114KB gz.
- 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.
Nine task files matching Phase 1's shape (Goal / Deliverables / Spec /
Acceptance / Risks / Done). README updated with full sequencing diagram,
files-modified outline, tech stack additions, design rules, and phase
acceptance.
| # | Task |
| --- | --------------------------------------------------------------------- |
| 2.1 | MapView singleton + mapReady gate |
| 2.2 | Tile-source switcher (Esri / OpenTopoMap / OSM / optional Google) |
| 2.3 | Sprite preload — 7 racing categories x 4 colour variants |
| 2.4 | WS client + rAF coalescer + Zustand position store + connection store |
| 2.5 | MapPositions — clustered + selected sources |
| 2.6 | MapTrails — bounded ring buffer, polyline rendering |
| 2.7 | Event picker — TanStack Query + WS subscription orchestration |
| 2.8 | Camera control trio — default-fit / selected-follow / one-shot |
| 2.9 | Connection status + per-device last-seen indicators |
Sequencing: 2.1 and 2.4 are parallel foundations (singleton vs data
pipeline). Once both land, 2.5 / 2.6 / 2.7 / 2.9 fan out independently.
2.2 / 2.3 only need 2.1. 2.8 sits at the end on top of 2.1 + 2.5.
Each task documents its deliverables down to file paths + interface
shapes, includes concrete code sketches in the Specification, lists
explicit out-of-scope items, and surfaces risks for the implementer
to think about. An agent (or future me) can pick up any single task
and ship it without re-deriving the design from the wiki.
Resolved Phase 2 design decisions baked into the task files:
- Trails: flat-colour-per-device for v1, defer speed-coloured segments
to a Phase 3 polish task.
- Cluster params: 14/50 (traccar default); tune after seeing real data.
- Event picker placement: top-left dropdown.
- Multi-event: out — single-select, one event at a time.
- Stale-position visual: fade icon opacity; defer warning badges.