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..b2c3b9220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,23 @@ ## Unreleased ### Features +- Show clickable circuit-overview sector markers on desktop only while hovering or using keyboard focus; keep them visible on touch screens. +- Group LMU state of charge, virtual energy and regenerative power in a separate table below the main Replay and Compare → Spectate metrics. +- Use dashed track edges in Replay and Compare → Spectate to provide fixed-distance speed cues. +- Show plain TC and ABS indicators in the Replay and Compare → Spectate center dashboard only when native activation telemetry was recorded, lighting them green during activation and keeping inactive or temporarily unavailable readings neutral. +- Show color-coded per-wheel traction status in the center dashboard for single-lap Replay and Compare → Spectate, with separate A/B readings and unavailable telemetry marked clearly. - Keep Sessions actions in a bottom-center floating bar with a selection count and Deselect all. Keep Compare visible and enable it only for two compatible laps; filter comparison laps by Mine, Others, or All inside the lap-picker dropdown. -- 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 separate LMU player energy, engine, electric motor, steering feedback, weather and race-control readings in live telemetry 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. +- Bring Compare → Spectate's scene, playback, telemetry and Insights to single-lap Replay, with saved 3D settings, grouped wheel graphs, stable ERS bands, chassis/tire-info rendering and independently resizable, horizontally scrollable panels. +- Show LMU traction-control and ABS settings and activation, traction-control cut level, motor map, battery state of charge, virtual energy and signed regen power in Replay's existing Data panel instead of a separate Player telemetry section; show brake migration beside brake bias in Dynamics. +- Restore the single-lap Replay segment-time list in a collapsible Segments panel, with active-segment highlighting and a saved open state. +- Use Replay on phones with a compact horizontal dashboard, larger touch controls, an opaque circuit overview you can close, and one panel at a time beneath the playback tabs. Read colored sector labels closer to the timeline on phones and desktops, and scroll track and lap pickers out of view while keeping navigation visible. +- Manage Replay AI analysis from its tab without a duplicate panel header. +- Open desktop Replay and Spectate panels within half the workspace, then drag them wider—including a single panel—to give charts more room. Resize each panel without shrinking its neighbors. +- Drag Replay and Spectate tabs to reorder them, or use Alt+Left/Right; keep the saved tab order when reopening the page. - 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. @@ -14,6 +27,10 @@ - Provide a Linux x86_64 AppImage for Ubuntu 22.04 and newer, with AppImageUpdate-compatible GitHub release deltas. ### Fixes +- Correct the LMU steering indicator direction in the Replay and Compare → Spectate center dashboard without changing recorded steering values. +- Show active TC and ABS readings in green in Replay's Data panel, while keeping inactive and unavailable readings neutral. +- Use reported traction-control activity in Replay and lap insights when the game's telemetry provides it; infer activation only when that channel is unavailable. +- Hide Y-axis labels on telemetry charts while keeping chart legends and data visible. - Keep recording splitting and background processing available when the UDP telemetry port is already in use. - Report inconclusive historical lap classification as limited without requesting attention, and correct earlier results. - Center Le Mans Ultimate track outlines within catalogue cards. @@ -81,6 +98,7 @@ - Skip LMU outline requests when stored track names do not identify one layout. ### Internal +- Cover Replay extraction invariants with regressions for bounded track ribbons, seek-aware tire labels, cold frame capture, keyboard frame stepping, position-only segment timings and native AC Evo TC evidence. - Ignore terminal color codes when waiting for compiler startup in the Paraglide lifecycle CI test. - Generate Paraglide declarations with the installed native TypeScript compiler and stop abandoned translation watchers from consuming CPU after development exits. - Use Ubuntu instead of macOS for CI runners and cap Mars memory requests at 6 GB. 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/messages/de.json b/client/messages/de.json index f5508904d..f12809b81 100644 --- a/client/messages/de.json +++ b/client/messages/de.json @@ -2760,8 +2760,8 @@ "lmu_channel_timing_gap_car_behind": "Abstand zum nachfolgenden Fahrzeug", "lmu_channel_timing_gap_place_ahead": "Abstand zum nächstbesser Platzierten", "lmu_channel_timing_gap_place_behind": "Abstand zum nächstschlechter Platzierten", - "lmu_channel_electronics_tc_active": "Traktionskontrolle aktiv", - "lmu_channel_electronics_traction_control_level": "Traktionskontrollstufe", + "lmu_channel_electronics_tc_active": "TC aktiv", + "lmu_channel_electronics_traction_control_level": "TC-Stufe", "lmu_channel_electronics_abs_active": "ABS aktiv", "lmu_channel_electronics_abs_level": "ABS-Stufe", "lmu_channel_electronics_motor_map": "Motorkennfeld", @@ -2865,7 +2865,8 @@ "spectate_position_lock": "Fahrzeuge an derselben Streckenposition fixieren", "spectate_position_lock_unavailable": "Positionsfixierung nicht verfügbar", "spectate_playback_speed": "Wiedergabegeschwindigkeit", - "spectate_resize_panel": "Telemetriepanel skalieren", + "spectate_resize_panel": "Panelbereich skalieren", + "spectate_resize_between": "{left} und {right} skalieren", "spectate_show_telemetry": "Telemetrie anzeigen", "spectate_hide_telemetry": "Telemetrie ausblenden", "spectate_telemetry": "Telemetrie", diff --git a/client/messages/en.json b/client/messages/en.json index b36873cfb..3a372ccc8 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -2764,12 +2764,12 @@ "lmu_channel_timing_gap_car_behind": "Gap to car behind", "lmu_channel_timing_gap_place_ahead": "Gap to position ahead", "lmu_channel_timing_gap_place_behind": "Gap to position behind", - "lmu_channel_electronics_tc_active": "Traction control active", - "lmu_channel_electronics_traction_control_level": "Traction control level", + "lmu_channel_electronics_tc_active": "TC active", + "lmu_channel_electronics_traction_control_level": "TC Level", "lmu_channel_electronics_abs_active": "ABS active", "lmu_channel_electronics_abs_level": "ABS level", "lmu_channel_electronics_motor_map": "Motor map", - "lmu_channel_electronics_tc_cut_level": "Traction control cut level", + "lmu_channel_electronics_tc_cut_level": "TC Cut", "lmu_channel_race_on_pit_road": "On pit road", "lmu_channel_race_pit_state": "Pit state", "lmu_channel_race_scheduled_stops": "Scheduled pit stops", @@ -2865,7 +2865,8 @@ "spectate_position_lock": "Lock cars to same track position", "spectate_position_lock_unavailable": "Position lock unavailable", "spectate_playback_speed": "Playback speed", - "spectate_resize_panel": "Resize telemetry panel", + "spectate_resize_panel": "Resize panels", + "spectate_resize_between": "Resize {left} and {right}", "spectate_show_telemetry": "Show telemetry", "spectate_hide_telemetry": "Hide telemetry", "spectate_telemetry": "Telemetry", diff --git a/client/messages/es.json b/client/messages/es.json index f1d98e106..920c1a5e8 100644 --- a/client/messages/es.json +++ b/client/messages/es.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Diferencia con el coche de detrás", "lmu_channel_timing_gap_place_ahead": "Diferencia con la posición anterior", "lmu_channel_timing_gap_place_behind": "Diferencia con la posición posterior", - "lmu_channel_electronics_tc_active": "Control de tracción activo", - "lmu_channel_electronics_traction_control_level": "Nivel del control de tracción", + "lmu_channel_electronics_tc_active": "TC activo", + "lmu_channel_electronics_traction_control_level": "Nivel de TC", "lmu_channel_electronics_abs_active": "ABS activo", "lmu_channel_electronics_abs_level": "Nivel del ABS", "lmu_channel_electronics_motor_map": "Mapa del motor", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Alinear coches en la misma posición de pista", "spectate_position_lock_unavailable": "Alineación por posición no disponible", "spectate_playback_speed": "Velocidad de reproducción", - "spectate_resize_panel": "Cambiar tamaño del panel de telemetría", + "spectate_resize_panel": "Cambiar tamaño de los paneles", + "spectate_resize_between": "Cambiar tamaño de {left} y {right}", "spectate_show_telemetry": "Mostrar telemetría", "spectate_hide_telemetry": "Ocultar telemetría", "spectate_telemetry": "Telemetría", diff --git a/client/messages/fi.json b/client/messages/fi.json index edd85271c..f4d574c50 100644 --- a/client/messages/fi.json +++ b/client/messages/fi.json @@ -2744,8 +2744,8 @@ "lmu_channel_timing_gap_car_behind": "Ero takana ajavaan autoon", "lmu_channel_timing_gap_place_ahead": "Ero edelliseen sijoitukseen", "lmu_channel_timing_gap_place_behind": "Ero seuraavaan sijoitukseen", - "lmu_channel_electronics_tc_active": "Luistonesto käytössä", - "lmu_channel_electronics_traction_control_level": "Luistoneston taso", + "lmu_channel_electronics_tc_active": "TC käytössä", + "lmu_channel_electronics_traction_control_level": "TC-taso", "lmu_channel_electronics_abs_active": "ABS käytössä", "lmu_channel_electronics_abs_level": "ABS-taso", "lmu_channel_electronics_motor_map": "Moottorikartta", @@ -2861,7 +2861,8 @@ "spectate_position_lock": "Lukitse autot samaan kohtaan radalla", "spectate_position_lock_unavailable": "Sijainnin lukitus ei ole käytettävissä", "spectate_playback_speed": "Toistonopeus", - "spectate_resize_panel": "Muuta telemetriapaneelin kokoa", + "spectate_resize_panel": "Muuta paneelien kokoa", + "spectate_resize_between": "Muuta paneelien {left} ja {right} kokoa", "spectate_show_telemetry": "Näytä telemetria", "spectate_hide_telemetry": "Piilota telemetria", "spectate_telemetry": "Telemetria", diff --git a/client/messages/fr.json b/client/messages/fr.json index e4b45a352..00c6702d2 100644 --- a/client/messages/fr.json +++ b/client/messages/fr.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Écart avec la voiture derrière", "lmu_channel_timing_gap_place_ahead": "Écart avec la position précédente", "lmu_channel_timing_gap_place_behind": "Écart avec la position suivante", - "lmu_channel_electronics_tc_active": "Antipatinage actif", - "lmu_channel_electronics_traction_control_level": "Niveau d’antipatinage", + "lmu_channel_electronics_tc_active": "TC actif", + "lmu_channel_electronics_traction_control_level": "Niveau de TC", "lmu_channel_electronics_abs_active": "ABS actif", "lmu_channel_electronics_abs_level": "Niveau d’ABS", "lmu_channel_electronics_motor_map": "Cartographie moteur", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Aligner les voitures sur la même position de piste", "spectate_position_lock_unavailable": "Alignement des positions indisponible", "spectate_playback_speed": "Vitesse de lecture", - "spectate_resize_panel": "Redimensionner le panneau de télémétrie", + "spectate_resize_panel": "Redimensionner les panneaux", + "spectate_resize_between": "Redimensionner {left} et {right}", "spectate_show_telemetry": "Afficher la télémétrie", "spectate_hide_telemetry": "Masquer la télémétrie", "spectate_telemetry": "Télémétrie", diff --git a/client/messages/hi.json b/client/messages/hi.json index 47d61b5bf..5bf81923d 100644 --- a/client/messages/hi.json +++ b/client/messages/hi.json @@ -2744,8 +2744,8 @@ "lmu_channel_timing_gap_car_behind": "पीछे की कार से समय का अंतर", "lmu_channel_timing_gap_place_ahead": "अगले स्थान की कार से समय का अंतर", "lmu_channel_timing_gap_place_behind": "पिछले स्थान की कार से समय का अंतर", - "lmu_channel_electronics_tc_active": "ट्रैक्शन कंट्रोल सक्रिय", - "lmu_channel_electronics_traction_control_level": "ट्रैक्शन कंट्रोल का स्तर", + "lmu_channel_electronics_tc_active": "TC सक्रिय", + "lmu_channel_electronics_traction_control_level": "TC स्तर", "lmu_channel_electronics_abs_active": "ABS सक्रिय", "lmu_channel_electronics_abs_level": "ABS का स्तर", "lmu_channel_electronics_motor_map": "मोटर मैप", @@ -2861,7 +2861,8 @@ "spectate_position_lock": "कारों को ट्रैक की समान स्थिति पर लॉक करें", "spectate_position_lock_unavailable": "स्थिति लॉक उपलब्ध नहीं", "spectate_playback_speed": "प्लेबैक गति", - "spectate_resize_panel": "टेलीमेट्री पैनल का आकार बदलें", + "spectate_resize_panel": "पैनलों का आकार बदलें", + "spectate_resize_between": "{left} और {right} का आकार बदलें", "spectate_show_telemetry": "टेलीमेट्री दिखाएँ", "spectate_hide_telemetry": "टेलीमेट्री छिपाएँ", "spectate_telemetry": "टेलीमेट्री", diff --git a/client/messages/it.json b/client/messages/it.json index 54bd0d026..5652060af 100644 --- a/client/messages/it.json +++ b/client/messages/it.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Distacco dalla vettura dietro", "lmu_channel_timing_gap_place_ahead": "Distacco dalla posizione precedente", "lmu_channel_timing_gap_place_behind": "Distacco dalla posizione successiva", - "lmu_channel_electronics_tc_active": "Controllo di trazione attivo", - "lmu_channel_electronics_traction_control_level": "Livello del controllo di trazione", + "lmu_channel_electronics_tc_active": "TC attivo", + "lmu_channel_electronics_traction_control_level": "Livello TC", "lmu_channel_electronics_abs_active": "ABS attivo", "lmu_channel_electronics_abs_level": "Livello ABS", "lmu_channel_electronics_motor_map": "Mappa motore", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Allinea le auto alla stessa posizione sul tracciato", "spectate_position_lock_unavailable": "Allineamento della posizione non disponibile", "spectate_playback_speed": "Velocità di riproduzione", - "spectate_resize_panel": "Ridimensiona il pannello della telemetria", + "spectate_resize_panel": "Ridimensiona i pannelli", + "spectate_resize_between": "Ridimensiona {left} e {right}", "spectate_show_telemetry": "Mostra telemetria", "spectate_hide_telemetry": "Nascondi telemetria", "spectate_telemetry": "Telemetria", diff --git a/client/messages/ja.json b/client/messages/ja.json index abb3f6e78..ca5f94297 100644 --- a/client/messages/ja.json +++ b/client/messages/ja.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "後方車両とのタイム差", "lmu_channel_timing_gap_place_ahead": "1つ上の順位とのタイム差", "lmu_channel_timing_gap_place_behind": "1つ下の順位とのタイム差", - "lmu_channel_electronics_tc_active": "トラクションコントロール作動中", - "lmu_channel_electronics_traction_control_level": "トラクションコントロールレベル", + "lmu_channel_electronics_tc_active": "TC作動中", + "lmu_channel_electronics_traction_control_level": "TCレベル", "lmu_channel_electronics_abs_active": "ABS作動中", "lmu_channel_electronics_abs_level": "ABSレベル", "lmu_channel_electronics_motor_map": "モーターマップ", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "車両を同じコース位置に固定", "spectate_position_lock_unavailable": "位置固定を利用できません", "spectate_playback_speed": "再生速度", - "spectate_resize_panel": "テレメトリーパネルのサイズを変更", + "spectate_resize_panel": "パネルのサイズを変更", + "spectate_resize_between": "{left}と{right}のサイズを変更", "spectate_show_telemetry": "テレメトリーを表示", "spectate_hide_telemetry": "テレメトリーを非表示", "spectate_telemetry": "テレメトリー", diff --git a/client/messages/ko.json b/client/messages/ko.json index e82e1b261..af132dcb7 100644 --- a/client/messages/ko.json +++ b/client/messages/ko.json @@ -2744,8 +2744,8 @@ "lmu_channel_timing_gap_car_behind": "뒤 차량과의 격차", "lmu_channel_timing_gap_place_ahead": "앞 순위 차량과의 격차", "lmu_channel_timing_gap_place_behind": "뒤 순위 차량과의 격차", - "lmu_channel_electronics_tc_active": "트랙션 컨트롤 작동 여부", - "lmu_channel_electronics_traction_control_level": "트랙션 컨트롤 단계", + "lmu_channel_electronics_tc_active": "TC 작동 여부", + "lmu_channel_electronics_traction_control_level": "TC 단계", "lmu_channel_electronics_abs_active": "ABS 작동 여부", "lmu_channel_electronics_abs_level": "ABS 단계", "lmu_channel_electronics_motor_map": "모터 맵", @@ -2861,7 +2861,8 @@ "spectate_position_lock": "차량을 동일한 트랙 위치에 고정", "spectate_position_lock_unavailable": "위치 고정을 사용할 수 없습니다", "spectate_playback_speed": "재생 속도", - "spectate_resize_panel": "텔레메트리 패널 크기 조절", + "spectate_resize_panel": "패널 크기 조절", + "spectate_resize_between": "{left} 및 {right} 크기 조절", "spectate_show_telemetry": "텔레메트리 표시", "spectate_hide_telemetry": "텔레메트리 숨기기", "spectate_telemetry": "텔레메트리", diff --git a/client/messages/nl.json b/client/messages/nl.json index 25ffc0c77..86c8fb37f 100644 --- a/client/messages/nl.json +++ b/client/messages/nl.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Verschil met achterliggende auto", "lmu_channel_timing_gap_place_ahead": "Verschil met positie ervoor", "lmu_channel_timing_gap_place_behind": "Verschil met positie erachter", - "lmu_channel_electronics_tc_active": "Tractiecontrole actief", - "lmu_channel_electronics_traction_control_level": "Tractiecontroleniveau", + "lmu_channel_electronics_tc_active": "TC actief", + "lmu_channel_electronics_traction_control_level": "TC-niveau", "lmu_channel_electronics_abs_active": "ABS actief", "lmu_channel_electronics_abs_level": "ABS-niveau", "lmu_channel_electronics_motor_map": "Motorkarakteristiek", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Auto's op dezelfde circuitpositie vergrendelen", "spectate_position_lock_unavailable": "Positievergrendeling niet beschikbaar", "spectate_playback_speed": "Afspeelsnelheid", - "spectate_resize_panel": "Telemetriepaneel vergroten of verkleinen", + "spectate_resize_panel": "Panelen vergroten of verkleinen", + "spectate_resize_between": "{left} en {right} vergroten of verkleinen", "spectate_show_telemetry": "Telemetrie tonen", "spectate_hide_telemetry": "Telemetrie verbergen", "spectate_telemetry": "Telemetrie", diff --git a/client/messages/pl.json b/client/messages/pl.json index be87a342b..0199f0d59 100644 --- a/client/messages/pl.json +++ b/client/messages/pl.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Odstęp do samochodu z tyłu", "lmu_channel_timing_gap_place_ahead": "Odstęp do poprzedniej pozycji", "lmu_channel_timing_gap_place_behind": "Odstęp do następnej pozycji", - "lmu_channel_electronics_tc_active": "Kontrola trakcji aktywna", - "lmu_channel_electronics_traction_control_level": "Poziom kontroli trakcji", + "lmu_channel_electronics_tc_active": "TC aktywna", + "lmu_channel_electronics_traction_control_level": "Poziom TC", "lmu_channel_electronics_abs_active": "ABS aktywny", "lmu_channel_electronics_abs_level": "Poziom ABS", "lmu_channel_electronics_motor_map": "Mapa silnika", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Ustaw samochody w tym samym miejscu toru", "spectate_position_lock_unavailable": "Blokada pozycji niedostępna", "spectate_playback_speed": "Szybkość odtwarzania", - "spectate_resize_panel": "Zmień rozmiar panelu telemetrii", + "spectate_resize_panel": "Zmień rozmiar paneli", + "spectate_resize_between": "Zmień rozmiar {left} i {right}", "spectate_show_telemetry": "Pokaż telemetrię", "spectate_hide_telemetry": "Ukryj telemetrię", "spectate_telemetry": "Telemetria", diff --git a/client/messages/pt-BR.json b/client/messages/pt-BR.json index cec123909..4f3c26260 100644 --- a/client/messages/pt-BR.json +++ b/client/messages/pt-BR.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Diferença para o carro atrás", "lmu_channel_timing_gap_place_ahead": "Diferença para a posição à frente", "lmu_channel_timing_gap_place_behind": "Diferença para a posição atrás", - "lmu_channel_electronics_tc_active": "Controle de tração ativo", - "lmu_channel_electronics_traction_control_level": "Nível do controle de tração", + "lmu_channel_electronics_tc_active": "TC ativo", + "lmu_channel_electronics_traction_control_level": "Nível de TC", "lmu_channel_electronics_abs_active": "ABS ativo", "lmu_channel_electronics_abs_level": "Nível do ABS", "lmu_channel_electronics_motor_map": "Mapa do motor", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Manter carros na mesma posição da pista", "spectate_position_lock_unavailable": "Bloqueio de posição indisponível", "spectate_playback_speed": "Velocidade de reprodução", - "spectate_resize_panel": "Redimensionar painel de telemetria", + "spectate_resize_panel": "Redimensionar painéis", + "spectate_resize_between": "Redimensionar {left} e {right}", "spectate_show_telemetry": "Mostrar telemetria", "spectate_hide_telemetry": "Ocultar telemetria", "spectate_telemetry": "Telemetria", diff --git a/client/messages/pt-PT.json b/client/messages/pt-PT.json index d71730d2e..a51a64b44 100644 --- a/client/messages/pt-PT.json +++ b/client/messages/pt-PT.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Intervalo para o carro atrás", "lmu_channel_timing_gap_place_ahead": "Intervalo para a posição à frente", "lmu_channel_timing_gap_place_behind": "Intervalo para a posição atrás", - "lmu_channel_electronics_tc_active": "Controlo de tração ativo", - "lmu_channel_electronics_traction_control_level": "Nível do controlo de tração", + "lmu_channel_electronics_tc_active": "TC ativo", + "lmu_channel_electronics_traction_control_level": "Nível de TC", "lmu_channel_electronics_abs_active": "ABS ativo", "lmu_channel_electronics_abs_level": "Nível do ABS", "lmu_channel_electronics_motor_map": "Mapa do motor", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Manter carros na mesma posição da pista", "spectate_position_lock_unavailable": "Bloqueio de posição indisponível", "spectate_playback_speed": "Velocidade de reprodução", - "spectate_resize_panel": "Redimensionar painel de telemetria", + "spectate_resize_panel": "Redimensionar painéis", + "spectate_resize_between": "Redimensionar {left} e {right}", "spectate_show_telemetry": "Mostrar telemetria", "spectate_hide_telemetry": "Ocultar telemetria", "spectate_telemetry": "Telemetria", diff --git a/client/messages/ru.json b/client/messages/ru.json index e670a4c90..ffe6aee50 100644 --- a/client/messages/ru.json +++ b/client/messages/ru.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Интервал до машины позади", "lmu_channel_timing_gap_place_ahead": "Интервал до позиции впереди", "lmu_channel_timing_gap_place_behind": "Интервал до позиции позади", - "lmu_channel_electronics_tc_active": "Антипробуксовочная система активна", - "lmu_channel_electronics_traction_control_level": "Уровень антипробуксовочной системы", + "lmu_channel_electronics_tc_active": "TC активна", + "lmu_channel_electronics_traction_control_level": "Уровень TC", "lmu_channel_electronics_abs_active": "ABS активна", "lmu_channel_electronics_abs_level": "Уровень ABS", "lmu_channel_electronics_motor_map": "Карта электромотора", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Совместить автомобили в одной позиции на трассе", "spectate_position_lock_unavailable": "Совмещение позиций недоступно", "spectate_playback_speed": "Скорость воспроизведения", - "spectate_resize_panel": "Изменить размер панели телеметрии", + "spectate_resize_panel": "Изменить размер панелей", + "spectate_resize_between": "Изменить размер {left} и {right}", "spectate_show_telemetry": "Показать телеметрию", "spectate_hide_telemetry": "Скрыть телеметрию", "spectate_telemetry": "Телеметрия", diff --git a/client/messages/uk.json b/client/messages/uk.json index ec7814f62..aab236529 100644 --- a/client/messages/uk.json +++ b/client/messages/uk.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "Інтервал до автомобіля позаду", "lmu_channel_timing_gap_place_ahead": "Інтервал до попередньої позиції", "lmu_channel_timing_gap_place_behind": "Інтервал до наступної позиції", - "lmu_channel_electronics_tc_active": "Антибуксувальна система активна", - "lmu_channel_electronics_traction_control_level": "Рівень антибуксувальної системи", + "lmu_channel_electronics_tc_active": "TC активна", + "lmu_channel_electronics_traction_control_level": "Рівень TC", "lmu_channel_electronics_abs_active": "ABS активна", "lmu_channel_electronics_abs_level": "Рівень ABS", "lmu_channel_electronics_motor_map": "Карта електромотора", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "Розташувати автомобілі в одній позиції на трасі", "spectate_position_lock_unavailable": "Вирівнювання позицій недоступне", "spectate_playback_speed": "Швидкість відтворення", - "spectate_resize_panel": "Змінити розмір панелі телеметрії", + "spectate_resize_panel": "Змінити розмір панелей", + "spectate_resize_between": "Змінити розмір {left} і {right}", "spectate_show_telemetry": "Показати телеметрію", "spectate_hide_telemetry": "Приховати телеметрію", "spectate_telemetry": "Телеметрія", diff --git a/client/messages/zh-CN.json b/client/messages/zh-CN.json index f441e1ee0..e8bc3d631 100644 --- a/client/messages/zh-CN.json +++ b/client/messages/zh-CN.json @@ -2743,8 +2743,8 @@ "lmu_channel_timing_gap_car_behind": "与后车的差距", "lmu_channel_timing_gap_place_ahead": "与前一名的差距", "lmu_channel_timing_gap_place_behind": "与后一名的差距", - "lmu_channel_electronics_tc_active": "牵引力控制介入中", - "lmu_channel_electronics_traction_control_level": "牵引力控制等级", + "lmu_channel_electronics_tc_active": "TC 介入中", + "lmu_channel_electronics_traction_control_level": "TC 等级", "lmu_channel_electronics_abs_active": "ABS 介入中", "lmu_channel_electronics_abs_level": "ABS 等级", "lmu_channel_electronics_motor_map": "电机映射", @@ -2860,7 +2860,8 @@ "spectate_position_lock": "将车辆锁定在相同赛道位置", "spectate_position_lock_unavailable": "位置锁定不可用", "spectate_playback_speed": "回放速度", - "spectate_resize_panel": "调整遥测面板大小", + "spectate_resize_panel": "调整面板大小", + "spectate_resize_between": "调整{left}和{right}的大小", "spectate_show_telemetry": "显示遥测", "spectate_hide_telemetry": "隐藏遥测", "spectate_telemetry": "遥测", diff --git a/client/package.json b/client/package.json index 1f19b3e24..c4b83ba8e 100644 --- a/client/package.json +++ b/client/package.json @@ -2,7 +2,7 @@ "name": "client", "private": true, "type": "module", - "exports": {"./src/components/home/dashboard-insights":"./src/components/home/dashboard-insights.ts","./src/components/ai-chat/resumable-chat":"./src/components/ai-chat/resumable-chat.ts","./src/components/ai-chat/token-usage-format":"./src/components/ai-chat/token-usage-format.ts","./src/components/ai/analysis-display-data":"./src/components/ai/analysis-display-data.ts","./src/components/analyse/AnalyseSegmentList":"./src/components/analyse/AnalyseSegmentList.tsx","./src/components/analyse/chart-data":"./src/components/analyse/chart-data.ts","./src/components/analyse/track-map/types":"./src/components/analyse/track-map/types.ts","./src/components/connection-status-logic":"./src/components/connection-status-logic.ts","./src/components/tune/browser/buildRows":"./src/components/tune/browser/buildRows.ts","./src/components/tunes/semantic-tune":"./src/components/tunes/semantic-tune.ts","./src/components/tunes/track-focus/TrackFocusZoom":"./src/components/tunes/track-focus/TrackFocusZoom.tsx","./src/lib/canvas/draw-track":"./src/lib/canvas/draw-track.ts","./src/hooks/websocket-url":"./src/hooks/websocket-url.ts","./src/lib/aligned-telemetry-fidelity":"./src/lib/aligned-telemetry-fidelity.ts","./src/lib/comparison-utils":"./src/lib/comparison-utils.ts","./src/lib/game-routes":"./src/lib/game-routes.ts","./src/lib/is-ai-configured":"./src/lib/is-ai-configured.ts","./src/lib/live-telemetry-view":"./src/lib/live-telemetry-view.ts","./src/lib/reprocess-state":"./src/lib/reprocess-state.ts","./src/lib/rpc-error":"./src/lib/rpc-error.ts","./src/lib/stint-traces":"./src/lib/stint-traces.ts","./src/lib/track-display-name":"./src/lib/track-display-name.ts","./src/stores/game":"./src/stores/game.ts","./src/stories/SessionsPage.stories":"./src/stories/SessionsPage.stories.tsx","./src/stories/snapshot-cases":"./src/stories/snapshot-cases.ts","./src/stories/storybook-ready":"./src/stories/storybook-ready.ts"}, + "exports": {"./src/components/home/dashboard-insights":"./src/components/home/dashboard-insights.ts","./src/components/ai-chat/resumable-chat":"./src/components/ai-chat/resumable-chat.ts","./src/components/ai-chat/token-usage-format":"./src/components/ai-chat/token-usage-format.ts","./src/components/ai/analysis-display-data":"./src/components/ai/analysis-display-data.ts","./src/components/analyse/chart-data":"./src/components/analyse/chart-data.ts","./src/components/analyse/track-map/types":"./src/components/analyse/track-map/types.ts","./src/components/connection-status-logic":"./src/components/connection-status-logic.ts","./src/components/tune/browser/buildRows":"./src/components/tune/browser/buildRows.ts","./src/components/tunes/semantic-tune":"./src/components/tunes/semantic-tune.ts","./src/components/tunes/track-focus/TrackFocusZoom":"./src/components/tunes/track-focus/TrackFocusZoom.tsx","./src/lib/canvas/draw-track":"./src/lib/canvas/draw-track.ts","./src/hooks/websocket-url":"./src/hooks/websocket-url.ts","./src/lib/aligned-telemetry-fidelity":"./src/lib/aligned-telemetry-fidelity.ts","./src/lib/comparison-utils":"./src/lib/comparison-utils.ts","./src/lib/game-routes":"./src/lib/game-routes.ts","./src/lib/is-ai-configured":"./src/lib/is-ai-configured.ts","./src/lib/live-telemetry-view":"./src/lib/live-telemetry-view.ts","./src/lib/reprocess-state":"./src/lib/reprocess-state.ts","./src/lib/rpc-error":"./src/lib/rpc-error.ts","./src/lib/stint-traces":"./src/lib/stint-traces.ts","./src/lib/track-display-name":"./src/lib/track-display-name.ts","./src/stores/game":"./src/stores/game.ts","./src/stories/SessionsPage.stories":"./src/stories/SessionsPage.stories.tsx","./src/stories/snapshot-cases":"./src/stories/snapshot-cases.ts","./src/stories/storybook-ready":"./src/stories/storybook-ready.ts"}, "scripts": { "dev": "vite", "test": "bun run i18n:compile && bun test ./test", @@ -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/TelemetryChart.tsx b/client/src/components/TelemetryChart.tsx index 9ed545a4b..49a4856ef 100644 --- a/client/src/components/TelemetryChart.tsx +++ b/client/src/components/TelemetryChart.tsx @@ -113,7 +113,7 @@ export function TelemetryChart({ data, syncKey, height = 200, title, fillColors, scales: { x: { time: false }, }, - axes: [axis(), axis()], + axes: [axis(), { ...axis(), values: () => [], size: 0, ticks: { show: false } }], series, hooks: { ready: [ diff --git a/client/src/components/analyse/AiPanelMenu.tsx b/client/src/components/analyse/AiPanelMenu.tsx index 0f226eacf..14d64ec9e 100644 --- a/client/src/components/analyse/AiPanelMenu.tsx +++ b/client/src/components/analyse/AiPanelMenu.tsx @@ -1,7 +1,7 @@ import { Settings2 } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; import { m } from "@/paraglide/messages"; import { Button } from "../ui/button"; +import { DropdownMenu } from "../ui/DropdownMenu"; interface AiPanelMenuProps { onClearChat: () => void; @@ -10,56 +10,18 @@ interface AiPanelMenuProps { } export function AiPanelMenu({ onClearChat, onClearAnalysis, onClearAll }: AiPanelMenuProps) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - - // Close on outside click - useEffect(() => { - if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [open]); - return ( -
- - {open && ( -
- - -
- -
- )} -
+ + + + } + items={[ + { key: "chat", label: m.aipanel_clear_chat_only(), onClick: onClearChat }, + { key: "analysis", label: m.aipanel_clear_analysis_keep_chat(), onClick: onClearAnalysis }, + { key: "all", label: m.aipanel_clear_all(), onClick: onClearAll, className: "text-status-danger" }, + ]} + /> ); } diff --git a/client/src/components/analyse/AnalyseAiSidebar.tsx b/client/src/components/analyse/AnalyseAiSidebar.tsx index fff4b95c0..894c9fcd8 100644 --- a/client/src/components/analyse/AnalyseAiSidebar.tsx +++ b/client/src/components/analyse/AnalyseAiSidebar.tsx @@ -1,31 +1,26 @@ -import { Sparkles } from "lucide-react"; import type { RefObject } from "react"; +import { createPortal } from "react-dom"; import { AiPanel, type AiPanelHandle } from "@/components/ai/AiPanel"; -import type { AnalysisHighlight } from "@/components/ai/analysis-types"; -import { m } from "../../paraglide/messages"; import { AiPanelMenu } from "./AiPanelMenu"; interface AnalyseAiSidebarProps { lapId: number; + headerTarget: HTMLDivElement | null; carName: string; trackName: string; segments: { type: string; name: string; startFrac: number; endFrac: number }[] | null; aiPanelRef: RefObject; onJumpToFrac: (frac: number) => void; - onHighlightsChange: (highlights: AnalysisHighlight[] | null) => void; } -export function AnalyseAiSidebar({ lapId, carName, trackName, segments, aiPanelRef, onJumpToFrac, onHighlightsChange }: AnalyseAiSidebarProps) { +export function AnalyseAiSidebar({ lapId, headerTarget, carName, trackName, segments, aiPanelRef, onJumpToFrac }: AnalyseAiSidebarProps) { return ( -
-
-
- - {m.analyse_ai_analysis()} -
- aiPanelRef.current?.clearChat()} onClearAnalysis={() => aiPanelRef.current?.clearAnalysis()} onClearAll={() => aiPanelRef.current?.clearAll()} /> -
- +
+ {headerTarget && createPortal( + aiPanelRef.current?.clearChat()} onClearAnalysis={() => aiPanelRef.current?.clearAnalysis()} onClearAll={() => aiPanelRef.current?.clearAll()} />, + headerTarget, + )} +
); } diff --git a/client/src/components/analyse/AnalyseChartsPanel.tsx b/client/src/components/analyse/AnalyseChartsPanel.tsx deleted file mode 100644 index 46f840373..000000000 --- a/client/src/components/analyse/AnalyseChartsPanel.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { getGame } from "@raceiq/shared/games/registry" -import { resolveAnalysisTelemetry } from "@raceiq/shared/racing/analysis/telemetry-capabilities" -import type { GameId } from "@raceiq/shared/games/ids"; -import { forwardRef, memo, useCallback, useImperativeHandle, useMemo, useRef } from "react"; -import { WHEEL_COLOR_VARS } from "@raceiq/scene-3d/colors"; -import type { SemanticAnalysisFrame } from "@raceiq/scene-3d/semantic-frame"; -import { m } from "../../paraglide/messages"; -import { TelemetryChart } from "./AnalyseTelemetryChart"; -import { useUnits } from "../../hooks/useUnits"; -import { buildChartData } from "./chart-data"; - - -export interface ChartsPanelHandle { - timeFracs: number[] | null; - times: number[] | null; - updateCursor: (idx: number) => void; -} - -interface ChartsPanelProps { - gameId: GameId; - semanticFrames: SemanticAnalysisFrame[]; - totalPackets: number; - visualTimeFrac: number | null; - onVisualFracChange: (frac: number | null) => void; - onClickIndex: (idx: number) => void; - onScrubStart: () => void; - speedLabel: string; -} - - -export const AnalyseChartsPanel = memo( - forwardRef(function AnalyseChartsPanel( - { semanticFrames, totalPackets, visualTimeFrac, onVisualFracChange, onClickIndex, onScrubStart, speedLabel, gameId }, - ref, - ) { - const units = useUnits(gameId); - const temperatureMetric = resolveAnalysisTelemetry(getGame(gameId)).tireTemperature; - const tireTemperatureSemanticId = temperatureMetric.source !== "unavailable" && temperatureMetric.binding?.kind === "value" - ? temperatureMetric.binding.semanticId - : "tire.temperature.surface.representative"; - const chartData = useMemo( - () => buildChartData(semanticFrames, tireTemperatureSemanticId, units.temp), - [semanticFrames, tireTemperatureSemanticId, units.temp], - ); - const scrollRef = useRef(null); - const cursorLineRef = useRef(null); - const chartDataRef = useRef(chartData); - chartDataRef.current = chartData; - - // Move shared cursor with a DOM style update; canvas resize/redraw per frame - // made chart scrubbing wait behind a full overlay repaint. - const drawSharedCursor = useCallback( - (idx: number) => { - const line = cursorLineRef.current; - const scroll = scrollRef.current; - if (!line || !scroll) return; - - const w = scroll.clientWidth; - const totalPackets = semanticFrames.length; - const timeFracs = chartDataRef.current?.timeFracs; - if (w <= 0 || totalPackets < 2) { - line.style.display = "none"; - return; - } - - const xFrac = timeFracs && idx < timeFracs.length ? timeFracs[idx] : idx / (totalPackets - 1); - const leftPad = 12 + 40; - const rightPad = 12 + 8; - const chartW = w - leftPad - rightPad; - const cx = Math.max(leftPad + 2, leftPad + xFrac * chartW); - line.style.display = ""; - line.style.height = `${scroll.scrollHeight}px`; - line.style.left = `${Math.round(cx)}px`; - }, - [semanticFrames.length], - ); - - useImperativeHandle( - ref, - () => ({ - get timeFracs() { - return chartDataRef.current?.timeFracs ?? null; - }, - get times() { - return chartDataRef.current?.times ?? null; - }, - updateCursor: drawSharedCursor, - }), - [drawSharedCursor], - ); - - if (!chartData) return null; - - const common = { - totalPackets, - timeFracs: chartData.timeFracs, - visualTimeFrac, - onVisualFracChange, - onClickIndex, - onScrubStart, - }; - - 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/AnalyseDynamicsPanel.tsx b/client/src/components/analyse/AnalyseDynamicsPanel.tsx index 052176b30..7cd94cd45 100644 --- a/client/src/components/analyse/AnalyseDynamicsPanel.tsx +++ b/client/src/components/analyse/AnalyseDynamicsPanel.tsx @@ -61,6 +61,9 @@ export function AnalyseDynamicsPanel({ frame, gameId, units }: Props) { const balance = resolveBalance(frame, analysis.balance); const balanceColor = balance?.state === "neutral" ? "var(--balance-neutral)" : balance?.state === "understeer" ? "var(--balance-positive)" : "var(--balance-negative)"; const brakeBias = number(frame, "brakes.brake-bias"); + const migration = (frame.states["diagnostics.migration"] === undefined || frame.states["diagnostics.migration"] === "ok") + && (frame.freshness["diagnostics.migration"] === undefined || frame.freshness["diagnostics.migration"] === "fresh") + ? semanticNumber(frame, "diagnostics.migration") : null; const surfaceValue = number(frame, "identity.player-track-surface"); const puddle = values(frame, "tires.wheel-in-puddle-depth"); const surfaceBinding = analysis.surface.source !== "unavailable" ? analysis.surface.binding : undefined; @@ -149,6 +152,7 @@ export function AnalyseDynamicsPanel({ frame, gameId, units }: Props) { return
{balance ? {balance.state === "neutral" ? "Neutral" : balance.state === "understeer" ? "Understeer" : "Oversteer"}({balance.balance > 0 ? "+" : ""}{balance.balance.toFixed(2)}) : "—"}
{m.analyse_brake_bias()}{brakeBias == null ? unavailable : `${(brakeBias * 100).toFixed(1)}%`}
+ {gameId === "lmu" &&
{m.lmu_channel_diagnostics_migration()}{migration == null ? unavailable : migration.toFixed(0)}
} grip[i] == null ? unavailable : C(`${(grip[i]! * 100).toFixed(0)}%`, frictionUtilColor(grip[i]!))), wheelRow(m.analyse_dynamics_traction(), tractionCell), wheelRow(dualTemperature ? m.label_surface() : (analysis.tireTemperature.source === "direct" && analysis.tireTemperature.freshness === "pit-snapshot" ? m.analyse_wheels_pit_temp() : m.analyse_dynamics_temp()), (i) => temps[i] == null ? unavailable : C(tireTempLabel(temps[i]!, units.thresholds).label, tireTempLabel(temps[i]!, units.thresholds).color)), ...(carcassTemps.some((value) => value != null) ? [wheelRow(m.label_carcass(), (i) => carcassTemps[i] == null ? unavailable : C(tireTempLabel(carcassTemps[i]!, units.thresholds).label, tireTempLabel(carcassTemps[i]!, units.thresholds).color))] : []), ...(coreTemps.some((value) => value != null) && dualTemperature ? [wheelRow(m.label_core(), (i) => coreTemps[i] == null ? unavailable : C(tireTempLabel(coreTemps[i]!, units.thresholds).label, tireTempLabel(coreTemps[i]!, units.thresholds).color))] : []), ...(surfaceBinding?.kind === "group" ? [wheelRow(m.analyse_dynamics_surface(), surfaceCell)] : [])]} /> {surfaceBinding?.kind === "value" &&
{m.analyse_dynamics_surface()}{surfaceText}
} {m.label_slip()}