Self-contained page documenting where track fusion works and where the
prototype silently missed duplicates. Draggable contact pair over a
to-scale cell grid, live per-gate readout, and the coverage-by-latitude
comparison. Opens straight off disk, no build or server needed.
Rework the fuseTracks() prototype into a pure, tested module and wire it
into the asset pipeline so the Fused layer is populated correctly.
The prototype picked the right algorithm (uniform spatial grid) but the
search neighbourhood was a fixed 3x3 ring, which is narrower than the
2886 m distance gate at every European latitude because a degree of
longitude shrinks toward the pole. Measured, that lost 6% of true
duplicate pairs at 52N and 41% at 70N: the duplicates the feature exists
to remove stayed on the Contacts layer.
- TrackIndex derives the ring radius from the gate at the worst latitude
it will scan, so coverage is guaranteed rather than assumed. Global,
non-negative cell coordinates remove the hidden dependency on the
Europe display clamp being applied first.
- Gates report each measurement, and an unmeasurable gate resolves by
motionPolicy instead of passing on a NaN comparison. Default refuses to
fuse: losing a real contact is worse than showing a duplicate.
- Clustering is union-find by minimum identifier, so the canonical track
is a pure function of the report set and no longer flips with map
iteration order.
- The index is reconciled from state at one point, making a stale cell
entry structurally impossible rather than dependent on every retirement
path remembering to clean up.
- Fusion runs outside the state updater, throttled to one pass per 150 ms
instead of a full rescan per message, and returns the same map by
identity when no verdict changed.
38 module tests plus 7 hook integration tests, including a sweep
asserting no duplicate is missed anywhere in the theatre.
tsc --noEmit reported 39 errors across 13 files; the build never caught them
because vite transpiles without type checking. Root causes rather than casts:
- userPreferencesStore: DEFAULT_PREFERENCES inferred each field from its own
default, pinning minimapPosition to 'top-right' and theme to 'dark', so no
setter could assign any other member of the union. Declared UserPreferences
explicitly. This also fixes MiniMap's 'no overlap' comparison.
- ReportPanel: ReportData.assets was a structural clone of Asset with an index
signature, which nothing could satisfy and the panel cast back to Asset
anyway. It is Asset[] now, which unblocks App.tsx and the story.
- reporting: getAssetPosition also accepts raw payloads spelling the fields
longitude/latitude, so position is typed as the union it actually holds.
- useDraggablePanel: style is Partial<DraggablePanelStyle>; the empty-until-
dragged branch never satisfied Record<string, never>, and callers spreading
it over their defaults tripped duplicate-property errors.
- types: declare AssetDescription.callsign, a field the MQTT bridge sends.
- messageTemplates: widen a possibly-undefined local, and use a ternary so the
first operation is not typed 'false | Record'.
- stories: drop unused React imports.
tsc --noEmit is clean, eslint unchanged at 0 errors, 117 tests pass.
Cesium's MOUSE_MOVE event carries startPosition/endPosition, not position. The
reporting-mode handler read movement.position and passed undefined to
camera.pickEllipsoid, which threw 'windowPosition is required.' on every mouse
move while drawing — so the live rubber-band preview for rectangle, circle and
hexagon never rendered. Shapes still completed because LEFT_CLICK does carry
position, which hid the breakage.
getClickLonLat now takes a Cartesian2 directly so each call site passes the
field its own event actually has. The drawing helpers become arrow consts:
hoisted function declarations do not inherit the non-null narrowing of the
viewer guard above them, which was 15 of the file's type errors.
ws.close() left the socket's handlers attached, so its own onclose fired and
scheduled an exponential-backoff reconnect. Under StrictMode's double-mount a
single tab ended up holding three server WebSockets, and every asset_update was
delivered three times.
Detach handlers before closing, guard each handler against a superseded socket,
and cancel pending reconnect timers on teardown. Verified against the running
stack: /health reports websockets:1 for one open tab, down from 3.
Replaces the shape-only tests with coverage of the reconnect semantics.
The entity-prune step was guarded by currentAssets.size > 0, so selecting a
theater (or layer set) with no matching assets left stale markers on the globe.
Prune unconditionally and request a render when anything was removed.
Server (server.cjs):
- subscribe to a discovery wildcard (<prefix>/+/assets/status/#) plus the base
feed so every theater streams in automatically
- derive a theater id from each topic, stamp it on asset_update, track seen
theaters, and expose GET /api/config/theaters
Frontend:
- stamp description.theater on incoming assets; filter the globe + Navigation
by a selected theater
- theater <select> in the ControlPanel Navigation header (All + per-theater,
live counts)
- new Settings → Topic Mapping tab: label/enable/add/remove mappings + adopt
discovered theaters; persisted in user preferences (seeded with Test Feed +
TFX-Baltics/CMED/EMED/NorthSea/Arctic/BlackSea/Gulf)
- utils/theaters.ts (+ tests): deriveTheaterFromTopic, filterAssetsByTheater,
buildTheaterOptions
Auto-discover theaters from <prefix>/<THEATER>/assets/status/# traffic,
stamp asset_update messages with a theater id, expose /api/config/theaters,
and add client-side TopicMapping prefs with seeded TFX exercise theaters.
- Add docs/STYLE.md describing the .settings/style reference, per-surface
reproduction, and how holocron-frontend / holocron-sim docs map to it
- Link the design system from README.md and AGENTS.md
- Nudge holocron-frontend default-dark neutral tokens to reference values
(bg-primary, text-primary/secondary/muted); keep tactical accents
K8s is the production target — docker compose is local-only. Collapse the
three compose files (docker-compose.yml, .local.yml, .local-dev.yml) into a
single docker-compose.yml driven by profiles:
local → full stack in containers
dev → backing services only; frontend on host (npm run start:dev)
demo → synthetic MQTT publisher (composable with local or dev)
Make targets:
make up → --profile local --profile demo
make up-dev → --profile dev --profile demo
make up-bare → --profile local (no synthetic data)
All values (MQTT_TOPIC_PREFIX, MAPTILER_KEY, KEYCLOAK_*, host port mappings)
are env-driven with sensible local defaults. The same env contract is what
the K8s ConfigMap will inject in production.
Also:
- Bump frontend Dockerfile (local/dev) from node:18 → node:20-alpine.
Vite 7 requires crypto.hash (Node 20.12+). Also bumps the test-publisher
base image to node:20-alpine.
- Default host MQTT port moved to 1885 and frontend to 3002 (compose env
defaults) to avoid common conflicts; both overridable via .env.
Verified:
- docker compose --profile local --profile demo config parses OK
- docker compose --profile dev --profile demo config parses OK
- `make up` brings up 6 healthy containers
- curl localhost:7070/health → 200
- curl localhost:3002 → 200
- Test publisher streams UAV/SHIP/HELO/TANK positions on r2d2/holocron/...
- Browser screenshot confirms Cesium map rendering with live MQTT assets
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add refresh icon next to Navigation to reload map icons when symbols go missing
- CesiumViewer clears entities and re-syncs on refreshMapKey change
- Add RefreshIcon to PanelIcons, nav-header and nav-refresh-btn styles
- Ignore coverage directory in gitignore
- Camera panel: raise z-index above asset details
- Video panel: remove redundant bottom bar, fix drag disappearing
- Video panel: add feed info popover (protocol, codec, resolution, host target)
- useDraggablePanel: use position:fixed, only set position on actual drag
- UXV Viewer: center title, improve spacing, add resizable height
- Streams: add FeedInfo metadata (protocol, codec, bitrate) from config
- Add InfoIcon to PanelIcons