From 76aaa3ab388c68469b8a1323724f6f7a2bbb893f Mon Sep 17 00:00:00 2001 From: Snazzie Date: Wed, 21 Oct 2026 18:21:43 +0100 Subject: [PATCH 1/5] feat(replay): share and extract replay workspace --- .github/workflows/playwright-e2e.yml | 18 +- CHANGELOG.md | 2 + advisor-plans/lap-replay-backend-plan.md | 25 + advisor-plans/lap-replay-contract.md | 54 +++ advisor-plans/lap-replay-ui-plan.md | 26 ++ apps/backend/package.json | 1 + .../src/routes/laps/resource-routes.ts | 91 +--- .../test/routes/lap-analysis-route.test.ts | 53 --- bun.lock | 30 ++ client/package.json | 1 + client/src/components/InsightPanel.tsx | 118 ----- .../components/analyse/AnalyseAiSidebar.tsx | 2 +- .../components/analyse/AnalyseDataPanel.tsx | 34 +- .../components/analyse/AnalyseLapHeader.tsx | 10 - .../analyse/AnalyseResizeHandle.tsx | 43 -- .../analyse/AnalyseTelemetryChart.tsx | 7 +- .../analyse/AnalyseTimelineScrubber.tsx | 2 +- .../components/analyse/AnalyseTopSection.tsx | 251 ---------- .../components/analyse/AnalyseTrackPanel.tsx | 5 +- .../analyse/AnalyseWorkspacePanels.tsx | 95 ++-- client/src/components/analyse/LapAnalyse.tsx | 56 +-- .../analyse/useAnalyseSelections.ts | 22 +- .../comparison/ComparisonCharts.tsx | 8 +- .../components/comparison/LapComparison.tsx | 2 +- .../comparison/spectate/SpectateCarPanel.tsx | 31 -- .../comparison/spectate/SpectateInputs.tsx | 229 --------- .../comparison/spectate/SpectateLineScore.tsx | 34 -- .../comparison/spectate/SpectateOverview.tsx | 283 ------------ .../comparison/spectate/SpectatePlayback.tsx | 117 ----- .../comparison/spectate/SpectateScene.tsx | 276 ----------- .../comparison/spectate/SpectateSession.tsx | 151 ------ .../spectate/SpectateTelemetryPanel.tsx | 433 ------------------ .../comparison/spectate/SpectateView.tsx | 227 --------- client/src/components/tunes/semantic-tune.ts | 2 +- client/src/features/lap-replay-host.tsx | 37 ++ client/src/hooks/laps.ts | 24 +- client/src/hooks/useLapPlayback.ts | 17 +- client/src/index.css | 1 + client/src/labels-smoke.tsx | 8 +- client/src/lib/lap-overview.ts | 2 +- client/src/routes/__root.tsx | 3 + .../AnalyseDataPanelParity.stories.tsx | 32 +- client/src/stories/ExperimentFlow.stories.tsx | 2 +- client/test/lap-overview.test.ts | 2 +- client/test/lap-playback-timing.test.ts | 2 +- client/test/semantic-tune-consumers.test.ts | 2 +- client/test/telemetry-capabilities-ui.test.ts | 16 +- docs/architecture/feature-packages.md | 2 +- packages/lap-replay/contracts/host.ts | 69 +++ packages/lap-replay/contracts/index.ts | 4 + packages/lap-replay/contracts/messages.ts | 92 ++++ packages/lap-replay/contracts/replay.ts | 21 + .../lap-replay/contracts/spectate.ts | 4 +- packages/lap-replay/package.json | 20 + packages/lap-replay/server/index.ts | 97 ++++ .../test/e2e/analyse/workspace.spec.ts | 125 +++++ .../test/e2e}/compare/spectate-cache.spec.ts | 2 +- .../test/e2e}/compare/spectate-camera.spec.ts | 4 +- .../test/e2e}/compare/spectate-data.spec.ts | 4 +- .../test/e2e}/compare/spectate-focus.spec.ts | 4 +- .../e2e/compare/spectate-insights.spec.ts | 56 +++ .../compare/spectate-native-camera.spec.ts | 2 +- .../e2e}/compare/spectate-preferences.spec.ts | 62 ++- .../e2e}/compare/spectate-transition.spec.ts | 2 +- .../test/e2e}/compare/spectate-zoom.spec.ts | 2 +- .../test/e2e}/compare/spectate.spec.ts | 8 +- packages/lap-replay/test/e2e/package.json | 3 + .../lap-replay/test/integration-files.txt | 1 + .../test/integration/replay-routes.test.ts | 61 +++ packages/lap-replay/test/unit-files.txt | 7 + .../test/unit}/spectate-ers-mode.test.ts | 4 +- .../test/unit}/spectate-focus-lanes.test.ts | 2 +- .../test/unit}/spectate-focus.test.ts | 4 +- .../test/unit}/spectate-line-spread.test.ts | 6 +- .../test/unit}/spectate-replay.test.ts | 79 +++- .../unit}/spectate-track-geometry.test.ts | 8 +- .../test/unit}/spectate-trails.test.ts | 4 +- packages/lap-replay/tsconfig.json | 4 + packages/lap-replay/tsconfig.server.json | 5 + packages/lap-replay/tsconfig.ui.json | 5 + packages/lap-replay/turbo.json | 8 + packages/lap-replay/ui/InsightPanel.tsx | 114 +++++ .../lap-replay/ui/SingleReplayWorkspace.tsx | 33 ++ packages/lap-replay/ui/SpectateCarPanel.tsx | 29 ++ packages/lap-replay/ui/SpectateDataPanel.tsx | 48 ++ .../lap-replay/ui}/SpectateFocusToolbar.tsx | 2 +- packages/lap-replay/ui/SpectateInputs.tsx | 223 +++++++++ .../lap-replay/ui/SpectateInsightsPanel.tsx | 18 + packages/lap-replay/ui/SpectateLapTabs.tsx | 15 + packages/lap-replay/ui/SpectateLineScore.tsx | 32 ++ packages/lap-replay/ui/SpectateOverview.tsx | 281 ++++++++++++ packages/lap-replay/ui/SpectatePlayback.tsx | 115 +++++ packages/lap-replay/ui/SpectateScene.tsx | 272 +++++++++++ .../lap-replay/ui}/SpectateSceneRuntime.ts | 19 +- packages/lap-replay/ui/SpectateSession.tsx | 149 ++++++ .../lap-replay/ui/SpectateTelemetryPanel.tsx | 427 +++++++++++++++++ packages/lap-replay/ui/SpectateView.tsx | 78 ++++ packages/lap-replay/ui/SpectateWorkspace.tsx | 141 ++++++ packages/lap-replay/ui/host.tsx | 34 ++ packages/lap-replay/ui/index.ts | 12 + .../lap-replay/ui}/spectate-channels.ts | 2 +- .../lap-replay/ui}/spectate-ers-mode.ts | 11 +- .../lap-replay/ui}/spectate-focus-lanes.ts | 0 .../lap-replay/ui}/spectate-focus.ts | 2 +- .../lap-replay/ui}/spectate-line-spread.ts | 2 +- .../lap-replay/ui}/spectate-replay.ts | 27 +- packages/lap-replay/ui/telemetry-gap.ts | 6 + playwright/README.md | 7 +- playwright/config/projects.ts | 14 +- playwright/tests/seeded/analyse/controls.ts | 28 +- .../tests/seeded/analyse/cross-game.spec.ts | 6 +- playwright/tsconfig.json | 2 +- scripts/run-browser-verification.ts | 4 +- 113 files changed, 3153 insertions(+), 2668 deletions(-) create mode 100644 advisor-plans/lap-replay-backend-plan.md create mode 100644 advisor-plans/lap-replay-contract.md create mode 100644 advisor-plans/lap-replay-ui-plan.md delete mode 100644 client/src/components/InsightPanel.tsx delete mode 100644 client/src/components/analyse/AnalyseResizeHandle.tsx delete mode 100644 client/src/components/analyse/AnalyseTopSection.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateCarPanel.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateInputs.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateLineScore.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateOverview.tsx delete mode 100644 client/src/components/comparison/spectate/SpectatePlayback.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateScene.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateSession.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateTelemetryPanel.tsx delete mode 100644 client/src/components/comparison/spectate/SpectateView.tsx create mode 100644 client/src/features/lap-replay-host.tsx create mode 100644 packages/lap-replay/contracts/host.ts create mode 100644 packages/lap-replay/contracts/index.ts create mode 100644 packages/lap-replay/contracts/messages.ts create mode 100644 packages/lap-replay/contracts/replay.ts rename client/src/components/comparison/spectate/types.ts => packages/lap-replay/contracts/spectate.ts (92%) create mode 100644 packages/lap-replay/package.json create mode 100644 packages/lap-replay/server/index.ts create mode 100644 packages/lap-replay/test/e2e/analyse/workspace.spec.ts rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-cache.spec.ts (95%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-camera.spec.ts (99%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-data.spec.ts (97%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-focus.spec.ts (98%) create mode 100644 packages/lap-replay/test/e2e/compare/spectate-insights.spec.ts rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-native-camera.spec.ts (96%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-preferences.spec.ts (88%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-transition.spec.ts (98%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate-zoom.spec.ts (97%) rename {playwright/tests/seeded => packages/lap-replay/test/e2e}/compare/spectate.spec.ts (97%) create mode 100644 packages/lap-replay/test/e2e/package.json create mode 100644 packages/lap-replay/test/integration-files.txt create mode 100644 packages/lap-replay/test/integration/replay-routes.test.ts create mode 100644 packages/lap-replay/test/unit-files.txt rename {client/test => packages/lap-replay/test/unit}/spectate-ers-mode.test.ts (82%) rename {client/test => packages/lap-replay/test/unit}/spectate-focus-lanes.test.ts (98%) rename {client/test => packages/lap-replay/test/unit}/spectate-focus.test.ts (97%) rename {client/test => packages/lap-replay/test/unit}/spectate-line-spread.test.ts (78%) rename {client/test => packages/lap-replay/test/unit}/spectate-replay.test.ts (84%) rename {client/test => packages/lap-replay/test/unit}/spectate-track-geometry.test.ts (93%) rename {client/test => packages/lap-replay/test/unit}/spectate-trails.test.ts (94%) create mode 100644 packages/lap-replay/tsconfig.json create mode 100644 packages/lap-replay/tsconfig.server.json create mode 100644 packages/lap-replay/tsconfig.ui.json create mode 100644 packages/lap-replay/turbo.json create mode 100644 packages/lap-replay/ui/InsightPanel.tsx create mode 100644 packages/lap-replay/ui/SingleReplayWorkspace.tsx create mode 100644 packages/lap-replay/ui/SpectateCarPanel.tsx create mode 100644 packages/lap-replay/ui/SpectateDataPanel.tsx rename {client/src/components/comparison/spectate => packages/lap-replay/ui}/SpectateFocusToolbar.tsx (98%) create mode 100644 packages/lap-replay/ui/SpectateInputs.tsx create mode 100644 packages/lap-replay/ui/SpectateInsightsPanel.tsx create mode 100644 packages/lap-replay/ui/SpectateLapTabs.tsx create mode 100644 packages/lap-replay/ui/SpectateLineScore.tsx create mode 100644 packages/lap-replay/ui/SpectateOverview.tsx create mode 100644 packages/lap-replay/ui/SpectatePlayback.tsx create mode 100644 packages/lap-replay/ui/SpectateScene.tsx rename {client/src/components/comparison/spectate => packages/lap-replay/ui}/SpectateSceneRuntime.ts (98%) create mode 100644 packages/lap-replay/ui/SpectateSession.tsx create mode 100644 packages/lap-replay/ui/SpectateTelemetryPanel.tsx create mode 100644 packages/lap-replay/ui/SpectateView.tsx create mode 100644 packages/lap-replay/ui/SpectateWorkspace.tsx create mode 100644 packages/lap-replay/ui/host.tsx create mode 100644 packages/lap-replay/ui/index.ts rename {client/src/components/comparison/spectate => packages/lap-replay/ui}/spectate-channels.ts (99%) rename {client/src/components/comparison/spectate => packages/lap-replay/ui}/spectate-ers-mode.ts (59%) rename {client/src/components/comparison/spectate => packages/lap-replay/ui}/spectate-focus-lanes.ts (100%) rename {client/src/components/comparison/spectate => packages/lap-replay/ui}/spectate-focus.ts (98%) rename {client/src/lib => packages/lap-replay/ui}/spectate-line-spread.ts (93%) rename {client/src/lib => packages/lap-replay/ui}/spectate-replay.ts (93%) create mode 100644 packages/lap-replay/ui/telemetry-gap.ts diff --git a/.github/workflows/playwright-e2e.yml b/.github/workflows/playwright-e2e.yml index ee34a92db..2be288917 100644 --- a/.github/workflows/playwright-e2e.yml +++ b/.github/workflows/playwright-e2e.yml @@ -71,42 +71,42 @@ jobs: runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-1 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 1/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-2 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 2/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-3 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 3/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-4 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 4/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-5 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 5/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-6 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 6/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - name: seeded-shard-7 server_set: seeded - projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue + projects: --project=seeded-e2e --project=seeded-routes --project=seeded-imports --project=tracks-catalogue --project=cars-catalogue --project=lap-replay shard: 7/7 runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' - - name: seeded-webgpu + - name: lap-replay-webgpu server_set: seeded - projects: --project=seeded-webgpu + projects: --project=lap-replay-webgpu runs_on: '["mars-ubuntu-2vcpu-6g", "mars-retry-3"]' uses: ./.github/workflows/playwright.yml with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d837afd1..a1b2f728a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ - Show separate LMU player energy, engine, electric motor, steering feedback, weather and race-control readings in live telemetry and recorded-lap analysis when provided by the game. - Show read-only LMU setup, forecast and condition snapshots with source age and availability, without waiting for network requests before displaying live telemetry. - Compare laps in Spectate with synchronized 3D replay, configurable cameras and overlays, live A/B instruments, sector deltas, and telemetry, car, AI, Focus View, and lap-statistics panels. +- Inspect recorded telemetry in Compare → Spectate with a Data panel and switch between lap A and B without showing both panels at once. +- Share the Spectate workspace layout between Replay and Compare, with an Insights panel on both pages. Keep Replay single-lap with its own AI, switch Compare findings between laps A and B, and seek recorded events on the selected lap without losing native timing. - Choose a 50–100 Hz recording frequency for ACC, AC Evo, iRacing, or LMU during setup to reduce recording size. Live UDP recording is unchanged. - Downsample saved recordings manually or with an opt-in age policy in Storage settings; measure the first 1,000 frame timestamps per stream and skip recordings already at or below the target. Track progress and retry failed files. Favorites and MoTeC imports stay unchanged. - Split recordings into individual, lossless per-lap `.bin` files so lap 60 loads as fast as lap 1. diff --git a/advisor-plans/lap-replay-backend-plan.md b/advisor-plans/lap-replay-backend-plan.md new file mode 100644 index 000000000..b7e9e7795 --- /dev/null +++ b/advisor-plans/lap-replay-backend-plan.md @@ -0,0 +1,25 @@ +# Lap replay server implementation + +Contract: [shared integration contract](lap-replay-contract.md). Reciprocal UI work: [UI plan](lap-replay-ui-plan.md). + +## Scope and ownership + +Own replay/Insights handlers currently in `apps/backend/src/routes/laps/resource-routes.ts`, feature semantic DTOs under `packages/lap-replay/contracts/replay.ts`, package-owned server implementation and focused endpoint tests. Preserve shared DB/capture/analysis services, middleware and application lifetime ownership. The coordinator owns package manifests, public barrels, app registration and final test discovery. + +## Tasks + +1. Move `SemanticReplayFrame` and `SemanticLapTelemetry` definitions from the client hook into browser-safe contracts; reuse authoritative LMU wire, insight and coverage types. Preserve the wire format; strengthen the existing unknown findings array to `LapInsight[]` only where the real handler already guarantees it. +2. Extract actual Hono handlers for semantic telemetry, Insights reads and Insights rerun into `server/`, preserving existing source/artifact selection, native replay, frame-index restoration, serialization and errors. Export real routes through `/server`; remove old handlers rather than wrap them (C4/C5). +3. Compose routes in the existing app with inherited auth/CSRF/origin behavior and inferred RPC typing. Move exclusively owned server tests if found; keep shared cross-feature tests centrally owned and migrate their public imports (C4/C5). + +## Acceptance and verification + +Server acceptance is C4 and server/contracts ownership in C5; C1/C2 depend on the unchanged real backend integration. Reuse all shared E2E scenario definitions. Validate shared-ID/header failures, wrong-game/missing laps, parsing failures and uncommitted live-capture states against existing source fixtures. Follow the shared contract's authoritative distinction between ordinary unavailable capture results and the later publication-race guard. Preserve current handling rather than invent new fallback data, clocks, indices, auth or persistence behavior. + +The coordinator runs feature server/unit gates, real API error/recording scenarios, full UI/backend replay/Insights integration, public typing, boundary rejection and production browser build. Record passing results and any unavailable environment explicitly. Server and UI implementations proceed independently after the shared contract; final completion depends on integrated E2E evidence. + +## Implementation outcome + +The package owns the three real Hono handlers, semantic DTOs and focused route integration suite; application registration preserves the typed route chain and shared middleware. Existing mixed comparison/AI route coverage remains centrally owned. + +Executed outcomes are linked from the [shared verification record](lap-replay-contract.md#recorded-verification). Real compiled HTTP coverage includes ordinary errors, rerun persistence and both unavailable-capture states. The unchanged later publication-disappearance race and generic replay-failure catch were not fault-injected; their handling was preserved, not claimed as independently exercised. diff --git a/advisor-plans/lap-replay-contract.md b/advisor-plans/lap-replay-contract.md new file mode 100644 index 000000000..a25329a59 --- /dev/null +++ b/advisor-plans/lap-replay-contract.md @@ -0,0 +1,54 @@ +# Lap replay package contract + +Status: implementation contract, derived from the existing feature-package architecture and current replay routes. No HTTP, authentication, telemetry, or AI behavior is being redesigned. + +Plans: [UI](lap-replay-ui-plan.md), [Server](lap-replay-backend-plan.md). Architecture: [feature packages](../docs/architecture/feature-packages.md). + +## Ownership and integration + +One workspace, `@raceiq/lap-replay`, owns `ui/`, `server/`, `contracts/`, `test/unit/`, and `test/e2e/`. Public imports are `@raceiq/lap-replay/ui`, `/server`, and `/contracts`; no mixed root or wildcard export. Existing shared analysis, comparison, telemetry, and scene DTOs remain authoritative in their current packages. Move the client-owned semantic replay DTOs into feature contracts without duplicating them. + +The package owns the Spectate component tree, replay controller/preparation, line-spread presentation helper, shared Insights rendering and panel workspace. Application routes continue composing session/lap selection, single-lap AI and comparison AI separately. Shared platform controls, translations, authenticated RPC, settings/game context and telemetry/scene primitives are injected through typed host bindings, following the catalogue provider pattern. No package source imports client or backend application composition roots. Do not conceal feature implementation behind untyped host callbacks. + +The single replay uses one recorded lap and lap-scoped AI. Compare uses the existing two distinct laps and comparison-scoped AI. Both consume the same workspace and Insights UI; Compare adds the shared A/B selector. No duplicate lap, fake comparison, copied UI, or compatibility re-export remains. + +## Existing operations preserved + +- `GET /api/laps/:id/semantic-telemetry`: use the existing shared [`IdParamSchema`](../shared/platform/http/route-schemas.ts), with required `X-Game-Id`; return the existing `SemanticLapTelemetry` response. Required fields: numeric `lapId`, `requestedSemanticIds: string[]`, and ordered `envelopes: SemanticReplayFrame[]`. Preserve optional/nullable sector times, sector starts, parse error, findings and detector coverage. Each envelope retains numeric sequence, observed/received `{domain: string, milliseconds: number}`, simulator string, semantic value entries (`semanticId`, unknown `value`, optional nullable `unit`, optional state/freshness), and optional existing LMU wire context. Findings use authoritative `LapInsight`; frame indices refer to the original replay frame array, not aligned comparison indices or sequence numbers. +- `GET /api/laps/:id/insights`: same ID/header rules; `{ insights: LapInsight[] }`. +- `POST /api/laps/:id/insights/rerun`: preserve the existing request, authorization, computation, response and error semantics; no new authentication rule. +- The extracted handlers in [`packages/lap-replay/server/index.ts`](../packages/lap-replay/server/index.ts) preserve exact serialization and errors: invalid/missing game header 400, missing/wrong-game lap 404, replay failure 422, and parse failure represented by the existing successful empty/unavailable response. In particular, an absent committed live member is normally returned as a 200 parse-error/unavailable result by the authoritative lap loader; the existing later 409 guard remains for a publication disappearing between reads. Numeric zero is accepted by the shared ID parser and resolves as a missing lap, not a validation error. Preserve shared middleware and Hono-inferred RPC typing. There is no pagination or new persistence state. + +Representative semantic result: `{ "lapId": 31, "requestedSemanticIds": ["motion.speed"], "sectorTimes": null, "sectorStarts": null, "insights": [], "detectorCoverage": [], "parseError": null, "envelopes": [] }`. Actual recorded envelopes and analysis values remain unmodified. + +## Acceptance and end-to-end evidence + +| ID | Observable criterion | Actual application scenarios | +| --- | --- | --- | +| C1 | Replay retains one scene, single-lap AI, playback/speed/scrub and lap-change reset. | Open seeded Replay, toggle panels, play/pause, change speed, seek an insight and switch session laps; assert recorded position/cursor and lap-scoped AI status response. | +| C2 | Compare retains paired replay/AI and shows A/B Insights through the same findings UI. | Open real compatible pair, choose each Insights side, click a recorded finding and verify corresponding rendered native pose; compare existing paired playback and Data scenarios. | +| C3 | Panel controls remain accessible and usable at desktop/mobile without page overflow. | Run feature workspace and Insights E2Es at 1440x1000 and 390x844; keyboard resize does not seek replay, responsive transition closes telemetry, panel headers match their bodies. | +| C4 | Existing HTTP and error behavior survives extraction. | Call real semantic/insight routes with valid, missing, wrong-game and invalid inputs; exercise real recording-backed replay from feature UI, including existing failure coverage. | +| C5 | Feature and tests have one package owner and enforced browser/server boundaries. | Compare full test discovery before/after; run package unit/E2E tasks, boundary checks including deliberate forbidden import rejection, UI/server/host typechecks and production browser build. Verify feature UI/server/spec changes select owner while unrelated feature edits do not; shared infrastructure retains conservative coverage. | + +Edge handling: missing/failed telemetry preserves current alerts/retry and unavailable coverage (C1/C2/C4); empty findings render the shared no-findings/coverage states (C1/C2); coalesced ticks map events to retained native frames and excluded tails clamp to the final retained tick (C2); invalid event indices do nothing (C2); B events use distance alignment only when invertible, otherwise native time alignment keeps the event reachable, including plateaus and A-coverage gaps (C2). Existing controller unit regressions supplement, not replace, recorded-pose E2Es. + +Integration owner: the current coordinator owns package manifest/configuration, shared contract exports, app registration, test discovery/selection and final verification. UI and server ownership are disjoint after this contract. Completion requires recorded passing outcomes, not planned or blocked tests. No checkpoint was requested. + +## Recorded verification + +The implementation now lives in `packages/lap-replay`; client and backend retain typed composition and registration. The application uses explicit public exports. The existing standalone labels smoke caller and Insights story also receive the host provider. No workspace commit or durable checkpoint was created. + +| Criteria | Executed evidence and outcome | +| --- | --- | +| C1–C3 | `RACEIQ_FEATURE_F1_EXPERIMENTS=true RACEIQ_FEATURE_IRACING_ADAPTER=true bunx turbo run test:e2e --filter=@raceiq/lap-replay` built the complete production application, ran both feature projects, and exited 0. Playwright reported 30 passed and one flaky case that passed on retry, including the shared setup case. Both desktop/mobile Replay scenarios, both A/B recorded-pose scenarios, panel persistence/alignment/Data/AI scenarios, and all three hardware WebGPU cases passed. The flaky existing zoom scenario expected a radius above 7,000 but observed 5,581 on its first attempt. [Full output](../playwright/test-results/lap-replay-verification/compiled-e2e.log). | +| C1/C3 | Independent compiled-app browser inspection confirmed exactly one single-lap renderer, no paired playback, desktop/mobile panels, and no horizontal page overflow. The single renderer remains height-bounded after panel/viewport changes; an intrinsic canvas sizing feedback loop was fixed by positioning the scene content independently of its intrinsic dimensions. [Mobile Replay](../playwright/test-results/lap-replay-verification/replay-mobile.png). LMU semantic telemetry and lap-scoped AI status each returned 200 through the compiled app. | +| C2 | The independent browser selected B in the shared Insights UI while paired playback remained mounted, without page overflow. [Selected B](../playwright/test-results/lap-replay-verification/compare-insights-b.png). Native coalescing, excluded tails, invalid indices, plateaus and missing A coverage additionally pass the owned controller regressions; those rare inputs were not individually forced through browser fixtures. | +| C4 | The package-owned real route suite passed two tests/17 assertions. The compiled-app Replay API E2E passed valid recordings, both GET header/error paths, missing/wrong-game laps, nonnumeric IDs, and real rerun persistence. Separate isolated-DB fixtures exercised the compiled HTTP entry point for incomplete published references and an uncommitted live member: both preserved the existing 200 parse-error response, empty telemetry/findings and unavailable coverage. [Route suite](../playwright/test-results/lap-replay-verification/route-integration.log), [actual error states](../playwright/test-results/lap-replay-verification/error-states.txt). The later publication-disappearance race and generic 422 catch were not fault-injected. | +| C5 | Package UI/server, client, stories, backend and Playwright typing passed; package lint and boundaries passed. The unit owner passed 59 tests/315 assertions in seven files. The shard gate assigned all 421 ordinary tests exactly once. An isolated copy of the actual feature rejected a deliberate UI-to-server export through the real boundary CLI, exit 1; the valid production build and real feature flows then passed. [Scoped gates](../playwright/test-results/lap-replay-verification/typecheck-unit-boundary.log). | +| C5 | Full Playwright discovery succeeded with 266 tests in 68 files. All 11 relocated feature specs have exactly one owner: 27 ordinary feature cases and three native GPU cases, with no duplicate or retired central feature registrations. The E2E directory explicitly retains CommonJS execution like the shared central harness, avoiding mixed-module transformation failures without changing production package modules or shared catalogue generation. No pre-extraction full-suite count was retained. [Discovery inventory](../playwright/test-results/lap-replay-verification/discovery.json). | +| C5 | Eight controlled scenarios using the current manifests/configuration and the real Turbo affected CLI passed: Replay UI/server/spec and host selected Replay only; unrelated Cars UI excluded Replay; shared track contracts selected Tracks and Replay; shared harness selected all three features; unchanged history selected none. An invalid explicit baseline failed closed with a nonzero Git error, not successful empty coverage. [Selection evidence](../playwright/test-results/lap-replay-verification/impact.json). The earlier failing feature E2Es propagated nonzero through Playwright, the package script and Turbo before correction. | + +Only the affected feature suites were executed, not all unrelated full-mode application tests. Full discovery remains registered in the shared browser runner, demo dependencies and CI matrix. Evidence files are generated under the ignored Playwright results directory; they are not checkpoint files. + +The remaining client telemetry UI consumer regression suite also passed: `bun test ./test/telemetry-capabilities-ui.test.ts` from `client/` ran 27 tests/150 assertions. A final client typecheck passed after adapting the pre-existing standalone smoke caller. No application imports of retired or private replay paths remain. The temporary local Playwright configuration, accidentally rooted fixture data, owned browser tab and all owned verification servers were removed or stopped. diff --git a/advisor-plans/lap-replay-ui-plan.md b/advisor-plans/lap-replay-ui-plan.md new file mode 100644 index 000000000..8c718edcd --- /dev/null +++ b/advisor-plans/lap-replay-ui-plan.md @@ -0,0 +1,26 @@ +# Lap replay UI implementation + +Contract: [shared integration contract](lap-replay-contract.md). Reciprocal server work: [server plan](lap-replay-backend-plan.md). + +## Scope and ownership + +Own the existing `client/src/components/comparison/spectate/` implementation, shared `InsightPanel`, replay preparation/controller and line-spread presentation helper, their exclusively owned unit tests and browser E2Es. Move these into `packages/lap-replay/ui/`, `contracts/`, and `test/`; keep application route selection and shared scene/telemetry/AI platform composition in typed client host bindings. Do not move unrelated import/export/session-maintenance or Focus View behavior solely to fill package directories. + +## Tasks + +1. Establish explicit UI host/provider contracts using the catalogue convention. Bind existing app controls, translations, settings, game context, queries and model resources without reverse client imports. Move authoritative replay types to contracts with the server owner; retain current shared DTO ownership. +2. Move Spectate UI, shared workspace and Insights rendering without copied implementations or old forwarding files. Migrate all direct callers, stories, tests and smoke consumers to public exports. Preserve distinct single-lap and paired AI composition (C1/C2). +3. Move native playback/preparation and focused unit tests. Preserve native clock/progress semantics, sample gaps and unavailable states. Cover invalid/coalesced/tail event indices, B plateaus and missing A coverage with controller tests (C2/C4). +4. Move every exclusively owned Spectate spec and new Replay workspace spec to `test/e2e/`; retain genuinely shared central helpers and exactly one Playwright owner. Update the header scenario for five panels without pinning an obsolete DOM order (C3/C5). + +## Acceptance and verification + +UI acceptance is C1, C2, C3 and the UI/test part of C5 in the shared contract. Reuse its E2E scenarios and edge handling verbatim: desktop/mobile single replay, A/B native event seeks, panel/keyboard transitions, paired playback/Data and existing unavailable/retry behavior. Run against the complete application and real backend; fixtures/mocks do not replace final integration. + +The coordinator executes those scenarios after integration, records outcomes, checks browser-safe imports through scoped TypeScript and boundary gates, and runs the production browser build. Source/config/fixture changes invalidate affected previous evidence. UI and server can implement independently against the agreed contract; only final integration depends on both. + +## Implementation outcome + +The package owns both panel configurations, shared workspace/Insights UI, the complete paired scene/controller implementation, browser-safe contracts and all 11 feature browser specs. The application supplies existing scene, telemetry, single-lap AI and comparison AI primitives through typed composition and host bindings. Package tests retain the shared harness's CommonJS execution mode without adding another workspace. + +Executed outcomes, screenshots, input-boundary coverage and the existing zoom retry are recorded once in the [shared verification record](lap-replay-contract.md#recorded-verification). Both desktop/mobile Replay and A/B recorded-event scenarios passed against the compiled real application. Rare controller-input cases have unit evidence rather than individually forced browser fixtures. diff --git a/apps/backend/package.json b/apps/backend/package.json index 5fe408341..0097aaca6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -43,6 +43,7 @@ "@mastra/hono": "^1.7.9", "mitata": "^1.0.34", "@raceiq/backend-core": "workspace:*", + "@raceiq/lap-replay": "workspace:*", "@raceiq/shared": "workspace:*", "@raceiq/capture-formats": "workspace:*", "@raceiq/capture-splitting": "workspace:*", diff --git a/apps/backend/src/routes/laps/resource-routes.ts b/apps/backend/src/routes/laps/resource-routes.ts index e07eca483..a2fc2dd2b 100644 --- a/apps/backend/src/routes/laps/resource-routes.ts +++ b/apps/backend/src/routes/laps/resource-routes.ts @@ -3,25 +3,19 @@ import { Hono } from "hono"; import { gzip } from "node:zlib"; import { promisify } from "node:util"; import { z } from "zod"; +import { replayRoutes } from "@raceiq/lap-replay/server"; import { IdParamSchema } from "@raceiq/shared/platform/http/route-schemas"; import { GameIdSchema, type GameId } from "@raceiq/shared/games/ids"; import { getGame, tryGetGame } from "@raceiq/shared/games/registry"; -import { analyseSemanticIds } from "@raceiq/shared/games/metric-contracts"; import { encodeLmuPacket } from "@raceiq/shared/telemetry/lmu-context"; -import { liveSemanticIds } from "@raceiq/telemetry-core/telemetry/live/semantics"; -import { runInsightScanWithCoverage } from "@raceiq/analysis-core/racing/analysis/laps/insights/scan"; -import { processLap, restoreFrameIndices } from "@raceiq/analysis-core/racing/analysis/laps/insights/process"; -import { INSIGHT_DETECTORS } from "@raceiq/analysis-core/racing/analysis/laps/insights/types"; -import { resolveRacingLineReference } from "@raceiq/backend-core/lap-analysis/insights"; -import { backfillLapInsights, getOrComputeLapInsights, recomputeLapInsights } from "@raceiq/backend-core/lap-analysis/metrics-store"; +import { backfillLapInsights, getOrComputeLapInsights } from "@raceiq/backend-core/lap-analysis/metrics-store"; import { alignLapSet, prepareLapSetAlignmentIndex, type AlignmentLapInput } from "@raceiq/analysis-core/racing/laps/alignment/build"; import { encodeAlignedLapSet } from "@raceiq/analysis-core/racing/laps/alignment/codec"; import type { EncodedAlignedLapSet } from "@raceiq/analysis-core/racing/laps/alignment/types"; -import { getLaps, getLapMetaById, getLapById, getLapsByIds, getLapsRaw, getReviewLaps, getSessionLaps } from "@raceiq/backend-core/db/lap-read-queries"; -import { getLapReplaySource, alignedTelemetryCacheGet, alignedTelemetryCacheSet, lapSetAlignmentIndexCacheGet, lapSetAlignmentIndexCacheSet } from "@raceiq/backend-core/db/telemetry-replay-storage"; +import { getLaps, getLapById, getLapsByIds, getLapsRaw, getReviewLaps, getSessionLaps } from "@raceiq/backend-core/db/lap-read-queries"; +import { alignedTelemetryCacheGet, alignedTelemetryCacheSet, lapSetAlignmentIndexCacheGet, lapSetAlignmentIndexCacheSet } from "@raceiq/backend-core/db/telemetry-replay-storage"; import { loadSessionCapture, loadSessionSource, resolveLapCaptureArtifact } from "@raceiq/backend-core/session-capture/source-loader"; -import { createRawCaptureIdentity, loadRawCaptureIdentity } from "@raceiq/backend-core/session-capture/identity"; import { getLiveLapCaptureSnapshot } from "@raceiq/backend-core/db/live-capture-queries"; import { resolveLiveLapCaptureArtifact } from "@raceiq/backend-core/session-capture/source-loader"; import { deleteLap, updateLapNotes, updateLapValidity } from "@raceiq/backend-core/db/lap-mutation-queries"; @@ -36,17 +30,12 @@ import { computeLineSpreadTrace } from "@raceiq/backend-core/lap-analysis/consis import { hasWorldPositions } from "@raceiq/shared/racing/tracks/path"; import { resolveLapCorners } from "../../tracks/corner-resolution"; import { BulkDeleteSchema, LapsQuerySchema, ReviewLapsQuerySchema, ReviewLineSpreadQuerySchema, CompareLineSpreadQuerySchema, AlignedTelemetryRequestSchema, LapInsightBackfillSchema } from "./support"; -import { resolveTelemetryReplay } from "@raceiq/backend-core/telemetry/replay"; import { resolveLapF1Setup } from "@raceiq/backend-core/ai/f1-setup-identity"; -export function semanticReplayIds(gameId: GameId): readonly string[] { - return analyseSemanticIds(getGame(gameId)); -} -const timestampMilliseconds = (timestamp: { domain: string; milliseconds?: number; nanoseconds?: bigint }) => - timestamp.domain === "monotonic" ? Number(timestamp.nanoseconds ?? 0n) / 1_000_000 : timestamp.milliseconds ?? 0; const gzipAsync = promisify(gzip); export const resourceRoutes = new Hono() + .route("/", replayRoutes) .get("/api/laps", zValidator("query", LapsQuerySchema), async (c) => { const { gameId, sessionId } = c.req.valid("query"); const lapList = sessionId != null ? await getSessionLaps(gameId!, sessionId) : await getLaps(gameId); @@ -90,72 +79,10 @@ export const resourceRoutes = new Hono() }) - .get("/api/laps/:id/semantic-telemetry", zValidator("param", IdParamSchema), async (c) => { - const { id } = c.req.valid("param"); - const gameIdResult = GameIdSchema.safeParse(c.req.header("X-Game-Id")); - if (!gameIdResult.success) return c.json({ error: "Missing or invalid X-Game-Id header" }, 400); - try { - const meta = await getLapMetaById(id); - if (!meta || meta.gameId !== gameIdResult.data) return c.json({ error: "Lap not found" }, 404); - const lap = await getLapById(id); - if (!lap) return c.json({ error: "Lap not found" }, 404); - if (lap.parseError) { - return c.json({ - lapId: id, requestedSemanticIds: [], sectorTimes: meta.sectorTimes ?? null, - sectorStarts: null, insights: [], detectorCoverage: INSIGHT_DETECTORS.map((detector) => ({ ...detector, status: "unavailable" as const, reason: "Lap telemetry could not be parsed" })), parseError: lap.parseError, envelopes: [], - }); - } - const source = await getLapReplaySource(id); - if (!source) return c.json({ error: "Lap not found" }, 404); - const live = source.captureLayoutVersion === 3 ? await getLiveLapCaptureSnapshot(source.sessionId, id) : null; - const artifact = source.captureLayoutVersion === 3 - ? live ? resolveLiveLapCaptureArtifact(live.archivePath, live.snapshot) : undefined - : (source.captureLayoutVersion === 1 || source.captureLayoutVersion === 2) && source.captureManifestFile && source.captureChunkId - ? await resolveLapCaptureArtifact(source.captureManifestFile, source.captureChunkId, id, source) - : undefined; - if (source.captureLayoutVersion === 3 && !artifact) return c.json({ error: "Live lap capture member is not committed" }, 409); - const replaySource = artifact - ? { ...source, rawByteOffset: artifact.chunk.ownedFrameByteOffset!, rawFrameCount: artifact.chunk.ownedFrameCount! } - : source; - const rawCapture = source.gameId === "iracing" - ? artifact - ? createRawCaptureIdentity( - await loadSessionCapture({ rawFile: artifact.path, artifact: artifact.artifact, source: null, gameId: source.gameId, carId: meta.carId, trackId: meta.trackId }), - artifact.artifact ? "gzip" : "identity", - ) - : source.rawFile ? await loadRawCaptureIdentity(source.rawFile) : undefined - : undefined; - const semanticIds = meta.gameId === "lmu" ? [...new Set([...semanticReplayIds(meta.gameId), ...liveSemanticIds("lmu")])] : semanticReplayIds(meta.gameId); - const replay = resolveTelemetryReplay(id, replaySource, lap.telemetry, semanticIds, rawCapture); - const nativeLayout = getGame(meta.gameId).getNativeSectorLayout?.(lap.telemetry[0]); - const processed = processLap(lap.telemetry, meta.gameId); - const analysis = runInsightScanWithCoverage(processed.packets, meta.gameId, { racingLine: resolveRacingLineReference(meta.gameId, meta.trackId) }); - restoreFrameIndices(analysis.insights, processed.sourceIndices); - return c.json({ - lapId: replay.lapId, requestedSemanticIds: replay.requestedSemanticIds, - sectorTimes: meta.sectorTimes ?? null, sectorStarts: nativeLayout?.starts ?? null, - insights: analysis.insights, detectorCoverage: analysis.detectorCoverage, parseError: lap.parseError ?? null, - envelopes: replay.envelopes.map((envelope) => ({ - sequence: Number(envelope.sequence), - observedAt: { domain: "wall-clock", milliseconds: timestampMilliseconds(envelope.observedAt) }, - receivedAt: { domain: "wall-clock", milliseconds: timestampMilliseconds(envelope.receivedAt) }, - simulator: envelope.simulator, - values: envelope.values.map(({ semanticId, value, unit, state, freshness }) => ({ semanticId, value, unit, state, freshness })), - ...(envelope.lmu ? { lmu: envelope.lmu } : {}), - })), - }); - } catch (error) { - return c.json({ error: error instanceof Error ? error.message : "Unable to replay telemetry" }, 422); - } - }) .post("/api/lap-insights/backfill", zValidator("json", LapInsightBackfillSchema), async (c) => c.json(await backfillLapInsights(c.req.valid("json"))), ) - .post("/api/laps/:id/insights/rerun", zValidator("param", IdParamSchema), async (c) => { - const insights = await recomputeLapInsights(c.req.valid("param").id); - return insights ? c.json({ insights }) : c.json({ error: "Lap not found or has no telemetry" }, 404); - }) .post("/api/laps/bulk-delete", zValidator("json", BulkDeleteSchema), async (c) => { const { ids } = c.req.valid("json"); @@ -204,14 +131,6 @@ export const resourceRoutes = new Hono() return c.json({ setup: gameIdResult.data === "f1-2025" ? resolveLapF1Setup({ carSetup: lap.carSetup, telemetry: lap.telemetry }) : null }); }) - .get("/api/laps/:id/insights", zValidator("param", IdParamSchema), async (c) => { - const gameId = GameIdSchema.safeParse(c.req.header("X-Game-Id")); - if (!gameId.success) return c.json({ error: "Missing or invalid X-Game-Id header" }, 400); - const { id } = c.req.valid("param"); - const meta = await getLapMetaById(id); - if (!meta || meta.gameId !== gameId.data) return c.json({ error: "Lap not found" }, 404); - return c.json({ insights: await getOrComputeLapInsights(id) ?? [] }); - }) .get("/api/laps/:id", zValidator("param", IdParamSchema), async (c) => { const gameIdResult = GameIdSchema.safeParse(c.req.header("X-Game-Id")); diff --git a/apps/backend/test/routes/lap-analysis-route.test.ts b/apps/backend/test/routes/lap-analysis-route.test.ts index d086ec536..5663dfd7f 100644 --- a/apps/backend/test/routes/lap-analysis-route.test.ts +++ b/apps/backend/test/routes/lap-analysis-route.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test"; import { initGameAdapters } from "@raceiq/game-catalogs/games/init"; -import { getGame } from "@raceiq/shared/games/registry"; -import { analyseSemanticIds } from "@raceiq/shared/games/metric-contracts"; import { decodeAlignedLapSet } from "@raceiq/analysis-core/racing/laps/alignment/codec"; import type { EncodedAlignedLapSet } from "@raceiq/analysis-core/racing/laps/alignment/types"; @@ -10,61 +8,10 @@ import { deleteSession, insertSession, updateSessionRawFile } from "@raceiq/back import { cacheDelete, cacheSet } from "@raceiq/backend-core/db/telemetry-replay-storage"; import { insertLap } from "@raceiq/backend-core/db/lap-mutation-queries"; import { lapRoutes } from "../../src/routes/laps/index"; -import { semanticReplayIds } from "../../src/routes/laps/resource-routes"; import { packet } from "@raceiq/telemetry-core/test-support/telemetry/resolver"; initGameAdapters(); -test("semantic replay requests F1 Analyse dependencies", () => { - const ids = semanticReplayIds("f1-2025"); - expect(ids).toEqual(analyseSemanticIds(getGame("f1-2025"))); - expect(ids).toContain("timing.current-lap"); - expect(ids).toContain("timing.lap-fraction"); - expect(ids).toContain("tire.temperature.surface.representative"); - expect(ids).toContain("tire.temperature.surface.inner"); - expect(ids).toContain("tire.temperature.surface.middle"); - expect(ids).toContain("tire.temperature.surface.outer"); - expect(ids).toContain("tire.temperature.core"); - expect(ids).not.toContain("tires.wheel-in-puddle-depth"); -}); - -describe("GET /api/laps/:id/semantic-telemetry", () => { - test("returns full F1 Analyse replay with adapter channels", async () => { - const sessionId = await insertSession("0", "0", "f1-2025"); - const lapId = await insertLap(sessionId, 1, 80.9, true, null, 0, null, null, null, null, null); - cacheSet(lapId, [ - packet("f1-2025", { TimestampMS: 1_000, Speed: 10 }), - packet("f1-2025", { TimestampMS: 1_017, Speed: 20 }), - ]); - try { - const response = await lapRoutes.request(`/api/laps/${lapId}/semantic-telemetry`, { - headers: { "X-Game-Id": "f1-2025" }, - }); - - expect(response.status).toBe(200); - const body = await response.json() as { - lapId: number; - requestedSemanticIds: readonly string[]; - envelopes: { sequence: number; values: { semanticId: string }[] }[]; - parseError: string | null; - }; - expect(body.lapId).toBe(lapId); - expect(body.parseError).toBeNull(); - expect(body.requestedSemanticIds).toEqual(analyseSemanticIds(getGame("f1-2025"))); - expect(body.envelopes).toHaveLength(2); - expect(body.envelopes.map((envelope) => envelope.sequence)).toEqual([0, 1]); - expect(body.envelopes[0]?.values).toEqual( - expect.arrayContaining([expect.objectContaining({ semanticId: "motion.speed", value: 10 })]), - ); - expect(body.envelopes[1]?.values).toEqual( - expect.arrayContaining([expect.objectContaining({ semanticId: "motion.speed", value: 20 })]), - ); - } finally { - cacheDelete(lapId); - await deleteSession(sessionId); - } - }); -}); describe("GET /api/laps/review", () => { test("returns only top valid metadata laps for a track/car", async () => { diff --git a/bun.lock b/bun.lock index be02fddca..b8cf41fad 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,7 @@ "@raceiq/game-iracing-metadata": "workspace:*", "@raceiq/game-lmu": "workspace:*", "@raceiq/game-lmu-metadata": "workspace:*", + "@raceiq/lap-replay": "workspace:*", "@raceiq/shared": "workspace:*", "@raceiq/telemetry-core": "workspace:*", "@raceiq/tooling-catalog": "workspace:*", @@ -127,6 +128,7 @@ "@raceiq/frontend-pure": "workspace:*", "@raceiq/game-catalogs": "workspace:*", "@raceiq/game-lmu-metadata": "workspace:*", + "@raceiq/lap-replay": "workspace:*", "@raceiq/scene-3d": "workspace:*", "@raceiq/telemetry-core": "workspace:*", "@raceiq/tooling-data": "workspace:*", @@ -381,6 +383,32 @@ "@raceiq/shared": "workspace:*", }, }, + "packages/lap-replay": { + "name": "@raceiq/lap-replay", + "dependencies": { + "@base-ui/react": "^1.8.0", + "@hono/zod-validator": "0.9.1", + "@raceiq/analysis-core": "workspace:*", + "@raceiq/backend-core": "workspace:*", + "@raceiq/scene-3d": "workspace:*", + "@raceiq/shared": "workspace:*", + "@raceiq/telemetry-core": "workspace:*", + "@raceiq/tracks-catalogue": "workspace:*", + "hono": "4.13.8", + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "three": "^0.185.1", + "zod": "^4.6.5", + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "@raceiq/game-catalogs": "workspace:*", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@types/three": "^0.185.4", + }, + }, "packages/scene-3d": { "name": "@raceiq/scene-3d", "dependencies": { @@ -1290,6 +1318,8 @@ "@raceiq/game-lmu-metadata": ["@raceiq/game-lmu-metadata@workspace:packages/game-lmu-metadata"], + "@raceiq/lap-replay": ["@raceiq/lap-replay@workspace:packages/lap-replay"], + "@raceiq/scene-3d": ["@raceiq/scene-3d@workspace:packages/scene-3d"], "@raceiq/shared": ["@raceiq/shared@workspace:shared"], diff --git a/client/package.json b/client/package.json index 1f19b3e24..c607faef4 100644 --- a/client/package.json +++ b/client/package.json @@ -66,6 +66,7 @@ "@raceiq/telemetry-core": "workspace:*", "@raceiq/frontend-pure": "workspace:*", "@raceiq/tracks-catalogue": "workspace:*", + "@raceiq/lap-replay": "workspace:*", "@raceiq/cars-catalogue": "workspace:*", "@raceiq/scene-3d": "workspace:*" }, diff --git a/client/src/components/InsightPanel.tsx b/client/src/components/InsightPanel.tsx deleted file mode 100644 index a8f708488..000000000 --- a/client/src/components/InsightPanel.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { InsightCategory, LapDetectorCoverage, LapInsight } from "@raceiq/analysis-core/racing/analysis/laps/insights/types" -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { useState } from "react"; -import { m } from "@/paraglide/messages"; -import { Button } from "./ui/button"; - -const SEVERITY_COLOR: Record = { - info: "var(--app-text-dim)", - warning: "var(--status-warning)", - critical: "var(--status-danger)", -}; -const SEVERITY_RANK: Record = { critical: 3, warning: 2, info: 1 }; - -function InsightRow({ insight, onJump }: { insight: LapInsight; onJump: (idx: number) => void }) { - const [eventIdx, setEventIdx] = useState(0); - const hasMultiple = insight.frameIndices.length > 1; - - return ( -
- - {hasMultiple && ( -
- - - {eventIdx + 1}/{insight.frameIndices.length} - - -
- )} -
- ); -} - -export function InsightPanel({ insights, detectorCoverage, onJumpToFrame }: { insights: LapInsight[]; detectorCoverage: LapDetectorCoverage[]; onJumpToFrame: (frameIdx: number) => void }) { - const categories: { key: InsightCategory; icon: string; label: string }[] = [ - { key: "suspension", icon: "🔧", label: m.insight_category_suspension() }, - { key: "tires", icon: "🛞", label: m.label_tires() }, - { key: "driving", icon: "🏎️", label: m.insight_category_driving() }, - { key: "mechanical", icon: "⚙️", label: m.insight_category_mechanical() }, - ]; - return ( -
- {categories.map(({ key, icon, label }) => { - const items = insights.filter((i) => i.category === key) - .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]); - const checks = detectorCoverage.filter((check) => check.category === key); - return ( -
-
- {icon} -

{label}

- {items.length > 0 && {items.length}} -
- {items.length === 0 && ( -
{m.insight_no_findings()}
- )} - {items.length > 0 && ( -
- {items.map((insight) => ( - - ))} -
- )} - {checks.length > 0 && ( -
- {m.insight_checks()} ({checks.length}) -
    - {checks.map((check) => ( -
  • - {check.label} - {" — "} - {check.status === "finding" ? m.insight_check_finding() : check.status === "checked" ? m.insight_check_clear() : m.analyse_unavailable()} - {check.reason && ({check.reason})} -
  • - ))} -
-
- )} -
- ); - })} -
- ); -} diff --git a/client/src/components/analyse/AnalyseAiSidebar.tsx b/client/src/components/analyse/AnalyseAiSidebar.tsx index fff4b95c0..d3f7ae148 100644 --- a/client/src/components/analyse/AnalyseAiSidebar.tsx +++ b/client/src/components/analyse/AnalyseAiSidebar.tsx @@ -17,7 +17,7 @@ interface AnalyseAiSidebarProps { export function AnalyseAiSidebar({ lapId, carName, trackName, segments, aiPanelRef, onJumpToFrac, onHighlightsChange }: AnalyseAiSidebarProps) { return ( -
+
diff --git a/client/src/components/analyse/AnalyseDataPanel.tsx b/client/src/components/analyse/AnalyseDataPanel.tsx index d9b18c924..0a71e65d8 100644 --- a/client/src/components/analyse/AnalyseDataPanel.tsx +++ b/client/src/components/analyse/AnalyseDataPanel.tsx @@ -1,15 +1,12 @@ import { getGame } from "@raceiq/shared/games/registry" -import type { LapDetectorCoverage, LapInsight } from "@raceiq/analysis-core/racing/analysis/laps/insights/types" import type { GameId } from "@raceiq/shared/games/ids"; import { Check, Copy, Info } from "lucide-react"; import { useCallback, useState } from "react"; import type { useUnits } from "../../hooks/useUnits"; import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; import { m } from "../../paraglide/messages"; -import { InsightPanel } from "../InsightPanel"; import { Button } from "../ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { AnalyseDynamicsPanel } from "./AnalyseDynamicsPanel"; import { AnalyseF1ErsPanel } from "./AnalyseF1ErsPanel"; import { MetricsPanel } from "./AnalyseMetricsPanel"; @@ -25,17 +22,12 @@ interface WearRate { RR: number; } interface Props { - sidebarTab: "live" | "insights"; - onSidebarTabChange: (tab: "live" | "insights") => void; currentFrame: SemanticAnalysisFrame | null; packetNumber: number; startFuel: number | undefined; gameId: GameId; units: ReturnType; wearRate: WearRate | null; - lapInsights: LapInsight[]; - detectorCoverage: LapDetectorCoverage[]; - onJumpToFrame: (idx: number) => void; } function UnavailableFeaturesTooltip({ frame, gameId }: { frame: SemanticAnalysisFrame; gameId: GameId }) { const [open, setOpen] = useState(false); @@ -102,7 +94,7 @@ export function buildAnalyseClipboardJson({ frame, packetNumber }: { frame: Sema }, null, 2); } -export function AnalyseDataPanel({ sidebarTab, onSidebarTabChange, currentFrame, packetNumber, startFuel, gameId, units, wearRate, lapInsights, detectorCoverage, onJumpToFrame }: Props) { +export function AnalyseDataContent({ currentFrame, packetNumber, startFuel, gameId, units, wearRate }: Props) { const [copied, setCopied] = useState(false); const handleCopyValues = useCallback(() => { if (!currentFrame) return; @@ -111,23 +103,7 @@ export function AnalyseDataPanel({ sidebarTab, onSidebarTabChange, currentFrame, setTimeout(() => setCopied(false), 1500); }, [currentFrame, packetNumber]); return ( - { - if (value === "live" || value === "insights") onSidebarTabChange(value); - }} - className="flex w-full shrink-0 flex-col border-t border-app-border bg-app-bg @5xl/workspace:h-full @5xl/workspace:w-[clamp(18rem,30cqw,22rem)] @5xl/workspace:border-t-0 @5xl/workspace:border-l" - > - - - {m.analyse_tab_data()} - - - {m.analyse_tab_insights()} - {lapInsights.length > 0 && {lapInsights.length}} - - - + <>

{m.analyse_metrics_at_cursor()} @@ -155,10 +131,6 @@ export function AnalyseDataPanel({ sidebarTab, onSidebarTabChange, currentFrame, )}

-
- - - -
+ ); } diff --git a/client/src/components/analyse/AnalyseLapHeader.tsx b/client/src/components/analyse/AnalyseLapHeader.tsx index 0b0974215..ca6d5348c 100644 --- a/client/src/components/analyse/AnalyseLapHeader.tsx +++ b/client/src/components/analyse/AnalyseLapHeader.tsx @@ -48,7 +48,6 @@ interface Props { tunePending: boolean; // UI state loading: boolean; - aiPanelOpen: boolean; // Callbacks onTrackChange: (v: string | null) => void; onCarChange: (v: string | null) => void; @@ -63,7 +62,6 @@ interface Props { importingBin: boolean; ownership: SessionOwnership; onOwnershipChange: (value: SessionOwnership) => void; - onToggleAi: () => void; onDeleteLap: () => void; onNotesChange: (notes: string) => void; } @@ -87,7 +85,6 @@ export const AnalyseLapHeader = memo(function AnalyseLapHeader({ availableTunes, tunePending, loading, - aiPanelOpen, onTrackChange, onCarChange, onLapChange, @@ -101,7 +98,6 @@ export const AnalyseLapHeader = memo(function AnalyseLapHeader({ importingBin, ownership, onOwnershipChange, - onToggleAi, onDeleteLap, onNotesChange, }: Props) { @@ -310,12 +306,6 @@ export const AnalyseLapHeader = memo(function AnalyseLapHeader({ : []), ]} /> - {hasTelemetry && ( - - )} {loading && {m.common_loading()}}
diff --git a/client/src/components/analyse/AnalyseResizeHandle.tsx b/client/src/components/analyse/AnalyseResizeHandle.tsx deleted file mode 100644 index b7eac3f10..000000000 --- a/client/src/components/analyse/AnalyseResizeHandle.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react"; -import { useCallback } from "react"; -export function AnalyseResizeHandle({ topHeight, onHeightChange }: { topHeight: number; onHeightChange: (height: number | ((height: number) => number)) => void }) { - const handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === "ArrowUp") { - event.preventDefault(); - onHeightChange((height) => Math.max(250, height - 10)); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - onHeightChange((height) => Math.min(800, height + 10)); - } - }, - [onHeightChange], - ); - const handleMouseDown = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - const startY = event.clientY; - const startH = topHeight; - const onMove = (moveEvent: MouseEvent) => onHeightChange(Math.max(250, Math.min(800, startH + moveEvent.clientY - startY))); - const onUp = () => { - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - }; - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - }, - [topHeight, onHeightChange], - ); - return ( -
- ); -} diff --git a/client/src/components/analyse/AnalyseTelemetryChart.tsx b/client/src/components/analyse/AnalyseTelemetryChart.tsx index b96f19185..990dfc986 100644 --- a/client/src/components/analyse/AnalyseTelemetryChart.tsx +++ b/client/src/components/analyse/AnalyseTelemetryChart.tsx @@ -2,12 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { syncCanvasSize } from "../../lib/rendering/canvas-size"; import { getSemanticCanvasContext } from "@raceiq/scene-3d/rendering/css-canvas"; -const TELEMETRY_GAP_SECONDS = 0.1; -const TELEMETRY_GAP_TOLERANCE_SECONDS = 0.001; - -export function hasTelemetryGap(previousTime: number, currentTime: number): boolean { - return currentTime - previousTime > TELEMETRY_GAP_SECONDS + TELEMETRY_GAP_TOLERANCE_SECONDS; -} +import { hasTelemetryGap } from "@raceiq/lap-replay/ui"; export interface ChartSeries { data: number[]; diff --git a/client/src/components/analyse/AnalyseTimelineScrubber.tsx b/client/src/components/analyse/AnalyseTimelineScrubber.tsx index d4c947038..4c22d5da6 100644 --- a/client/src/components/analyse/AnalyseTimelineScrubber.tsx +++ b/client/src/components/analyse/AnalyseTimelineScrubber.tsx @@ -3,7 +3,7 @@ import { SECTOR_COLOR_VARS } from "@raceiq/scene-3d/colors"; import { formatLapTime } from "@/lib/format"; import { Button } from "../ui/button"; import { type SemanticAnalysisFrame, semanticNumber } from "@raceiq/scene-3d/semantic-frame"; -import { hasTelemetryGap } from "./AnalyseTelemetryChart"; +import { hasTelemetryGap } from "@raceiq/lap-replay/ui" import { m } from "../../paraglide/messages"; const currentLap = (frame: SemanticAnalysisFrame): number => semanticNumber(frame, "timing.current-lap") ?? 0; diff --git a/client/src/components/analyse/AnalyseTopSection.tsx b/client/src/components/analyse/AnalyseTopSection.tsx deleted file mode 100644 index b266f36bf..000000000 --- a/client/src/components/analyse/AnalyseTopSection.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import type { SceneRuntime, SceneSource } from "@raceiq/scene-3d/wireframe/SceneRuntime" -import type { GameId } from "@raceiq/shared/games/ids"; -import { type CSSProperties, type RefObject, useEffect, useRef } from "react"; -import type { AnalysisHighlight } from "@/components/ai/analysis-types"; -import type { PitLine } from "@/lib/canvas/draw-track"; -import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; -import type { Point, SectorBoundaries, TrackMapBoundaries, TrackMapHandle, TrackMapLabel, TrackOverlayKey, TrackOverlays, TrackZoomBehavior } from "./track-map/types"; -import type { useUnits } from "../../hooks/useUnits"; -import { m } from "../../paraglide/messages"; -import { AnalyseSegmentList } from "./AnalyseSegmentList"; -import { AnalyseTrackPanel } from "./AnalyseTrackPanel"; -import { AnalyseVizPanel } from "./AnalyseVizPanel"; - -interface AnalyseTopSectionProps { - lmuCarClass?: string; - gameId?: GameId; - // Layout - topHeight: number; - leftColWidth: number; - rightColWidth: number; - onLeftResize: (width: number) => void; - onRightResize: (width: number) => void; - - // Data - semanticFrames: SemanticAnalysisFrame[]; - cursorIdx: number; - outline: Point[] | null; - closed?: boolean; - mapLabels?: TrackMapLabel[] | null; - pitLines?: PitLine[] | null; - boundaries: TrackMapBoundaries | null; - sectors: SectorBoundaries | null; - segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; - lapLine: Point[] | null; - units: ReturnType; - - // AI highlights - aiPanelOpen: boolean; - aiHighlights: AnalysisHighlight[] | null; - - // View toggles - rotateWithCar: boolean; - trackOverlays: TrackOverlays; - mapZoom: number; - zoomBehavior: TrackZoomBehavior; - onRotateWithCarToggle: () => void; - onTrackOverlayChange: (overlay: TrackOverlayKey, checked: boolean) => void; - onMapZoomChange: (updater: (z: number) => number) => void; - onZoomBehaviorChange: () => void; - - // Viz - vizMode: "2d" | "3d"; - onVizModeChange: (mode: "2d" | "3d") => void; - - // Refs - trackMapRef: RefObject; - cursorRef: RefObject; - displayTelemetryRef: RefObject; - sceneSource: SceneSource; - onRuntime: (runtime: SceneRuntime | null) => void; -} - -export function AnalyseTopSection({ - lmuCarClass, - gameId, - topHeight, - leftColWidth, - rightColWidth, - onLeftResize, - onRightResize, - semanticFrames, - cursorIdx, - outline, - closed = true, - mapLabels, - pitLines, - boundaries, - sectors, - segments, - lapLine, - units, - aiPanelOpen, - aiHighlights, - rotateWithCar, - trackOverlays, - mapZoom, - zoomBehavior, - onRotateWithCarToggle, - onTrackOverlayChange, - onMapZoomChange, - onZoomBehaviorChange, - vizMode, - onVizModeChange, - trackMapRef, - cursorRef, - displayTelemetryRef, - sceneSource, - onRuntime, -}: AnalyseTopSectionProps) { - const resizeCleanupRef = useRef<(() => void) | null>(null); - - useEffect( - () => () => { - resizeCleanupRef.current?.(); - resizeCleanupRef.current = null; - }, - [], - ); - const responsiveSizeVars = { - "--analyse-top-height": `min(${topHeight}px, max(15.625rem, calc(100dvh - 18rem)))`, - "--analyse-left-width": `clamp(3.75rem, ${leftColWidth}px, min(12rem, calc(100cqw - 29.25rem)))`, - "--analyse-right-width": `clamp(12.5rem, ${rightColWidth}px, calc(100cqw - var(--analyse-left-width) - 16.75rem))`, - } as CSSProperties; - - return ( -
- {/* Segment table + legend */} -
- {/* Legend */} -
-
-
- {m.label_corner()} -
-
-
- {m.analyse_straight()} -
-
- {/* Segment list */} - -
- - {/* Left resize handle */} -
- ); -} diff --git a/client/src/components/analyse/AnalyseTrackPanel.tsx b/client/src/components/analyse/AnalyseTrackPanel.tsx index eac7f16aa..1014e2f8b 100644 --- a/client/src/components/analyse/AnalyseTrackPanel.tsx +++ b/client/src/components/analyse/AnalyseTrackPanel.tsx @@ -59,10 +59,7 @@ interface AnalyseTrackPanelProps { } /** - * AnalyseTrackPanel — the track map + overlays block (map, weather widget, - * rotate/overlay toggles, zoom, compass, steering/pedal overlay). Extracted - * from AnalyseTopSection's middle column so Analyse and the Setup Engineer - * live dashboard render the identical panel instead of each re-assembling it. + * Shared track map, overlays, and controls for recorded replay and live telemetry. * Always renders — no live packet just means the compass/steering overlay * and weather widget stay hidden, not the whole panel. */ diff --git a/client/src/components/analyse/AnalyseWorkspacePanels.tsx b/client/src/components/analyse/AnalyseWorkspacePanels.tsx index bf5c26a5e..570a08d1c 100644 --- a/client/src/components/analyse/AnalyseWorkspacePanels.tsx +++ b/client/src/components/analyse/AnalyseWorkspacePanels.tsx @@ -1,59 +1,76 @@ -import type { ComponentProps, RefObject } from "react"; +import { useMemo, useState, type ComponentProps, type RefObject } from "react"; +import { semanticNumber } from "@raceiq/scene-3d/semantic-frame"; +import { m } from "@/paraglide/messages"; +import { SingleReplayWorkspace } from "@raceiq/lap-replay/ui"; +import { CarWireframe } from "../CarWireframe"; +import { TireDiagram } from "../telemetry/TireDiagram"; import { AnalyseAiSidebar } from "./AnalyseAiSidebar"; import { AnalyseChartsPanel, type ChartsPanelHandle } from "./AnalyseChartsPanel"; -import { AnalyseDataPanel } from "./AnalyseDataPanel"; -import { AnalyseResizeHandle } from "./AnalyseResizeHandle"; +import { AnalyseDataContent } from "./AnalyseDataPanel"; import { AnalyseTimelineScrubber } from "./AnalyseTimelineScrubber"; -import { AnalyseTopSection } from "./AnalyseTopSection"; - -type TopSectionProps = ComponentProps; -type ResizeHandleProps = ComponentProps; -type TimelineScrubberProps = ComponentProps; -type ChartsPanelProps = ComponentProps; -type DataPanelProps = ComponentProps; -type AiSidebarProps = ComponentProps; +import { AnalyseTrackPanel } from "./AnalyseTrackPanel"; +type TrackPanelProps = ComponentProps; +type SceneProps = Pick, "lmuCarClass" | "source" | "onRuntime">; interface AnalyseWorkspacePanelsProps { - topSectionProps: TopSectionProps; - resizeHandleProps: ResizeHandleProps; - timelineScrubberProps: TimelineScrubberProps; - chartsPanelProps: ChartsPanelProps; + trackPanelProps: TrackPanelProps; + sceneProps: SceneProps; + timelineScrubberProps: ComponentProps; + chartsPanelProps: ComponentProps; chartsPanelRef: RefObject; - dataPanelProps: DataPanelProps; - aiSidebarProps: AiSidebarProps | null; + dataPanelProps: ComponentProps; + insightsProps: ComponentProps["insightsProps"]; + aiSidebarProps: ComponentProps | null; + onAiOpenChange: (open: boolean) => void; } export function AnalyseWorkspacePanels({ - topSectionProps, - resizeHandleProps, + trackPanelProps, + sceneProps, timelineScrubberProps, chartsPanelProps, chartsPanelRef, dataPanelProps, + insightsProps, aiSidebarProps, + onAiOpenChange, }: AnalyseWorkspacePanelsProps) { + const [mapOpen, setMapOpen] = useState(true); + const frames = chartsPanelProps.semanticFrames; + const lapLine = useMemo(() => { + const points: { x: number; z: number }[] = []; + for (const frame of frames) { + const x = semanticNumber(frame, "motion.position-x"); + const z = semanticNumber(frame, "motion.position-z"); + if (x != null && z != null) points.push({ x, z }); + } + return points.length > 2 ? points : null; + }, [frames]); return ( -
- {/* Left: main content (map, charts, scrubber) */} -
- {/* Top section: Track Map + Metrics */} - - - -
- {/* Lap time + Timeline scrubber */} - - - {/* Stacked charts — with own scroll */} - {chartsPanelProps.semanticFrames.length > 0 && } + } + carLayout={
{dataPanelProps.currentFrame && }
} + data={
} + insightsProps={insightsProps} + ai={aiSidebarProps && } + aiOpen={aiSidebarProps != null} + onAiOpenChange={onAiOpenChange} + scene={ +
+
+
+ + {mapOpen &&
+ +
}
- - {/* Right panel – full height */} - - - {/* AI panel — analysis + chat */} - {aiSidebarProps && } -
+ } + playback={} + /> ); } diff --git a/client/src/components/analyse/LapAnalyse.tsx b/client/src/components/analyse/LapAnalyse.tsx index ac19712ca..e8e37bf6c 100644 --- a/client/src/components/analyse/LapAnalyse.tsx +++ b/client/src/components/analyse/LapAnalyse.tsx @@ -21,7 +21,7 @@ import { AnalyseWorkspacePanels } from "./AnalyseWorkspacePanels"; import { AnalyseWorkspaceStatus } from "./AnalyseWorkspaceStatus"; import { flushSync } from "react-dom"; import { semanticNumber } from "@raceiq/scene-3d/semantic-frame"; -import type { Point, TrackMapHandle, TrackOverlayKey, TrackZoomBehavior } from "./track-map/types"; +import type { TrackMapHandle, TrackOverlayKey, TrackZoomBehavior } from "./track-map/types"; import type { SceneRuntime, SceneSource } from "@raceiq/scene-3d/wireframe/SceneRuntime" import { useAnalyseImports } from "./useAnalyseImports"; import { useAnalyseSelections } from "./useAnalyseSelections"; @@ -68,14 +68,6 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init setRotateWithCar, trackOverlays, setTrackOverlays, - vizMode, - setWheelTab, - leftColWidth, - setLeftColWidth, - rightColWidth, - setRightColWidth, - topHeight, - setTopHeight, trackNames, carNames, tracks, @@ -139,7 +131,6 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init const loading = lapLoading; const [cursorIdx, setCursorIdx] = useState(0); const [visualTimeFrac, setVisualTimeFrac] = useState(null); - const [sidebarTab, setSidebarTab] = useState<"live" | "insights">("live"); const [playing, setPlaying] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [aiPanelOpen, setAiPanelOpen] = useCookieState("analyse-aiPanel", false); @@ -154,16 +145,6 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init const [setup, setSetup] = useState(null); const aiPanelRef = useRef(null); const [viewingTuneId, setViewingTuneId] = useState(null); - const lapLine = useMemo(() => { - if (semanticFrames.length < 2) return null; - const pts: Point[] = []; - for (const p of semanticFrames) { - const x = semanticNumber(p, "motion.position-x"); - const z = semanticNumber(p, "motion.position-z"); - if (x != null || z != null) pts.push({ x: x ?? 0, z: z ?? 0 }); - } - return pts.length > 2 ? pts : null; - }, [semanticFrames]); const playRef = useRef(false); const speedRef = useRef(1); const displayTelemetryRef = useRef(semanticFrames); @@ -262,7 +243,7 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init const generation = publishSeek(); flushSync(() => setCursorIdx(idx)); const runtime = runtimeRef.current; - if (runtime && vizMode === "3d") runtime.requestFrame(generation).then(resolve, reject); + if (runtime) runtime.requestFrame(generation).then(resolve, reject); else resolve(); }); }; @@ -274,7 +255,7 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init w.__totalFrames = undefined; w.__frameTimes = undefined; }; - }, [semanticFrames.length, publishSeek, vizMode]); + }, [semanticFrames.length, publishSeek]); // Playback animation + keyboard controls const { updateOverlays } = useLapPlayback({ @@ -417,7 +398,6 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init }, [selectedLapId, filteredLaps, deleteLapMutation.mutate]); const handleTuneChange = useCallback((tuneId: number | null) => updateLapTune.mutate(tuneId), [updateLapTune.mutate]); const handleNotesChange = useCallback((notes: string) => updateLapNotesMutation.mutate(notes), [updateLapNotesMutation.mutate]); - const handleToggleAi = useCallback(() => setAiPanelOpen((open) => !open), [setAiPanelOpen]); const handleRotateWithCarToggle = useCallback(() => setRotateWithCar((rotate) => !rotate), [setRotateWithCar]); const handleTrackOverlayChange = useCallback( (overlay: TrackOverlayKey, checked: boolean) => { @@ -485,7 +465,6 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init availableTunes={availableTunes} tunePending={updateLapTune.isPending} loading={loading} - aiPanelOpen={aiPanelOpen} onTrackChange={handleTrackChange} onCarChange={handleCarChange} onLapChange={setSelectedLapId} @@ -497,7 +476,6 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init importingBin={importingBin} ownership={ownership} onOwnershipChange={setOwnership} - onToggleAi={handleToggleAi} onDeleteLap={handleDeleteLap} onNotesChange={handleNotesChange} /> @@ -506,27 +484,19 @@ function LapAnalyseInner({ sessionId, initialLapId }: { sessionId?: number; init {semanticFrames.length > 0 && ( ("analyse-trackOverlays", DEFAULT_TRACK_OVERLAYS); - const [vizMode, setWheelTab] = useCookieState<"2d" | "3d">("analyse-vizMode", "2d"); - const appliedVizParam = useRef(false); - useEffect(() => { - if (!appliedVizParam.current && (search.viz === "3d" || search.viz === "2d")) { - setWheelTab(search.viz); - appliedVizParam.current = true; - } - }, [search.viz]); - const [leftColWidth, setLeftColWidth] = useCookieState("analyse-leftCol", 150); - const [rightColWidth, setRightColWidth] = useCookieState("analyse-rightCol", 650); - const [topHeight, setTopHeight] = useCookieState("analyse-topHeight", 500); useEffect(() => { setLaps(allLaps.filter((l) => l.lapTime > 0 && (sessionId == null || l.sessionId === sessionId))); }, [allLaps, sessionId]); @@ -253,14 +241,6 @@ export function useAnalyseSelections(search: AnalyseSearch, gameId: Parameters - {CATEGORY_LABELS[item]()} + {m[CATEGORY_LABELS[item]]()} )}
diff --git a/client/src/components/comparison/LapComparison.tsx b/client/src/components/comparison/LapComparison.tsx index 3df9d6f8e..0ea282523 100644 --- a/client/src/components/comparison/LapComparison.tsx +++ b/client/src/components/comparison/LapComparison.tsx @@ -22,7 +22,7 @@ import { CompareAiSidebar } from "./CompareAiSidebar"; import { CompareTrackMap, type SegmentTiming } from "./CompareTrackMap"; import { ComparisonCharts } from "./ComparisonCharts"; import { ComparisonSelectors } from "./ComparisonSelectors"; -import { SpectateSession } from "./spectate/SpectateSession"; +import { SpectateSession } from "@raceiq/lap-replay/ui" import { CompareOverview } from "./CompareOverview"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { CompareOverviewPicker } from "./CompareOverviewPicker"; diff --git a/client/src/components/comparison/spectate/SpectateCarPanel.tsx b/client/src/components/comparison/spectate/SpectateCarPanel.tsx deleted file mode 100644 index 89ba8872d..000000000 --- a/client/src/components/comparison/spectate/SpectateCarPanel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useSyncExternalStore } from "react"; -import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; -import { m } from "@/paraglide/messages"; -import { TireDiagram } from "@/components/telemetry/TireDiagram"; -import type { SpectateController, SpectatePair } from "./types"; - -const EMPTY_METADATA = {}; - -export function SpectateCarPanel({ pair, controller }: { pair: SpectatePair; controller: SpectateController }) { - const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); - return ( -
- {([ - { side: "A", reading: snapshot.a, lap: pair.comparison.lapA, color: "var(--comparison-lap-a)" }, - { side: "B", reading: snapshot.b, lap: pair.comparison.lapB, color: "var(--comparison-lap-b)" }, - ] as const).map(({ side, reading, lap, color }) => { - const frame: SemanticAnalysisFrame | null = reading ? { values: reading.values, states: EMPTY_METADATA, freshness: EMPTY_METADATA } : null; - return ( -
-

- {side} — {m.compare_lap_label()} {lap.lapNumber} -

-
- {frame ? :

{m.compare_telemetry_unavailable()}

} -
-
- ); - })} -
- ); -} diff --git a/client/src/components/comparison/spectate/SpectateInputs.tsx b/client/src/components/comparison/spectate/SpectateInputs.tsx deleted file mode 100644 index e8155d57c..000000000 --- a/client/src/components/comparison/spectate/SpectateInputs.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { m } from "@/paraglide/messages"; -import { useMemo, useSyncExternalStore } from "react"; -import { getTelemetryVariable } from "@raceiq/shared/telemetry/catalog/query"; -import { controlInputPercent } from "@raceiq/scene-3d/vehicle-dynamics"; -import { useUnits } from "@/hooks/useUnits"; -import type { SpectateController, SpectatePair, SpectateReading } from "./types"; -import { channelValue, type SpectateChannel } from "./spectate-channels"; -import { getGame } from "@raceiq/shared/games/registry"; -import { resolveAnalysisTelemetry } from "@raceiq/shared/racing/analysis/telemetry-capabilities"; -import { resolveBalance } from "@raceiq/analysis-core/racing/analysis/metric-values"; -import { balanceColor } from "@raceiq/scene-3d/vehicle-dynamics"; -import { ERS_DEPLOY_MODES, readERSDeployMode } from "./spectate-ers-mode"; - -function number(reading: SpectateReading | null, key: string): number | null { - const value = reading?.values[key]; - return typeof value === "number" && Number.isFinite(value) ? value : null; -} -function display(value: number | null, digits = 0) { - return value === null ? "—" : value.toFixed(digits); -} -function Pedal({ label, keyName, a, b }: { label: string; keyName: string; a: SpectateReading | null; b: SpectateReading | null }) { - const av = number(a, keyName), - bv = number(b, keyName); - const percent = (value: number | null) => (value === null ? null : controlInputPercent(value)); - return ( -
-
{label}
-
- {[ - { side: "A", value: percent(av), color: "var(--comparison-lap-a)" }, - { side: "B", value: percent(bv), color: "var(--comparison-lap-b)" }, - ].map(({ side, value, color }) => ( -
- {side} -
- -
- - {display(value)} - {value === null ? "" : "%"} - -
- ))} -
-
- ); -} -function BatteryModeRail({ a, b }: { a: SpectateReading | null; b: SpectateReading | null }) { - const modeA = readERSDeployMode(a); - const modeB = readERSDeployMode(b); - return ( -
- {m.f1live_ers_label()} - {[{ side: "A", mode: modeA, color: "var(--comparison-lap-a)" }, { side: "B", mode: modeB, color: "var(--comparison-lap-b)" }].map(({ side, mode, color }) => ( - - {side} - {mode === null ? m.analyse_unavailable() : ERS_DEPLOY_MODES[mode].label()} - - ))} -
- ); -} - -export function SpectateInputs({ pair, controller }: { pair: SpectatePair; controller: SpectateController }) { - const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); - const isF1 = pair.gameId === "f1-2025"; - const units = useUnits(pair.gameId); - const a = snapshot.a, - b = snapshot.b; - const balanceMetric = useMemo(() => resolveAnalysisTelemetry(getGame(pair.gameId)).balance, [pair.gameId]); - const balanceA = snapshot.a ? resolveBalance(snapshot.a, balanceMetric) : null; - const balanceB = snapshot.b ? resolveBalance(snapshot.b, balanceMetric) : null; - const deltaChannel = useMemo(() => ({ - id: "comparison.time-delta", - label: "Time delta", - unit: "s", - components: [], - derived: "delta", - delta: { distance: pair.comparison.traces.distance, values: pair.comparison.timeDelta }, - }), [pair]); - const rawDelta = channelValue(a, deltaChannel, ""); - const delta = rawDelta === null ? null : Math.round(rawDelta * 1000) / 1000; - const previousDelta = snapshot.time < 1 ? null : channelValue(controller.sample(snapshot.time - 1).a, deltaChannel, ""); - // Delta is A − B, so a falling delta means A is gaining on B. - const gain = rawDelta === null || previousDelta === null ? null : Math.round((previousDelta - rawDelta) * 1000) / 1000; - const trendValue = Math.max(-1, Math.min(1, gain ?? 0)); - const trendText = gain === null - ? m.spectate_delta_trend_unavailable() - : gain === 0 - ? m.spectate_delta_trend_steady() - : gain > 0 - ? m.spectate_delta_trend_gaining({ seconds: gain.toFixed(3) }) - : m.spectate_delta_trend_losing({ seconds: Math.abs(gain).toFixed(3) }); - const rawSpeedA = number(a, "motion.speed"), - rawSpeedB = number(b, "motion.speed"); - const valueA = rawSpeedA === null ? null : units.speed(rawSpeedA); - const valueB = rawSpeedB === null ? null : units.speed(rawSpeedB); - const unitLabel = units.speedLabel; - const speedScale = useMemo(() => { - let maximum = 1; - for (const lap of [pair.a, pair.b]) - for (const sample of lap.samples) { - const speed = sample.values["motion.speed"]; - if (typeof speed === "number" && Number.isFinite(speed)) maximum = Math.max(maximum, units.speed(speed)); - } - return maximum; - }, [pair, units]); - const steerA = number(a, "inputs.steer"), - steerB = number(b, "inputs.steer"); - const steeringUnit = getTelemetryVariable("inputs.steer").canonicalUnit ?? ""; - const steeringScale = useMemo(() => { - let maximum = 1; - for (const lap of [pair.a, pair.b]) - for (const sample of lap.samples) { - const steering = sample.values["inputs.steer"]; - if (typeof steering === "number" && Number.isFinite(steering)) maximum = Math.max(maximum, Math.abs(steering)); - } - return maximum; - }, [pair]); - const needle = (value: number | null, radius: number) => { - if (value === null) return null; - const angle = Math.PI - Math.PI * Math.min(1, Math.max(0, value / speedScale)); - return ; - }; - const markerX = (value: number) => 16 + (Math.max(-1, Math.min(1, value / steeringScale)) + 1) * 64; - return ( -
-
- {m.spectate_time_delta()} · A − B - - {delta === null ? "—" : `${delta > 0 ? "+" : ""}${delta.toFixed(3)} s`} - -
-
-
0 ? "var(--status-success)" : "var(--status-danger)", - }} - /> -
-
- {m.spectate_delta_trend_window()} -
-
-
-
- {m.spectate_speed()} · {unitLabel} -
- - - {needle(valueA, 39)} - {needle(valueB, 31)} - - -
- A {display(valueA)} - B {display(valueB)} -
-
-
-
- {m.spectate_steering()} - {steeringUnit && ` · ${steeringUnit}`} -
- - - {steerA !== null && } - {steerB !== null && } - -
- A {display(steerA, 2)} - B {display(steerB, 2)} -
-
-
-
{m.spectate_gear()}
-
- A {display(number(a, "inputs.gear"))} - B {display(number(b, "inputs.gear"))} -
-
- - -
- {isF1 && } -
- {m.spectate_balance()} -
- {[ - { side: "A", balance: balanceA, color: "var(--comparison-lap-a)" }, - { side: "B", balance: balanceB, color: "var(--comparison-lap-b)" }, - ].map(({ side, balance, color }) => ( -
- {side} - - {balance - ? balance.state === "neutral" ? m.dynamics_state_neutral() : balance.state === "understeer" ? m.dynamics_state_understeer() : m.dynamics_state_oversteer() - : "—"} - -
- ))} -
-
-
-
- ); -} diff --git a/client/src/components/comparison/spectate/SpectateLineScore.tsx b/client/src/components/comparison/spectate/SpectateLineScore.tsx deleted file mode 100644 index a810fedcc..000000000 --- a/client/src/components/comparison/spectate/SpectateLineScore.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { severityRangeColor } from "@raceiq/scene-3d/colors"; -import type { LineSpreadTrace } from "@/hooks/experiments"; -import { m } from "@/paraglide/messages"; -import { useSyncExternalStore } from "react"; -import type { SpectateController, SpectatePair } from "./types"; -import { windowLineSpread } from "@/lib/spectate-line-spread"; - -export function SpectateLineScore({ trace, pair, controller }: { trace: LineSpreadTrace | null | undefined; pair: SpectatePair; controller: SpectateController }) { - const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); - const start = controller.sample(Math.max(0, snapshot.time - 1)).a?.distance; - const end = snapshot.a?.distance; - const spread = start == null || end == null || !(pair.a.distance > 0) || !pair.a.hasProgress || snapshot.time > pair.a.duration - ? null - : windowLineSpread(trace, start / pair.a.distance, end / pair.a.distance); - // Match the whole-lap score's five-metre full scale. - const score = spread === null ? null : Math.max(0, Math.min(100, Math.round(100 - spread * 20))); - const color = score === null ? undefined : severityRangeColor(100 - score, [20, 40]); - return ( -
-
{m.spectate_line_similarity()} · 1 s
- {score !== null && spread !== null ? ( - <> -
{score}%
-
{m.spectate_mean_spread({ spread: spread.toFixed(2) })}
- - ) : ( - <> -
—
-
{m.spectate_mean_spread({ spread: "—" })}
- - )} -
- ); -} diff --git a/client/src/components/comparison/spectate/SpectateOverview.tsx b/client/src/components/comparison/spectate/SpectateOverview.tsx deleted file mode 100644 index f821c1c93..000000000 --- a/client/src/components/comparison/spectate/SpectateOverview.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { m } from "@/paraglide/messages"; -import { useMemo, useSyncExternalStore } from "react"; -import type { SpectateController, SpectatePair, SpectateSegment } from "./types"; -import { focusRuns, type SpectateFocus } from "./spectate-focus"; - -type TrackPoint = { distance: number; run: number; x: number; z: number }; -function trackRuns(pair: SpectatePair): TrackPoint[][] { - const runs: TrackPoint[][] = []; - let current: TrackPoint[] = []; - let prior: TrackPoint | null = null; - for (const sample of pair.a.samples) { - const x = sample.values["motion.position-x"], - z = sample.values["motion.position-z"]; - if (!Number.isFinite(sample.distance) || !Number.isFinite(x) || !Number.isFinite(z)) { - if (current.length) runs.push(current); - current = []; - prior = null; - continue; - } - const point = { distance: sample.distance, run: sample.run, x: x as number, z: z as number }; - if (prior && (prior.run !== point.run || point.distance < prior.distance)) { - if (current.length) runs.push(current); - current = []; - } - current.push(point); - prior = point; - } - if (current.length) runs.push(current); - return runs; -} -function positionAtDistance(points: readonly TrackPoint[], target: number) { - if (!points.length || target < points[0].distance || target > points[points.length - 1].distance) return null; - let low = 0, - high = points.length; - while (low < high) { - const middle = (low + high) >>> 1; - if (points[middle].distance < target) low = middle + 1; - else high = middle; - } - const after = Math.min(points.length - 1, low); - if (points[after].distance === target || after === 0) return points[after]; - const before = points[after - 1], - next = points[after], - span = next.distance - before.distance; - if (span <= 0) return before; - const fraction = (target - before.distance) / span; - return { x: before.x + (next.x - before.x) * fraction, z: before.z + (next.z - before.z) * fraction }; -} -function positionAcrossRuns(runs: readonly TrackPoint[][], target: number) { - for (const run of runs) { - const position = positionAtDistance(run, target); - if (position) return position; - } - return null; -} -function segmentPaths(runs: readonly TrackPoint[][], start: number, end: number, maximum: number, project: (point: { x: number; z: number }) => string) { - const startDistance = start * maximum, - endDistance = end * maximum; - const intervals: [number, number][] = - end < start - ? [ - [startDistance, maximum], - [0, endDistance], - ] - : [[startDistance, endDistance]]; - return intervals.flatMap(([from, to]) => - runs.flatMap((run) => { - const positions: { x: number; z: number }[] = []; - const first = positionAtDistance(run, from), - last = positionAtDistance(run, to); - if (first) positions.push(first); - for (const sample of run) if (sample.distance > from && sample.distance < to) positions.push(sample); - if (last && (positions.length === 0 || positions[positions.length - 1] !== last)) positions.push(last); - return positions.length >= 2 ? [positions.map(project).join(" ")] : []; - }), - ); -} -/** Interpolate boundary times on the physical-distance grid, never sample indices. */ -function splitLookup(pair: SpectatePair) { - const { distance, elapsedTimeA, elapsedTimeB } = pair.comparison.traces; - const valid = distance.length > 1 && [elapsedTimeA, elapsedTimeB].every((times) => - times.length === distance.length && times.every((time, index) => - Number.isFinite(time) && Number.isFinite(distance[index]) && - (index === 0 || (time >= times[index - 1] && distance[index] > distance[index - 1])))); - const maximum = distance.at(-1) ?? 0; - const at = (times: number[], target: number): number | null => { - if (!valid || target < distance[0] || target > maximum) return null; - let low = 0, high = distance.length - 1; - while (low < high) { - const middle = (low + high) >>> 1; - if (distance[middle] < target) low = middle + 1; - else high = middle; - } - if (distance[low] === target) return times[low]; - const before = low - 1; - return times[before] + (times[low] - times[before]) * (target - distance[before]) / (distance[low] - distance[before]); - }; - return (segment: SpectateSegment) => { - const duration = (times: number[]) => { - const start = at(times, segment.startFrac * maximum), end = at(times, segment.endFrac * maximum); - if (start === null || end === null) return null; - if (segment.endFrac >= segment.startFrac) return end - start; - const first = at(times, 0), last = at(times, maximum); - return first === null || last === null ? null : last - start + end - first; - }; - const a = duration(elapsedTimeA), b = duration(elapsedTimeB); - return a === null || b === null ? null : Math.round((a - b) * 1000) / 1000; - }; -} -function splitColor(delta: number | null) { - return delta === null || delta === 0 ? "var(--app-text-secondary)" : delta < 0 ? "var(--status-success)" : "var(--status-danger)"; -} -function splitText(delta: number | null) { - return delta === null ? m.analyse_unavailable() : `${delta > 0 ? "+" : ""}${delta.toFixed(3)} s`; -} - -export function SpectateOverview({ pair, controller, segments, focus }: { pair: SpectatePair; controller: SpectateController; segments: readonly SpectateSegment[]; focus: SpectateFocus }) { - const coloredRuns = useMemo(() => [focusRuns(pair.a.samples, focus), focusRuns(pair.b.samples, focus)], [pair, focus]); - const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); - const splitAt = useMemo(() => splitLookup(pair), [pair]); - const rows = useMemo( - () => - segments - .filter((segment) => Number.isFinite(segment.startFrac) && Number.isFinite(segment.endFrac) && segment.startFrac >= 0 && segment.startFrac <= 1 && segment.endFrac >= 0 && segment.endFrac <= 1) - .map((segment, index) => ({ segment, number: index + 1, delta: splitAt(segment) })), - [segments, splitAt], - ); - const runs = useMemo(() => trackRuns(pair), [pair]); - const maximum = pair.comparison.traces.distance.at(-1) ?? pair.a.distance; - const geometry = useMemo(() => { - let minX = Infinity, - maxX = -Infinity, - minZ = Infinity, - maxZ = -Infinity; - let pointCount = 0; - for (const run of runs) - for (const point of run) { - minX = Math.min(minX, point.x); - maxX = Math.max(maxX, point.x); - minZ = Math.min(minZ, point.z); - maxZ = Math.max(maxZ, point.z); - pointCount++; - } - if (pointCount < 2 || !pair.a.hasProgress || !(maximum > 0)) return null; - const bounds = { minX, maxX, minZ, maxZ }; - // Leave room for numbered markers outside the track stroke. - const scale = Math.min(152 / (bounds.maxX - bounds.minX || 1), 60 / (bounds.maxZ - bounds.minZ || 1)); - const project = (point: { x: number; z: number }) => ({ - x: (200 - (bounds.maxX - bounds.minX) * scale) / 2 + (point.x - bounds.minX) * scale, - y: (108 - (bounds.maxZ - bounds.minZ) * scale) / 2 + (point.z - bounds.minZ) * scale, - }); - const projectString = (point: { x: number; z: number }) => { - const projected = project(point); - return `${projected.x},${projected.y}`; - }; - const projectedRuns = runs.map((run) => run.map(project)); - const placed: { x: number; y: number }[] = []; - const clear = (x: number, y: number) => { - if (x < 7 || x > 193 || y < 7 || y > 101) return false; - if (placed.some((other) => Math.hypot(x - other.x, y - other.y) < 13)) return false; - for (const run of projectedRuns) { - for (let i = 0; i < run.length; i++) { - const a = run[i], b = run[Math.min(i + 1, run.length - 1)]; - const dx = b.x - a.x, dy = b.y - a.y; - const lengthSquared = dx * dx + dy * dy; - const t = lengthSquared ? Math.max(0, Math.min(1, ((x - a.x) * dx + (y - a.y) * dy) / lengthSquared)) : 0; - if (Math.hypot(x - a.x - t * dx, y - a.y - t * dy) < 11) return false; - } - } - return true; - }; - const markers = rows.map(({ segment, number, delta }) => { - const endFraction = segment.endFrac < segment.startFrac ? segment.endFrac + 1 : segment.endFrac; - const fraction = ((segment.startFrac + endFraction) / 2) % 1; - const position = positionAcrossRuns(runs, fraction * maximum); - const anchor = position ? project(position) : null; - let marker: { x: number; y: number } | null = null; - if (anchor) { - const before = positionAcrossRuns(runs, Math.max(0, fraction * maximum - maximum * 0.002)); - const after = positionAcrossRuns(runs, Math.min(maximum, fraction * maximum + maximum * 0.002)); - let nx = before && after ? -(after.z - before.z) : anchor.x - 100; - let ny = before && after ? after.x - before.x : anchor.y - 54; - if (nx * (anchor.x - 100) + ny * (anchor.y - 54) < 0) { - nx = -nx; - ny = -ny; - } - const angle = Math.atan2(ny, nx); - // Search outward first, avoiding every track run and previously placed badge. - for (let radius = 13; radius <= 200 && !marker; radius += 3) { - for (let step = 0; step < 24; step++) { - const offset = Math.ceil(step / 2) * (step % 2 ? 1 : -1) * Math.PI / 12; - const x = anchor.x + Math.cos(angle + offset) * radius; - const y = anchor.y + Math.sin(angle + offset) * radius; - if (clear(x, y)) { - marker = { x, y }; - placed.push(marker); - break; - } - } - } - } - return { number, segment, delta, anchor, position: marker, sectionPaths: segmentPaths(runs, segment.startFrac, segment.endFrac, maximum, projectString) }; - }); - return { project, paths: runs.filter((run) => run.length > 1).map((run) => run.map(projectString).join(" ")), markers }; - }, [maximum, rows, runs]); - const projectedFocusRuns = useMemo(() => geometry ? coloredRuns.map((runs) => runs.map((run) => ({ - token: run.token, - points: run.points.map((point) => { const p = geometry.project(point); return `${p.x},${p.y}`; }).join(" "), - }))) : [], [coloredRuns, geometry]); - const poseA = geometry?.project(snapshot.a?.pose ?? { x: Number.NaN, z: Number.NaN }); - const poseB = geometry?.project(snapshot.b?.pose ?? { x: Number.NaN, z: Number.NaN }); - return ( -
-
- {m.spectate_overview()} - {m.spectate_select_segment()} -
- {geometry ? ( - - {geometry.paths.map((path, index) => ( - - ))} - {projectedFocusRuns.flatMap((runs, side) => runs.map((run, index) => ( - - )))} - {geometry.markers.map(({ number, segment, delta, anchor, position, sectionPaths }) => ( - controller.seekDistance(Math.max(0, Math.min(maximum, segment.startFrac * maximum)))} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - controller.seekDistance(Math.max(0, Math.min(maximum, segment.startFrac * maximum))); - } - }} - className="cursor-pointer outline-none focus-visible:opacity-70" - > - {`${segment.name || m.spectate_segment_number({ number })} · A − B: ${splitText(delta)}`} - {sectionPaths.map((path, index) => ( - - ))} - {position && ( - <> - {anchor && } - - 0 ? "var(--app-text)" : "var(--app-bg)"} fontSize="6" fontWeight="var(--font-weight-bold)"> - {number} - - - )} - - ))} - {poseA && Number.isFinite(poseA.x) && Number.isFinite(poseA.y) && ( - - A - - )} - {poseB && Number.isFinite(poseB.x) && Number.isFinite(poseB.y) && ( - - B - - )} - - ) : ( -
{m.spectate_geometry_unavailable()}
- )} -
- ); -} diff --git a/client/src/components/comparison/spectate/SpectatePlayback.tsx b/client/src/components/comparison/spectate/SpectatePlayback.tsx deleted file mode 100644 index b6538f0bd..000000000 --- a/client/src/components/comparison/spectate/SpectatePlayback.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useMemo, useSyncExternalStore } from "react"; -import { Pause, Play } from "lucide-react"; -import { m } from "@/paraglide/messages"; -import type { SpectateController, SpectatePair } from "./types"; -function stamp(seconds: number) { - const minutes = Math.floor(seconds / 60); - return `${String(minutes).padStart(2, "0")}:${(seconds - minutes * 60).toFixed(3).padStart(6, "0")}`; -} -export function SpectatePlayback({ controller, pair }: { controller: SpectateController; pair: SpectatePair }) { - const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); - const duration = Number.isFinite(snapshot.duration) ? Math.max(0, snapshot.duration) : 0; - const time = Math.max(0, Math.min(duration, snapshot.time)); - // The replay clock always follows A's elapsed time, including position-lock mode. - const sectors = useMemo(() => { - const times = pair.comparison.lapA.sectorTimes; - if (!times?.length || times.some((value) => !Number.isFinite(value) || value <= 0)) return []; - let elapsed = 0; - return times.map((seconds, index) => { - const start = elapsed; - elapsed += seconds; - const other = pair.comparison.lapB.sectorTimes?.[index]; - const delta = other != null && Number.isFinite(other) && other > 0 ? Math.round((seconds - other) * 1000) / 1000 : null; - return { sector: index + 1, start, time: elapsed, seconds, delta }; - }); - }, [pair]); - return ( -
-
- {sectors.map((sector) => sector.start < duration && duration > 0 && ( - - - {sector.seconds.toFixed(3)} - - {sector.delta !== null && - {` (${sector.delta > 0 ? "+" : ""}${sector.delta.toFixed(3)} s)`} - } - {" "}S{sector.sector} - - ))} - {sectors.map((split, index) => index < sectors.length - 1 && split.time > 0 && split.time < duration && ( - - ))} - controller.scrub(Number(event.currentTarget.value))} - /> -
-
- - - {stamp(time)} - - / {stamp(duration)} - -
-
- ); -} diff --git a/client/src/components/comparison/spectate/SpectateScene.tsx b/client/src/components/comparison/spectate/SpectateScene.tsx deleted file mode 100644 index 4e04fd03b..000000000 --- a/client/src/components/comparison/spectate/SpectateScene.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { useEffect, useRef, useState, useSyncExternalStore, type ButtonHTMLAttributes, type ReactNode } from "react"; -import { Navigation, Footprints, CarFront, Route, Map } from "lucide-react"; -import type { SpectateController, SpectatePair } from "./types"; -import type { TrackMapBoundaries } from "../../analyse/track-map/types"; -import { useSettings } from "@/hooks/settings"; -import { useTrackBoundaries } from "@/hooks/track-queries"; -import { m } from "@/paraglide/messages"; -import { SpectateSceneRuntime, type CarModelMode } from "./SpectateSceneRuntime"; -import { FOCUS_ZOOM_SNAP_ZONE_END } from "./spectate-focus-lanes"; -import type { SpectateFocus } from "./spectate-focus"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; - -type ToolbarTooltipProps = Omit, "children"> & { - tooltip: string; - side?: "top" | "bottom" | "left" | "right"; - children: ReactNode; -}; -function ToolbarTooltip({ tooltip, side = "right", children, ...buttonProps }: ToolbarTooltipProps) { - return ( - - }>{children} - {tooltip} - - ); -} -export interface SpectateSceneProps { - pair: SpectatePair; - controller: SpectateController; - outline: readonly { x: number; z: number }[] | null; - overview: ReactNode; - focus: SpectateFocus; -} - -function useToolbarToggle(key: string, defaultValue: boolean) { - const [value, setValue] = useState(() => { - try { - const stored = localStorage.getItem(key); - if (stored === "true") return true; - if (stored === "false") return false; - } catch { - /* Storage may be unavailable. */ - } - return defaultValue; - }); - useEffect(() => { - try { - localStorage.setItem(key, String(value)); - } catch { - /* Keep toggles usable when storage is unavailable. */ - } - }, [key, value]); - return [value, setValue] as const; -} - -/** Canvas owner for the paired recorded-world scene. */ -export function SpectateScene({ pair, controller, outline, overview, focus }: SpectateSceneProps) { - const { displaySettings } = useSettings(); - const boundaries = useTrackBoundaries(pair.comparison.lapA.trackId, pair.gameId).data as TrackMapBoundaries | undefined; - const positionLock = useSyncExternalStore(controller.subscribe, () => controller.getSnapshot().positionLock, () => controller.getSnapshot().positionLock); - const canLock = useSyncExternalStore(controller.subscribe, () => controller.getSnapshot().canLock, () => controller.getSnapshot().canLock); - const host = useRef(null); - const runtime = useRef(null); - const zoomIndicator = useRef(null); - const [ready, setReady] = useState(false); - const [directionLock, setDirectionLock] = useToolbarToggle("spectate-direction-lock", false); - const [tireTrails, setTireTrails] = useToolbarToggle("spectate-tire-trails", false); - const [racingLine, setRacingLine] = useToolbarToggle("spectate-racing-line", false); - const [positionLockPreferred, setPositionLockPreferred] = useToolbarToggle("spectate-position-lock", true); - const [overviewVisible, setOverviewVisible] = useToolbarToggle("spectate-track-overview", true); - const [carModelMode, setCarModelMode] = useState("solid"); - const hasRacingLine = Array.isArray(boundaries?.raceLine) && boundaries.raceLine.length > 1; - const [error, setError] = useState(null); - const [sideAState, setSideAState] = useState("loading"); - const [sideBState, setSideBState] = useState("loading"); - const key = `${pair.gameId}:${pair.comparison.lapA.id}:${pair.comparison.lapB.id}`; - useEffect(() => { - setReady(false); - setError(null); - setSideAState("loading"); - setSideBState("loading"); - const node = host.current; - if (!node) return; - let mounted = true; - const current = new SpectateSceneRuntime( - node, - pair, - controller, - (value) => { - if (mounted) { - setReady(value); - setSideAState(runtime.current?.canvas.dataset.sideAState ?? "loading"); - setSideBState(runtime.current?.canvas.dataset.sideBState ?? "loading"); - } - }, - (message) => { - if (mounted) { - setError(message || null); - setSideAState(runtime.current?.canvas.dataset.sideAState ?? "unavailable"); - setSideBState(runtime.current?.canvas.dataset.sideBState ?? "unavailable"); - } - }, - (position, available) => { - const indicator = zoomIndicator.current; - if (!indicator) return; - indicator.dataset.zoomPosition = String(position); - indicator.style.display = available ? "block" : "none"; - indicator.style.setProperty("--zoom-position", `${position * 100}%`); - }, - new URLSearchParams(location.search).get("forceWebGL") === "true", - displaySettings.renderFpsCap, - ); - runtime.current = current; - return () => { - mounted = false; - runtime.current = null; - current.dispose(); - }; - }, [key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - runtime.current?.setOutline(outline, boundaries ?? null); - }, [outline, boundaries, key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - runtime.current?.setFocus(focus); - }, [focus, key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - runtime.current?.setDirectionLock(directionLock); - }, [directionLock, key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - runtime.current?.setCarModelMode(carModelMode); - }, [carModelMode, key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - runtime.current?.setTireTrails(tireTrails); - }, [tireTrails, key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - runtime.current?.setRacingLine(racingLine && hasRacingLine); - }, [racingLine, hasRacingLine, key, controller, displaySettings.renderFpsCap]); - useEffect(() => { - controller.setPositionLock(positionLockPreferred); - }, [controller, canLock, positionLockPreferred]); - return ( -
-
-