diff --git a/.planning/phase-2-live-map/08-camera-trio.md b/.planning/phase-2-live-map/08-camera-trio.md index eb58c68..680eeab 100644 --- a/.planning/phase-2-live-map/08-camera-trio.md +++ b/.planning/phase-2-live-map/08-camera-trio.md @@ -192,7 +192,7 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o ## Done - **`src/map/core/map-pref-store.ts`** — added `mapFollow: boolean` (default `true`) + `setMapFollow`. Persisted via the existing `trm-map-prefs` zustand+persist store. -- **`src/map/core/camera/default-camera.tsx`** — ``. Watches `activeEventId` + `latestByDevice`. Uses a `fittedFor` ref to ensure the camera only fits *once* per event-id change. Single-device events centre + zoom to 12; multi-device events `fitBounds` with padding capped at `min(canvas * 0.1, 80)`. +- **`src/map/core/camera/default-camera.tsx`** — ``. Watches `activeEventId` + `latestByDevice`. Uses a `fittedFor` ref to ensure the camera only fits _once_ per event-id change. Single-device events centre + zoom to 12; multi-device events `fitBounds` with padding capped at `min(canvas * 0.1, 80)`. - **`src/map/core/camera/selected-device.tsx`** — ``. Two effects: - Selection / position effect: on selection change, always pan + zoom to ≥ 14. On subsequent position updates with `mapFollow` on, smooth pan only (no zoom change). - Manual-pan listener: hooks `map.on('movestart', ...)`. Distinguishes user gestures from programmatic `easeTo`s via `originalEvent`-truthy check; flips `mapFollow` to false when the user grabs the map. @@ -207,6 +207,7 @@ Click toggles `mapFollow`. Selecting a device with follow off still pans to it o - `` clears `fittedFor.current = null` when `activeEventId` becomes null (e.g. on logout / event clear). Without that, switching back to the same event later wouldn't re-fit. Spec didn't call this out; added it as a small correctness fix. **Smoke check (`pnpm dev`):** + - Pick the seeded Rally Albania event → camera fits all snapshot positions on first appearance. - Click a device → camera pans + zooms in. - With Follow on, push a synthetic position for the selected device → camera smoothly pans to follow it. diff --git a/.planning/phase-2-live-map/09-connection-status.md b/.planning/phase-2-live-map/09-connection-status.md index a7c233f..3c0995e 100644 --- a/.planning/phase-2-live-map/09-connection-status.md +++ b/.planning/phase-2-live-map/09-connection-status.md @@ -147,4 +147,29 @@ useEffect(() => { ## Done -(Filled in when the task lands.) +- **`src/live/last-seen.ts`** — `formatLastSeen(ts, now)` returns `"now"` / `"Ns ago"` / `"Nm ago"` / wall-clock `"HH:MM"` based on age. Used by both the connection chip and Phase 3.4's per-device detail panel. +- **`src/live/use-staleness.ts`** — `useStalenessTick(intervalMs = 1000)` hook. Re-renders subscribers every `intervalMs` with the current epoch ms. The position store doesn't change on its own once positions stop arriving; this keeps `staleSec` fresh on every `` feature so the icon-opacity fade reflects "device went silent" without waiting for a new position. +- **`src/ui/components/connection-chip.tsx`** — `` floating bottom-left. Three states: `connected` (green dot + "Live"), `connecting`/`reconnecting` (amber pulsing dot + status word), `disconnected` (red dot + "Offline · last live HH:MM"). `aria-live="polite"` so screen readers announce transitions; `role="status"`. +- **`src/map/layers/map-positions.tsx`** updated: + - Adds `staleSec` to every `FeatureProps`; computed as `Math.floor((now - position.ts) / 1000)`. + - Both non-selected and selected symbol layers gain `'icon-opacity'` interpolations on `staleSec` (0–60 s full opacity, 5 min faded, 30 min very faded). Non-selected layer also fades `text-opacity` so the device label ages along with the marker. + - Update effect now reads `useStalenessTick(1000)` and includes it in dependencies — feature collections rebuild every second so opacity stays fresh. +- **`src/routes/_authed/monitor.tsx`** — renders `` inside ``. +- **`src/live/index.ts`** — re-exports `formatLastSeen` and `useStalenessTick`. + +**Strategy A picked from the spec's open question**: faded marker via `icon-opacity` interpolation, no separate "warning" badge. Rationale per spec: simpler, fewer layers, cleaner visually. If it's not legible enough after dogfood feedback, layer B (a dedicated overlay sprite) is a single new symbol layer addition. + +**Deviations from spec:** + +1. Spec sketched a `stalenessTick` mutation inside the position store's actions. Pulled it out into a dedicated `useStalenessTick` hook in `src/live/use-staleness.ts` instead — keeps the position store free of UI-driven re-render concerns, and the hook is reusable in any component that needs an N-Hz tick (per-device detail panel in Phase 3.4 will). +2. Spec referenced a per-device `` component for use inside a sidebar / detail panel. Skipped — the SPA doesn't have a sidebar yet (Phase 3.4 territory), and the per-marker fade is the per-device indicator for v1. The `formatLastSeen` utility is exported and ready for that component when 3.4 lands. + +**Smoke check (`pnpm dev`):** +- `/monitor` shows the connection chip bottom-left. While connecting the chip pulses amber. Once the WS opens, it flips to a green dot + "Live". Disconnect the network → flips to amber + "Reconnecting…". Stay disconnected past `STALE_CONNECTION_MS` (60 s) → red dot + "Offline · last live …". +- Push a synthetic position via the console, wait > 60 s without further updates → marker visibly fades. Wait > 5 min → fades further. Each tier matches the interpolation breakpoints. +- The visible fade is also gradual *within* each tier because the interpolation is `linear`. +- Hard refresh while disconnected: chip starts as "Connecting…" (no `lastConnectedAt` yet), then either flips to "Live" or "Reconnecting…" based on whether the WS opens. + +**Bundle:** main 396KB / 121KB gz — small bump from 2.8. + +Landed in `PENDING_SHA`. diff --git a/.planning/phase-2-live-map/README.md b/.planning/phase-2-live-map/README.md index 292d918..1976f8b 100644 --- a/.planning/phase-2-live-map/README.md +++ b/.planning/phase-2-live-map/README.md @@ -1,6 +1,6 @@ # Phase 2 — Live monitoring map -**Status:** ⬜ Not started — depends on [[processor]] Phase 1.5 landing (already shipped). +**Status:** 🟩 Done — all 9 tasks shipped. The dogfood-day deliverable. After Phase 2, an operator opens the SPA, picks the active event, and watches the field move on a real-time map. Inherits the architecture documented in `docs/wiki/concepts/maps-architecture.md` from `docs/wiki/sources/traccar-maps-architecture.md`, with the deliberate divergences (rAF coalescer, Zustand, longer trail default, racing sprite set, native PostGIS GeoJSON) baked in from day one. @@ -56,7 +56,7 @@ When Phase 2 is done: | 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) | ⬜ | +| 2.9 | [Connection status + per-device last-seen indicators](./09-connection-status.md) | 🟩 | ## Files modified diff --git a/src/live/index.ts b/src/live/index.ts index 42c338e..519a669 100644 --- a/src/live/index.ts +++ b/src/live/index.ts @@ -2,7 +2,9 @@ export { LiveBootstrap, getLiveClient } from './bootstrap'; export { readSavedActiveEventId, useActiveEventOrchestration } from './active-event'; export { createCoalescer, type Coalescer } from './coalescer'; export { useConnectionStore, type ConnectionStatus } from './connection-store'; +export { formatLastSeen } from './last-seen'; export { usePositionStore } from './position-store'; +export { useStalenessTick } from './use-staleness'; export { PositionEntrySchema, InboundMessageSchema, diff --git a/src/live/last-seen.ts b/src/live/last-seen.ts new file mode 100644 index 0000000..2b70bb9 --- /dev/null +++ b/src/live/last-seen.ts @@ -0,0 +1,19 @@ +/** + * Format a position-recorded timestamp as a human-readable "age" string. + * + * - < 5 s → "now" + * - < 60 s → "Ns ago" + * - < 1 hr → "Nm ago" + * - else → wall-clock time ("HH:MM") + * + * Used by the connection chip ("last live HH:MM") and by Phase 3.4's + * per-device detail panel. + */ +export function formatLastSeen(ts: number, now: number = Date.now()): string { + const ageMs = Math.max(0, now - ts); + if (ageMs < 5_000) return 'now'; + if (ageMs < 60_000) return `${Math.floor(ageMs / 1000)}s ago`; + if (ageMs < 60 * 60_000) return `${Math.floor(ageMs / 60_000)}m ago`; + const d = new Date(ts); + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} diff --git a/src/live/use-staleness.ts b/src/live/use-staleness.ts new file mode 100644 index 0000000..201331c --- /dev/null +++ b/src/live/use-staleness.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; + +/** + * Re-renders subscribers every `intervalMs` with the current epoch ms. + * + * Used by `` to inject a fresh `staleSec` property on + * every feature so the symbol layer's `icon-opacity` interpolation can + * fade markers whose last position is getting old. Without this, the + * fade only updates when a new position arrives — which is exactly what + * we don't want for "this device went silent" UX. + * + * Default 1 Hz. The cost is one rebuild of the position FeatureCollection + * per second; with N devices it's O(N) — fine at pilot scale. + */ +export function useStalenessTick(intervalMs: number = 1000): number { + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => { + setTick(Date.now()); + }, intervalMs); + return () => { + clearInterval(id); + }; + }, [intervalMs]); + return tick; +} diff --git a/src/map/layers/map-positions.tsx b/src/map/layers/map-positions.tsx index 6103a1b..bd389e5 100644 --- a/src/map/layers/map-positions.tsx +++ b/src/map/layers/map-positions.tsx @@ -1,7 +1,7 @@ import { useEffect, useId } from 'react'; import type { Feature, FeatureCollection, Point } from 'geojson'; import type { GeoJSONSource, MapLayerMouseEvent } from 'maplibre-gl'; -import { usePositionStore } from '@/live'; +import { usePositionStore, useStalenessTick } from '@/live'; import { type PositionEntry } from '@/live/protocol'; import { getMap } from '@/map/core/map-view'; import { inferColor, mapCategoryToSprite } from '@/map/core/categories'; @@ -14,6 +14,8 @@ type FeatureProps = { course: number; direction: boolean; title: string; + /** Seconds since this device's last position. Drives the fade on icon-opacity. */ + staleSec: number; }; const EMPTY_FC: FeatureCollection = { @@ -47,6 +49,9 @@ export function MapPositions() { const latestByDevice = usePositionStore((s) => s.latestByDevice); const selectedDeviceId = usePositionStore((s) => s.selectedDeviceId); const { data: devices } = useDevicesById(); + // 1 Hz tick keeps `staleSec` on every feature fresh so the icon-opacity + // fade reflects "device went silent" without waiting for a new position. + const stalenessTick = useStalenessTick(1000); // ---- Setup / teardown ------------------------------------------------ @@ -87,6 +92,34 @@ export function MapPositions() { 'text-color': '#0E0E0C', 'text-halo-color': '#FAFAF7', 'text-halo-width': 1.5, + // Fade older markers based on the device's last-position age. + // 0–60 s: full opacity. 5 min: 0.4. 30 min+: 0.2. + 'icon-opacity': [ + 'interpolate', + ['linear'], + ['get', 'staleSec'], + 0, + 1.0, + 60, + 1.0, + 300, + 0.4, + 1800, + 0.2, + ], + 'text-opacity': [ + 'interpolate', + ['linear'], + ['get', 'staleSec'], + 0, + 1.0, + 60, + 1.0, + 300, + 0.5, + 1800, + 0.25, + ], }, }); @@ -145,6 +178,19 @@ export function MapPositions() { 'text-color': '#0E0E0C', 'text-halo-color': '#FAFAF7', 'text-halo-width': 2, + 'icon-opacity': [ + 'interpolate', + ['linear'], + ['get', 'staleSec'], + 0, + 1.0, + 60, + 1.0, + 300, + 0.5, + 1800, + 0.3, + ], }, }); @@ -243,12 +289,20 @@ export function MapPositions() { latestByDevice, selectedDeviceId, devices, + now: stalenessTick, }); const ns = map.getSource(nonSelectedSourceId) as GeoJSONSource | undefined; const sel = map.getSource(selectedSourceId) as GeoJSONSource | undefined; ns?.setData(nonSelected); sel?.setData(selected); - }, [latestByDevice, selectedDeviceId, devices, nonSelectedSourceId, selectedSourceId]); + }, [ + latestByDevice, + selectedDeviceId, + devices, + stalenessTick, + nonSelectedSourceId, + selectedSourceId, + ]); return null; } @@ -259,6 +313,7 @@ function buildFeatureCollections(opts: { latestByDevice: Map; selectedDeviceId: string | null; devices: Map; + now: number; }): { nonSelected: FeatureCollection; selected: FeatureCollection; @@ -269,7 +324,7 @@ function buildFeatureCollections(opts: { for (const [deviceId, position] of opts.latestByDevice) { const isSelected = deviceId === opts.selectedDeviceId; const device = opts.devices.get(deviceId); - const feat = buildPositionFeature(position, device, isSelected); + const feat = buildPositionFeature(position, device, isSelected, opts.now); if (isSelected) selectedFeatures.push(feat); else nonSelectedFeatures.push(feat); } @@ -284,6 +339,7 @@ function buildPositionFeature( p: PositionEntry, device: Device | undefined, isSelected: boolean, + now: number, ): Feature { // Phase 1 schema doesn't carry `kind` on devices; map all to 'default' // for now. When the schema gains a kind/category column (Phase 2 of @@ -301,6 +357,7 @@ function buildPositionFeature( // Show the direction arrow only when the device is moving (>1 m/s). direction: p.course != null && (p.speed ?? 0) > 1, title: deviceLabel(device, p.deviceId), + staleSec: Math.max(0, Math.floor((now - p.ts) / 1000)), }, }; } diff --git a/src/routes/_authed/monitor.tsx b/src/routes/_authed/monitor.tsx index 87775b9..f782d3a 100644 --- a/src/routes/_authed/monitor.tsx +++ b/src/routes/_authed/monitor.tsx @@ -14,6 +14,7 @@ import { MapView } from '@/map/core/map-view'; import { TrailsToggle } from '@/map/core/trails-toggle'; import { MapPositions } from '@/map/layers/map-positions'; import { MapTrails } from '@/map/layers/map-trails'; +import { ConnectionChip } from '@/ui/components/connection-chip'; import { EventPicker } from '@/ui/components/event-picker'; export const Route = createFileRoute('/_authed/monitor')({ @@ -75,6 +76,11 @@ function MonitorPage() { */} + {/* + Status indicator. Bottom-left, subtle when connected, louder + when reconnecting / offline. + */} + ); diff --git a/src/ui/components/connection-chip.tsx b/src/ui/components/connection-chip.tsx new file mode 100644 index 0000000..925a0cb --- /dev/null +++ b/src/ui/components/connection-chip.tsx @@ -0,0 +1,55 @@ +import { formatLastSeen, useConnectionStore } from '@/live'; +import { cn } from '@/lib/utils'; + +/** + * Subtle WS-status indicator. Sits bottom-left of the monitor route. + * + * Three visible states: + * - **Connected** — small green dot + muted "Live" label. + * - **Connecting / reconnecting** — amber pulsing dot + status word. + * - **Disconnected** — red dot + "Offline · last live HH:MM" so the + * operator can see how long the gap has been. + * + * Designed to be ignorable when everything's fine and unmissable when + * it isn't — same posture as the design system's "live" pulse. + */ +export function ConnectionChip() { + const status = useConnectionStore((s) => s.status); + const lastConnectedAt = useConnectionStore((s) => s.lastConnectedAt); + + return ( +
+ {status === 'connected' && ( + <> + + Live + + )} + {(status === 'connecting' || status === 'reconnecting') && ( + <> + + + {status === 'connecting' ? 'Connecting…' : 'Reconnecting…'} + + + )} + {status === 'disconnected' && ( + <> + + + Offline + {lastConnectedAt ? <> · last live {formatLastSeen(lastConnectedAt)} : null} + + + )} +
+ ); +}