From cec9e14429a4fb9c46513920c33ff3f343749dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:29:01 +0000 Subject: [PATCH 01/46] plan 0022: drag run detector + drag-data gate A GPS log from a drag strip matches no known track, and worse, the return road loops back near the staging lanes so waypoint mode falsely detects out-and-back passes as laps. This adds the pure detection layer for drag mode: a staged/launch/run state machine that finds standing-start passes, times every traditional mark (60/330/660/1000/1320 ft) with sub-sample interpolation, and gates on "at least one straight, fast 660" so unknown circuits and autocross keep falling through to waypoint mode. Orchestration and UI land in follow-up commits on this plan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g --- CHANGELOG.md | 11 + docs/plans/0022-drag-mode.md | 136 ++++++++++++ src/lib/dragRunDetection.test.ts | 352 +++++++++++++++++++++++++++++++ src/lib/dragRunDetection.ts | 313 +++++++++++++++++++++++++++ 4 files changed, 812 insertions(+) create mode 100644 docs/plans/0022-drag-mode.md create mode 100644 src/lib/dragRunDetection.test.ts create mode 100644 src/lib/dragRunDetection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af7c3de4..2ee370a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > from git history and grouped by theme rather than exhaustive per-commit > detail. +## [4.2.0] - unreleased + +### Added +- **Drag mode** — drop in a log from a drag strip (or any venue the app doesn't + know) and standing-start runs are detected automatically: pick 1/8 mile, + 1000 ft or 1/4 mile and every pass gets a time-slip style breakdown — ET at + the scoring distance plus 60 ft / 330 ft / 660 ft / 1000 ft splits. Passes + that lifted early stay listed as incomplete with the splits they did reach, + and your chosen distance is remembered per file and switchable from the + session header. + ## [4.1.0] - 2026-08-24 ### Added diff --git a/docs/plans/0022-drag-mode.md b/docs/plans/0022-drag-mode.md new file mode 100644 index 00000000..3996d742 --- /dev/null +++ b/docs/plans/0022-drag-mode.md @@ -0,0 +1,136 @@ +# Drag Mode — standing-start runs at unknown venues + +> Status: **IN PROGRESS** — detector landed first (logic-first, like plan 0015); +> orchestration and UI follow in separate commits on this plan. + +## Why this exists + +A GPS log from a drag strip matches no known track — and today that's *worse* +than untimed. The return road loops back within `WAYPOINT_RETURN_RADIUS_M = 30` +of the staging area, so `createWaypointResult` (`src/lib/courseDetection.ts`) +**falsely detects out-and-back passes as "laps"**; when it doesn't, detection +returns `null` and the user gets the dead-end "No track detected" dialog with +zero timing. Drag racers get either garbage laps or nothing. + +Drag mode detects standing-start runs (stopped → launch → straight-line pull), +lets the user pick a scoring distance — **1/8 mi (660 ft), 1000 ft, +1/4 mi (1320 ft)** — and produces time-slip-style results: ET at the scoring +distance plus every intermediate split (60 / 330 / 660 / 1000 ft). + +Product decisions (owner-approved): + +1. **Detect + prompt** — the drag detector runs when no track matches; if runs + are found, `TrackPromptDialog` shows a "Drag runs detected" branch with the + distance picker, pre-selected from the actual run lengths. Waypoint mode and + Create Track stay available as escapes. +2. **Full time slip** — splits ride in `Lap.sectorTimes`/`sectorBoundaries` so + existing sector UI works. +3. **Short runs shown as incomplete** — a pass that lifted early stays listed + with the splits it reached, no final ET, and is **never ranked fastest** + (its window duration can beat a real ET), while its partial splits DO feed + the optimal calc (best 60 ft of the day counts from any pass). +4. Distance choice persists per file (`FileMetadata.dragDistanceFt`) and is + switchable mid-session from the header. + +Modeled on sprint mode (plan 0015): runs are plain `Lap[]` so the map, charts, +playback and lap table work unchanged; only labels that would be untrue fork. +**No `'drag'` CourseType, no synthetic Track/Course** — drag timing is +distance-based, not line-based. `selection` stays `null`, so course-gated +features (snapshots, overlays, leaderboards) stay inert. + +## Design + +### Detector — `src/lib/dragRunDetection.ts` (pure, fully unit-tested) + +Batch state machine over `GpsSample[]`: `idle → staged → launching → running`. + +- **Staged**: speed ≤ `STAGE_SPEED_MPH` (2) continuously for `MIN_STAGED_MS` + (3 s). Samples in (2, 3] mph are neutral — they pause the staged clock + without disarming (deep-stage creep). +- **Launch / t0**: the first sample above `LAUNCH_MOTION_MPH` (3) after a valid + stage. Its `t` is t0 and its position is **distance zero** — anchoring at the + launch sample makes staged GPS wander structurally harmless (nothing + accumulates before launch) and is deterministic where interpolating "speed + crosses zero" is noise-dominated. It starts the clock a hair late, loosely + mimicking real strip rollout. *Our ET approximates strip timing; it is not + sanctioned.* +- **Confirm** (the burnout / re-stage rule): motion must reach + `LAUNCH_CONFIRM_MPH` (15) within `LAUNCH_CONFIRM_MS` (3 s) or the candidate + is discarded and a fresh stage is required — so the scored launch is always + the *last* stage before a confirmed acceleration, the same rule + `pairSprintRuns` uses for sprint re-launches. +- **Running**: `calculateDistanceArray` over the run slice + (`src/lib/referenceUtils.ts`); marks at `DRAG_MARKS_FT = [60, 330, 660, + 1000, 1320]` scored via `interpolateSampleByDistance` (sub-sample-accurate + `t` → split, interpolated speed → trap readout). Lift = max smoothed speed + (3-sample window, detection only); a mark ≤ `MARK_GRACE_M` (10 m) past the + lift still scores; marks rolled through while coasting do **not** score — + that's what keeps "lifted at the 660, coasted through the 1320" an + incomplete quarter. Run window ends below `RUN_END_SPEED_MPH` (5), capped + `RUN_TAIL_MAX_MS` (10 s) past the lift. +- **Run filter**: must score 60 ft and peak ≥ `MIN_RUN_PEAK_MPH` (30). +- **"Is this drag data?" gate** (inside the detector): the result is `null` + unless ≥ 1 run scores the 660 ft mark with straight-line ratio + ≥ `STRAIGHTNESS_MIN` (0.95, net displacement ÷ path distance over + launch→660) and speed there ≥ `MIN_EIGHTH_SPEED_MPH` (40). Curvy autocross + and slow parades fall through to waypoint mode. Because the gate demands a + 660, `suggestedDistanceFt` (longest standard distance completed by ≥ 1 run) + always exists. + +### Run → Lap mapping (same module) + +`dragRunsToLaps(samples, runs, distanceFt)`. Marks for a distance = +`DRAG_MARKS_FT` up to it; the last is the scoring line. + +- **Complete run**: `lapTimeMs` = ET at the scoring mark, `endIndex` = the + mark's sample; `sectorTimes` = per-segment deltas, `sectorBoundaries` = + `[launchIndex, …mark indices…]` (circuit `buildLap` contract). Top speed over + the window ≈ speed at the stripe, so the existing Top Speed column doubles + as trap speed (no new column in v1). +- **Incomplete run**: `lapTimeMs` = detector window duration (NOT an ET), + arrays same length with `undefined` past the last scored mark — + `calculateOptimalLap` already skips `undefined` per segment, so partial + splits feed optimal for free. Marked with the new **`Lap.incomplete`** flag; + every fastest-picking site skips it (`fastestRankedLap` in + `lapCalculation.ts` + guards in `LapTable`, `useReferenceLap`, + `useDataLoader`, `calculateOptimalLap.deltaToFastest`, + `Index.selectedLapTimeMs`). + +### Orchestration — `src/hooks/useDataLoader.ts` only + +Drag runs **before** a waypoint result is accepted (that ordering IS the +false-positive fix); `autoDetectCourse` and `CourseDetectionResult` are +untouched (Simulator + realtimeTimer callers must not grow drag behavior). +Restore: `FileMetadata.dragDistanceFt` (feet-as-number, matching `lengthFt` +convention) re-runs the detector and re-maps silently; a real track/course +restore shadows a stale drag field; assigning a course clears the field. +Distance switching is a pure re-map of the held `DragRun[]` — no re-detection. + +### UI + +`TrackPromptDialog` precedence: course-step > drag > waypoint > no-track (title +forks too). Header gets a distance `Select` next to the compact TrackEditor, +visible only in drag mode. `LapTable` renders cumulative time-slip columns +(via `dragTimeSlipTimes`), an Incomplete badge, Run labels through the +existing `lapLabels` plumbing, Min Speed hidden (always ~0 at launch), footer +Best ET. `SessionContext` carries `dragSession` + `onSetDragDistance`. +Mark labels stay imperial in v1 — drag increments are defined in feet; a +metric follow-up can localize them. + +## Follow-ups (out of scope for v1) + +- Per-mark trap-speed row (the detector already records interpolated speed at + every mark in `DragMarkCrossing.speedMph`). +- Metric display for mark labels. +- Live drag timing on the phone lap timer (`lib/gps/realtimeTimer.ts` is + circuit-only today). +- 1/16 mi (330 ft) scoring distance for junior classes — `DRAG_DISTANCES_FT` + and the `FileMetadata.dragDistanceFt` number field need no schema change. + +## Commits on this plan + +1. `plan 0022:` detector + drag-data gate (`dragRunDetection.ts` + tests) +2. `plan 0022:` run→lap mapping + ranking guards (`Lap.incomplete`, + `dragRunsToLaps`, `fastestRankedLap`, optimal guard) +3. `plan 0022:` orchestration + persistence (useDataLoader, FileMetadata) +4. `plan 0022:` UI + i18n (prompt branch, header switcher, LapTable, locales) diff --git a/src/lib/dragRunDetection.test.ts b/src/lib/dragRunDetection.test.ts new file mode 100644 index 00000000..747b455d --- /dev/null +++ b/src/lib/dragRunDetection.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect } from "vitest"; +import { + detectDragRuns, + DRAG_MARKS_FT, + LAUNCH_MOTION_MPH, + MIN_RUN_PEAK_MPH, +} from "./dragRunDetection"; +import { autoDetectCourse } from "./courseDetection"; +import { speedTriple, EARTH_RADIUS_M } from "./parserUtils"; +import type { GpsSample, Track } from "@/types/racing"; + +// ─── Trace builder ─────────────────────────────────────────────────────────── +// +// 10 Hz synthetic GPS traces around (0, 0), positions in local meters +// (x = east, y = north) converted through the same equirectangular scale the +// production distance math uses, so distances round-trip exactly. + +const DT_MS = 100; +const M_PER_DEG = (Math.PI / 180) * EARTH_RADIUS_M; +/** Reported speed while staged: ~0.5 mph of Doppler wander, below STAGE_SPEED_MPH. */ +const IDLE_MPS = 0.22; + +function makeTrace() { + const samples: GpsSample[] = []; + const state = { t: 0, x: 0, y: 0, heading: 0, v: 0 }; + + const emit = (v = state.v, x = state.x, y = state.y) => { + samples.push({ t: state.t, lat: y / M_PER_DEG, lon: x / M_PER_DEG, ...speedTriple(v), extraFields: {} }); + }; + + const api = { + samples, + state, + /** Stationary staging. Optional position jitter simulates GPS wander; the last sample returns to base. */ + idle(seconds: number, jitterM = 0) { + const baseX = state.x; + const baseY = state.y; + state.v = IDLE_MPS; + const n = Math.round((seconds * 1000) / DT_MS); + for (let k = 0; k < n; k++) { + state.t += DT_MS; + const last = k === n - 1; + const jx = last ? 0 : jitterM * Math.sin(k * 0.7); + const jy = last ? 0 : jitterM * Math.cos(k * 1.3); + emit(IDLE_MPS, baseX + jx, baseY + jy); + } + state.x = baseX; + state.y = baseY; + }, + /** Constant acceleration until `distM` of travel; curves at `radiusM` when finite (clockwise). */ + accelerate(aMps2: number, distM: number, radiusM = Infinity) { + let traveled = 0; + while (traveled < distM) { + const v0 = state.v; + state.v = Math.max(0, v0 + (aMps2 * DT_MS) / 1000); + const d = ((v0 + state.v) / 2) * (DT_MS / 1000); + if (isFinite(radiusM)) state.heading += d / radiusM; + state.x += Math.sin(state.heading) * d; + state.y += Math.cos(state.heading) * d; + state.t += DT_MS; + traveled += d; + emit(); + } + }, + /** Constant deceleration down to `targetMps`. */ + decelerate(aMps2: number, targetMps: number) { + while (state.v > targetMps) { + const v0 = state.v; + state.v = Math.max(targetMps, v0 - (aMps2 * DT_MS) / 1000); + const d = ((v0 + state.v) / 2) * (DT_MS / 1000); + state.x += Math.sin(state.heading) * d; + state.y += Math.cos(state.heading) * d; + state.t += DT_MS; + emit(); + } + }, + /** Constant-speed travel for `distM`; curves at `radiusM` when finite (clockwise). */ + cruise(speedMps: number, distM: number, radiusM = Infinity) { + state.v = speedMps; + let traveled = 0; + while (traveled < distM) { + const d = speedMps * (DT_MS / 1000); + if (isFinite(radiusM)) state.heading += d / radiusM; + state.x += Math.sin(state.heading) * d; + state.y += Math.cos(state.heading) * d; + state.t += DT_MS; + traveled += d; + emit(); + } + }, + /** A constant-radius turn through `deg` degrees (clockwise positive). */ + turn(radiusM: number, deg: number, speedMps: number) { + api.cruise(speedMps, (Math.abs(deg) * Math.PI / 180) * radiusM, radiusM * Math.sign(deg)); + }, + }; + return api; +} + +/** Stage → full quarter-mile pull at `a` m/s² → brake hard to a near-stop. */ +function quarterPass(tr: ReturnType, a = 4) { + tr.idle(5); + tr.accelerate(a, 403); + tr.decelerate(8, 0.1); +} + +// A distant track so autoDetectCourse takes its no-nearby-track (waypoint) path +// instead of bailing on an empty track list. +const farTrack: Track = { + name: "Far Away", + courses: [ + { + name: "Main", + startFinishA: { lat: 1, lon: 0 }, + startFinishB: { lat: 1.0001, lon: 0 }, + isUserDefined: false, + }, + ], + isUserDefined: false, +}; + +// ─── Degenerate inputs ─────────────────────────────────────────────────────── + +describe("detectDragRuns - degenerate inputs", () => { + it("returns null for empty samples", () => { + expect(detectDragRuns([])).toBeNull(); + }); + + it("returns null for a fully stationary trace", () => { + const tr = makeTrace(); + tr.idle(60, 1); + expect(detectDragRuns(tr.samples)).toBeNull(); + }); +}); + +// ─── Clean pass ────────────────────────────────────────────────────────────── + +describe("detectDragRuns - clean quarter pass", () => { + const tr = makeTrace(); + quarterPass(tr); + tr.idle(5); + const result = detectDragRuns(tr.samples); + + it("detects exactly one run with all five marks", () => { + expect(result).not.toBeNull(); + expect(result!.runs).toHaveLength(1); + expect(result!.runs[0].marks.map((m) => m.markFt)).toEqual([...DRAG_MARKS_FT]); + }); + + it("suggests the quarter mile", () => { + expect(result!.suggestedDistanceFt).toBe(1320); + }); + + it("mark times and speeds ascend", () => { + const marks = result!.runs[0].marks; + for (let i = 1; i < marks.length; i++) { + expect(marks[i].elapsedMs).toBeGreaterThan(marks[i - 1].elapsedMs); + expect(marks[i].speedMph).toBeGreaterThan(marks[i - 1].speedMph); + } + }); + + it("times match constant-acceleration physics (a = 4 m/s²)", () => { + const byFt = Object.fromEntries(result!.runs[0].marks.map((m) => [m.markFt, m])); + // d = ½at² minus the sub-second t0/anchor offset ⇒ generous ±0.4 s bands. + expect(byFt[60].elapsedMs).toBeGreaterThan(2400); + expect(byFt[60].elapsedMs).toBeLessThan(3100); + expect(byFt[660].elapsedMs).toBeGreaterThan(9400); + expect(byFt[660].elapsedMs).toBeLessThan(10100); + expect(byFt[1320].elapsedMs).toBeGreaterThan(13500); + expect(byFt[1320].elapsedMs).toBeLessThan(14300); + // Trap ≈ a·t ≈ 127 mph at the stripe. + expect(byFt[1320].speedMph).toBeGreaterThan(120); + expect(byFt[1320].speedMph).toBeLessThan(133); + }); + + it("each mark's interpolated time falls between its bracketing samples", () => { + const run = result!.runs[0]; + for (const mark of run.marks) { + const upper = tr.samples[mark.sampleIndex].t - run.t0; + const lower = tr.samples[mark.sampleIndex - 1].t - run.t0; + expect(mark.elapsedMs).toBeLessThanOrEqual(upper); + expect(mark.elapsedMs).toBeGreaterThanOrEqual(lower); + } + }); + + it("launches from the first confident-motion sample", () => { + const run = result!.runs[0]; + expect(tr.samples[run.launchIndex].speedMph).toBeGreaterThan(LAUNCH_MOTION_MPH); + expect(tr.samples[run.launchIndex - 1].speedMph).toBeLessThanOrEqual(LAUNCH_MOTION_MPH); + }); +}); + +// ─── Burnout / re-stage ────────────────────────────────────────────────────── + +describe("detectDragRuns - burnout then re-stage", () => { + it("anchors the single run at the last stage before the confirmed launch", () => { + const tr = makeTrace(); + tr.idle(5); + // Burnout roll-through: 6 mph for ~2 s never confirms as a launch. + tr.cruise(2.7, 5.4); + tr.decelerate(3, 0.1); + tr.idle(5); + const restageEndT = tr.state.t; + tr.accelerate(4, 403); + tr.decelerate(8, 0.1); + tr.idle(3); + + const result = detectDragRuns(tr.samples); + expect(result).not.toBeNull(); + expect(result!.runs).toHaveLength(1); + // t0 is after the second staging period, not the burnout. + expect(result!.runs[0].t0).toBeGreaterThanOrEqual(restageEndT); + }); +}); + +// ─── GPS wander while staged ───────────────────────────────────────────────── + +describe("detectDragRuns - staged GPS wander", () => { + it("position jitter before launch does not leak into the 60 ft time", () => { + const clean = makeTrace(); + clean.idle(60); + clean.accelerate(4, 403); + clean.decelerate(8, 0.1); + + const jittered = makeTrace(); + jittered.idle(60, 1); // ±1 m of wander for a full minute + jittered.accelerate(4, 403); + jittered.decelerate(8, 0.1); + + const cleanRun = detectDragRuns(clean.samples)!.runs[0]; + const jitteredRun = detectDragRuns(jittered.samples)!.runs[0]; + const clean60 = cleanRun.marks.find((m) => m.markFt === 60)!.elapsedMs; + const jittered60 = jitteredRun.marks.find((m) => m.markFt === 60)!.elapsedMs; + expect(Math.abs(jittered60 - clean60)).toBeLessThanOrEqual(200); + }); +}); + +// ─── Back-to-back passes with a return road ────────────────────────────────── + +function backToBackTrace() { + const tr = makeTrace(); + for (let pass = 0; pass < 2; pass++) { + tr.idle(10); + tr.accelerate(4, 403); + tr.decelerate(8, 10); // chute out, down to 10 m/s + tr.turn(10, 180, 8); // turnaround, now heading south offset ~20 m east + tr.cruise(10, 590); // return road, above RUN_END but below 30 mph + tr.turn(10, 180, 8); // back onto the strip heading north + tr.decelerate(3, 0.1); + } + tr.idle(3); + return tr; +} + +describe("detectDragRuns - back-to-back passes", () => { + it("finds both passes and nothing on the return road", () => { + const result = detectDragRuns(backToBackTrace().samples); + expect(result).not.toBeNull(); + expect(result!.runs).toHaveLength(2); + expect(result!.runs.map((r) => r.runNumber)).toEqual([1, 2]); + expect(result!.runs[1].t0).toBeGreaterThan(result!.runs[0].t0); + for (const run of result!.runs) { + expect(run.marks.map((m) => m.markFt)).toEqual([...DRAG_MARKS_FT]); + } + }); +}); + +// ─── Aborted / coast-through passes ────────────────────────────────────────── + +describe("detectDragRuns - aborted pass", () => { + it("scores only the marks reached under power", () => { + const tr = makeTrace(); + quarterPass(tr); // complete sibling keeps the session gated in + tr.idle(10); + tr.accelerate(4, 152); // lift at ~500 ft + tr.decelerate(3, 0.1); + tr.idle(3); + + const result = detectDragRuns(tr.samples); + expect(result).not.toBeNull(); + expect(result!.runs).toHaveLength(2); + const aborted = result!.runs[1]; + expect(aborted.marks.map((m) => m.markFt)).toEqual([60, 330]); + expect(aborted.maxScoredFt).toBeGreaterThan(450); + expect(aborted.maxScoredFt).toBeLessThan(560); + }); + + it("does not score marks rolled through while coasting", () => { + const tr = makeTrace(); + tr.idle(5); + tr.accelerate(4, 202); // pull to just past the 660 mark… + tr.accelerate(-2, 250); // …then coast/decelerate through 1000 and 1320 + tr.decelerate(3, 0.1); + tr.idle(3); + + const result = detectDragRuns(tr.samples); + expect(result).not.toBeNull(); + expect(result!.runs).toHaveLength(1); + expect(result!.runs[0].marks.map((m) => m.markFt)).toEqual([60, 330, 660]); + expect(result!.suggestedDistanceFt).toBe(660); + }); +}); + +// ─── Why ordering vs waypoint mode matters ─────────────────────────────────── + +describe("detectDragRuns - waypoint-mode false positive", () => { + it("a drag session waypoint mode would mis-time is detected as drag runs", () => { + const samples = backToBackTrace().samples; + // The return road passes ~20 m from the first ≥30 mph point, so waypoint + // detection happily makes "laps" out of drag passes… + const waypoint = autoDetectCourse(samples, [farTrack]); + expect(waypoint).not.toBeNull(); + expect(waypoint!.isWaypointMode).toBe(true); + // …which is why useDataLoader must consult detectDragRuns FIRST. + const drag = detectDragRuns(samples); + expect(drag).not.toBeNull(); + expect(drag!.runs).toHaveLength(2); + }); +}); + +// ─── The "is this drag data?" gate ─────────────────────────────────────────── + +describe("detectDragRuns - gate", () => { + it("rejects a curvy autocross-style run (straightness)", () => { + const tr = makeTrace(); + tr.idle(5); + tr.accelerate(4, 250, 100); // hard launch but on a 100 m-radius arc + tr.decelerate(3, 0.1); + tr.idle(3); + expect(detectDragRuns(tr.samples)).toBeNull(); + }); + + it("rejects a straight but slow roll (speed floor at the 660)", () => { + const tr = makeTrace(); + tr.idle(5); + tr.accelerate(3, 40); // brisk enough to confirm as a launch, tops out ~35 mph… + tr.cruise(15.5, 700); // …held straight past the 660 + tr.decelerate(3, 0.1); + tr.idle(3); + // Peak clears MIN_RUN_PEAK_MPH but the 660 comes up short of 40 mph. + expect(15.5 * 2.23694).toBeGreaterThan(MIN_RUN_PEAK_MPH); + expect(detectDragRuns(tr.samples)).toBeNull(); + }); + + it("rejects a staging-lane creep that never becomes a pass", () => { + const tr = makeTrace(); + tr.idle(10); + tr.cruise(4, 120); // ~9 mph pit roll + tr.decelerate(3, 0.1); + tr.idle(10); + expect(detectDragRuns(tr.samples)).toBeNull(); + }); +}); diff --git a/src/lib/dragRunDetection.ts b/src/lib/dragRunDetection.ts new file mode 100644 index 00000000..274a62a4 --- /dev/null +++ b/src/lib/dragRunDetection.ts @@ -0,0 +1,313 @@ +/** + * Drag-run detection: find standing-start straight-line passes ("runs") in a + * GPS log from an unknown venue, and score them at the traditional drag + * distances (60 / 330 / 660 / 1000 / 1320 ft). + * + * Why this exists: a drag strip is never in the track database, and the return + * road loops back near the staging lanes — close enough that waypoint mode + * (courseDetection) happily "detects" out-and-back passes as laps. The caller + * (useDataLoader) therefore runs this detector BEFORE accepting a waypoint + * result; the gate at the bottom of `detectDragRuns` is what keeps unknown + * circuits and autocross runs falling through to waypoint mode. + * + * Timing model: t0 is the first sample above LAUNCH_MOTION_MPH after a valid + * staged period, and that sample's position is distance zero. Interpolating + * the true "speed leaves zero" instant is noise-dominated (a parked GPS reads + * 0–2 mph of Doppler wander), while the first confident-motion sample is + * deterministic and reproducible — and starting the clock a fraction late + * loosely mimics real strip rollout. The resulting ET approximates a track + * time slip; it is not sanctioned timing. + */ + +import type { GpsSample } from "@/types/racing"; +import { calculateDistanceArray, interpolateSampleByDistance } from "./referenceUtils"; +import { haversineDistance } from "./parserUtils"; +import { METERS_PER_FOOT, FEET_PER_METER } from "./units"; + +// ─── Tunables ──────────────────────────────────────────────────────────────── + +/** "Stopped" amid GPS Doppler wander (sessionGate treats <1 mph as stopped; +1 tolerates staging creep). */ +export const STAGE_SPEED_MPH = 2; +/** Must hold at/below STAGE_SPEED_MPH this long (cumulative) to arm a launch. */ +export const MIN_STAGED_MS = 3000; +/** First sample above this after a valid stage = t0 and the distance-zero anchor. */ +export const LAUNCH_MOTION_MPH = 3; +/** Motion must reach this speed… */ +export const LAUNCH_CONFIRM_MPH = 15; +/** …within this window, or the candidate was staging creep / a burnout roll, not a launch. */ +export const LAUNCH_CONFIRM_MS = 3000; +/** The run window closes when speed falls back below this. */ +export const RUN_END_SPEED_MPH = 5; +/** Hard cap on the window past the lift point, so return-road driving can't stretch an aborted run. */ +export const RUN_TAIL_MAX_MS = 10000; +/** Moving-average window (samples) used for lift/peak detection only — raw speeds are reported. */ +export const SPEED_SMOOTH_WINDOW = 3; +/** A mark this close past the lift point still scores (driver lifted exactly at the stripe). */ +export const MARK_GRACE_M = 10; +/** A candidate run must peak at/above this (and score 60 ft) or it was a pit/staging-lane roll. */ +export const MIN_RUN_PEAK_MPH = 30; +/** Gate: net displacement ÷ path distance over launch→660 ft. A real strip is >0.99. */ +export const STRAIGHTNESS_MIN = 0.95; +/** Gate: speed floor at the 660 ft mark — filters slow parades that happen to run straight. */ +export const MIN_EIGHTH_SPEED_MPH = 40; + +/** Every mark a time slip prints, in feet, ascending. */ +export const DRAG_MARKS_FT = [60, 330, 660, 1000, 1320] as const; +/** The distances a user can score a session at: 1/8 mi, 1000 ft, 1/4 mi. */ +export const DRAG_DISTANCES_FT = [660, 1000, 1320] as const; + +export type DragDistanceFt = (typeof DRAG_DISTANCES_FT)[number]; + +/** Validate an untrusted value (e.g. from FileMetadata) as a scoring distance. */ +export function isDragDistanceFt(value: unknown): value is DragDistanceFt { + return (DRAG_DISTANCES_FT as readonly number[]).includes(value as number); +} + +// ─── Result shapes ─────────────────────────────────────────────────────────── + +/** One scored mark crossing within a run, timed under power (not coast-through). */ +export interface DragMarkCrossing { + /** One of DRAG_MARKS_FT. */ + markFt: number; + /** Interpolated time from t0 to the mark. */ + elapsedMs: number; + /** Absolute index of the first sample at/past the mark. */ + sampleIndex: number; + /** Interpolated speed at the mark (trap-style readout). */ + speedMph: number; + speedKph: number; +} + +/** One standing-start pass. Marks are ascending and only include distances covered under power. */ +export interface DragRun { + /** 1-based, chronological. */ + runNumber: number; + /** Absolute index of the launch sample; t0 = samples[launchIndex].t, distance 0 = its position. */ + launchIndex: number; + t0: number; + /** Absolute index of the end of the run's data window (lift/stop), NOT a timing line. */ + endIndex: number; + marks: DragMarkCrossing[]; + /** Distance covered under power (at the lift point), in feet. */ + maxScoredFt: number; + peakSpeedMph: number; + peakSpeedKph: number; +} + +export interface DragDetectionResult { + runs: DragRun[]; + /** Longest standard scoring distance completed by at least one run (the gate guarantees ≥ 660). */ + suggestedDistanceFt: DragDistanceFt; +} + +// ─── Detection ─────────────────────────────────────────────────────────────── + +/** Centered moving average of speedMph, for lift/peak location only. */ +function smoothSpeeds(samples: GpsSample[]): number[] { + const half = Math.floor(SPEED_SMOOTH_WINDOW / 2); + const out = new Array(samples.length); + for (let i = 0; i < samples.length; i++) { + let sum = 0; + let n = 0; + for (let j = Math.max(0, i - half); j <= Math.min(samples.length - 1, i + half); j++) { + sum += samples[j].speedMph; + n++; + } + out[i] = sum / n; + } + return out; +} + +interface CandidateRun { + launchIndex: number; + endIndex: number; + marks: DragMarkCrossing[]; + maxScoredFt: number; + peakSpeedMph: number; + peakSpeedKph: number; + /** Net displacement ÷ path distance over launch→660, when the 660 was scored. */ + straightnessAtEighth?: number; + /** Interpolated speed at the 660 mark, when scored. */ + speedAtEighthMph?: number; +} + +/** + * Score one confirmed launch: walk the run window, and time every mark the car + * crossed under power. Returns null when the motion never amounted to a run + * (no 60 ft, or peak below MIN_RUN_PEAK_MPH). + */ +function scoreRun(samples: GpsSample[], smoothed: number[], launchIndex: number, stopIndex: number): CandidateRun | null { + if (stopIndex - launchIndex < 2) return null; + const slice = samples.slice(launchIndex, stopIndex + 1); + const distances = calculateDistanceArray(slice); + const t0 = samples[launchIndex].t; + + // Lift = maximum smoothed speed; everything past it (plus a little grace) was coasting. + let liftLocal = 0; + let peakSmoothed = -Infinity; + let peakMph = -Infinity; + let peakKph = 0; + for (let k = 0; k < slice.length; k++) { + const s = smoothed[launchIndex + k]; + if (s > peakSmoothed) { + peakSmoothed = s; + liftLocal = k; + } + if (slice[k].speedMph > peakMph) { + peakMph = slice[k].speedMph; + peakKph = slice[k].speedKph; + } + } + + if (peakMph < MIN_RUN_PEAK_MPH) return null; + + const scoredThroughM = distances[liftLocal] + MARK_GRACE_M; + const totalM = distances[distances.length - 1]; + + const marks: DragMarkCrossing[] = []; + let straightnessAtEighth: number | undefined; + let speedAtEighthMph: number | undefined; + let searchFrom = 0; + for (const markFt of DRAG_MARKS_FT) { + const markM = markFt * METERS_PER_FOOT; + if (markM > scoredThroughM || markM > totalM) break; + const interp = interpolateSampleByDistance(slice, distances, markM); + while (searchFrom < distances.length && distances[searchFrom] < markM) searchFrom++; + marks.push({ + markFt, + elapsedMs: interp.t - t0, + sampleIndex: launchIndex + Math.min(searchFrom, slice.length - 1), + speedMph: interp.speedMph, + speedKph: interp.speedKph, + }); + if (markFt === 660) { + const launch = slice[0]; + straightnessAtEighth = haversineDistance(launch.lat, launch.lon, interp.lat, interp.lon) / markM; + speedAtEighthMph = interp.speedMph; + } + } + + if (marks.length === 0 || marks[0].markFt !== 60) return null; + + // Data window ends at the stop, capped RUN_TAIL_MAX_MS past the lift. + let endLocal = slice.length - 1; + const liftT = slice[liftLocal].t; + for (let k = liftLocal; k < slice.length; k++) { + if (slice[k].t - liftT > RUN_TAIL_MAX_MS) { + endLocal = k; + break; + } + } + + return { + launchIndex, + endIndex: launchIndex + endLocal, + marks, + maxScoredFt: distances[liftLocal] * FEET_PER_METER, + peakSpeedMph: peakMph, + peakSpeedKph: peakKph, + straightnessAtEighth, + speedAtEighthMph, + }; +} + +/** + * Detect standing-start drag runs in a session. + * + * Returns null unless the data passes the "is this drag data?" gate: at least + * one run must cover the 660 ft mark under power, in a near-straight line + * (STRAIGHTNESS_MIN) and at speed (MIN_EIGHTH_SPEED_MPH) — so unknown circuits + * and autocross sessions keep falling through to waypoint mode. + */ +export function detectDragRuns(samples: GpsSample[]): DragDetectionResult | null { + if (samples.length < 10) return null; + const smoothed = smoothSpeeds(samples); + + const candidates: CandidateRun[] = []; + let stagedMs = 0; + let i = 1; + while (i < samples.length) { + const v = samples[i].speedMph; + if (v <= STAGE_SPEED_MPH) { + // Attribute the interval to stopped time when it connects two stopped samples. + if (samples[i - 1].speedMph <= STAGE_SPEED_MPH) stagedMs += samples[i].t - samples[i - 1].t; + i++; + continue; + } + if (v <= LAUNCH_MOTION_MPH) { + // Neutral band: staging creep pauses the staged clock without disarming. + i++; + continue; + } + + // Motion. Without a full stage behind it, it's just driving. + if (stagedMs < MIN_STAGED_MS) { + stagedMs = 0; + i++; + continue; + } + + // Launch candidate: confirm it reaches LAUNCH_CONFIRM_MPH in time, else it + // was creep / a burnout roll — discard and require a fresh stage, so the + // scored launch is always the LAST stage before a confirmed acceleration + // (the pairSprintRuns re-launch rule). + const t0 = samples[i].t; + let confirmIdx = -1; + for (let j = i; j < samples.length && samples[j].t - t0 <= LAUNCH_CONFIRM_MS; j++) { + if (samples[j].speedMph >= LAUNCH_CONFIRM_MPH) { + confirmIdx = j; + break; + } + } + if (confirmIdx === -1) { + stagedMs = 0; + i++; + continue; + } + + // Confirmed: the run's raw window extends until the car slows back down. + let stopIdx = samples.length - 1; + for (let j = confirmIdx; j < samples.length; j++) { + if (samples[j].speedMph < RUN_END_SPEED_MPH) { + stopIdx = j; + break; + } + } + + const run = scoreRun(samples, smoothed, i, stopIdx); + if (run) candidates.push(run); + + stagedMs = 0; + i = stopIdx + 1; + } + + // Gate: at least one straight, fast 660 or this isn't drag data. + const gatePassed = candidates.some( + (c) => + c.straightnessAtEighth !== undefined && + c.straightnessAtEighth >= STRAIGHTNESS_MIN && + (c.speedAtEighthMph ?? 0) >= MIN_EIGHTH_SPEED_MPH, + ); + if (!gatePassed) return null; + + let suggested: DragDistanceFt = 660; + for (const c of candidates) { + for (const d of DRAG_DISTANCES_FT) { + if (d > suggested && c.marks.some((m) => m.markFt === d)) suggested = d; + } + } + + return { + runs: candidates.map((c, idx) => ({ + runNumber: idx + 1, + launchIndex: c.launchIndex, + t0: samples[c.launchIndex].t, + endIndex: c.endIndex, + marks: c.marks, + maxScoredFt: c.maxScoredFt, + peakSpeedMph: c.peakSpeedMph, + peakSpeedKph: c.peakSpeedKph, + })), + suggestedDistanceFt: suggested, + }; +} From aae49f2ffb6ac2cb4f936323a4c69ee642f24c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:32:02 +0000 Subject: [PATCH 02/46] plan 0022: run-to-lap mapping + ranking guards for incomplete runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dragRunsToLaps scores detected runs at a chosen distance and expresses them as ordinary Laps (splits in sectorTimes/sectorBoundaries under the circuit timing-line contract), so the whole lap UI works unchanged. A run that lifted before the scoring mark keeps its reached splits but is flagged with the new Lap.incomplete: its lapTimeMs is a data window that can undercut a real ET, so ranking goes through the new fastestRankedLap helper and calculateOptimalLap's deltaToFastest now skips incomplete laps — while their partial splits still feed the optimal, since the best 60 ft of the day is valid from any pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g --- src/lib/dragRunDetection.test.ts | 103 +++++++++++++++++++++++++++++++ src/lib/dragRunDetection.ts | 96 +++++++++++++++++++++++++++- src/lib/lapCalculation.test.ts | 34 ++++++++++ src/lib/lapCalculation.ts | 23 ++++++- src/types/racing.ts | 7 +++ 5 files changed, 261 insertions(+), 2 deletions(-) diff --git a/src/lib/dragRunDetection.test.ts b/src/lib/dragRunDetection.test.ts index 747b455d..e21dfe76 100644 --- a/src/lib/dragRunDetection.test.ts +++ b/src/lib/dragRunDetection.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect } from "vitest"; import { detectDragRuns, + dragRunsToLaps, + dragTimeSlipTimes, + isDragDistanceFt, DRAG_MARKS_FT, LAUNCH_MOTION_MPH, MIN_RUN_PEAK_MPH, } from "./dragRunDetection"; +import { fastestRankedLap } from "./lapCalculation"; import { autoDetectCourse } from "./courseDetection"; import { speedTriple, EARTH_RADIUS_M } from "./parserUtils"; import type { GpsSample, Track } from "@/types/racing"; @@ -350,3 +354,102 @@ describe("detectDragRuns - gate", () => { expect(detectDragRuns(tr.samples)).toBeNull(); }); }); + +// ─── Run → Lap mapping ─────────────────────────────────────────────────────── + +describe("dragRunsToLaps", () => { + // One complete quarter pass + one pass aborted around 500 ft. + const tr = makeTrace(); + quarterPass(tr); + tr.idle(10); + tr.accelerate(4, 152); + tr.decelerate(3, 0.1); + tr.idle(3); + const result = detectDragRuns(tr.samples)!; + + it("maps a complete quarter with the full timing-line contract", () => { + const laps = dragRunsToLaps(tr.samples, result.runs, 1320); + const lap = laps[0]; + const run = result.runs[0]; + const final = run.marks.find((m) => m.markFt === 1320)!; + + expect(lap.lapNumber).toBe(1); + expect(lap.incomplete).toBeUndefined(); + expect(lap.startIndex).toBe(run.launchIndex); + expect(lap.startTime).toBe(run.t0); + expect(lap.lapTimeMs).toBe(final.elapsedMs); + expect(lap.endIndex).toBe(final.sampleIndex); + expect(lap.sectorTimes).toHaveLength(5); + expect(lap.sectorBoundaries).toHaveLength(5); + expect(lap.sectorBoundaries![0]).toBe(run.launchIndex); + // Segments sum back to the ET. + const sum = lap.sectorTimes!.reduce((a, b) => a + (b ?? 0), 0); + expect(sum).toBeCloseTo(lap.lapTimeMs, 6); + // The window ends at the stripe, so top speed is the trap-style readout. + expect(lap.maxSpeedMph).toBeGreaterThan(120); + }); + + it("marks a short run incomplete and keeps its reached splits", () => { + const laps = dragRunsToLaps(tr.samples, result.runs, 1320); + const aborted = laps[1]; + const run = result.runs[1]; + + expect(aborted.incomplete).toBe(true); + expect(aborted.endIndex).toBe(run.endIndex); + expect(aborted.lapTimeMs).toBe(tr.samples[run.endIndex].t - run.t0); + expect(aborted.sectorTimes).toHaveLength(5); + expect(aborted.sectorTimes!.slice(0, 2).every((t) => typeof t === "number")).toBe(true); + expect(aborted.sectorTimes!.slice(2)).toEqual([undefined, undefined, undefined]); + expect(aborted.sectorBoundaries).toEqual([ + run.launchIndex, + run.marks[0].sampleIndex, + run.marks[1].sampleIndex, + undefined, + undefined, + ]); + }); + + it("re-scoring at the eighth completes the aborted pass with a shorter ET", () => { + const quarter = dragRunsToLaps(tr.samples, result.runs, 1320); + const eighth = dragRunsToLaps(tr.samples, result.runs, 660); + + expect(eighth[0].sectorTimes).toHaveLength(3); + expect(eighth[0].lapTimeMs).toBeLessThan(quarter[0].lapTimeMs); + expect(eighth[0].lapTimeMs).toBe(result.runs[0].marks.find((m) => m.markFt === 660)!.elapsedMs); + // The 500 ft abort still misses the 660, so it stays incomplete either way. + expect(eighth[1].incomplete).toBe(true); + }); + + it("dragTimeSlipTimes returns cumulative mark times, undefined past the lift", () => { + const laps = dragRunsToLaps(tr.samples, result.runs, 1320); + const complete = dragTimeSlipTimes(laps[0]); + const run = result.runs[0]; + expect(complete).toHaveLength(5); + for (let k = 0; k < 5; k++) { + expect(complete[k]).toBeCloseTo(run.marks[k].elapsedMs, 6); + } + const aborted = dragTimeSlipTimes(laps[1]); + expect(aborted.slice(2)).toEqual([undefined, undefined, undefined]); + }); + + it("fastestRankedLap never picks the incomplete run, even when its window is shorter", () => { + const laps = dragRunsToLaps(tr.samples, result.runs, 1320); + // The abort's data window can genuinely be shorter than the real ET… + const ranked = fastestRankedLap(laps); + expect(ranked).toBe(laps[0]); + // …and with only incomplete laps there is no fastest at all. + expect(fastestRankedLap(laps.filter((l) => l.incomplete))).toBeNull(); + }); +}); + +describe("isDragDistanceFt", () => { + it("accepts exactly the three scoring distances", () => { + expect(isDragDistanceFt(660)).toBe(true); + expect(isDragDistanceFt(1000)).toBe(true); + expect(isDragDistanceFt(1320)).toBe(true); + expect(isDragDistanceFt(60)).toBe(false); + expect(isDragDistanceFt("1320")).toBe(false); + expect(isDragDistanceFt(undefined)).toBe(false); + expect(isDragDistanceFt(null)).toBe(false); + }); +}); diff --git a/src/lib/dragRunDetection.ts b/src/lib/dragRunDetection.ts index 274a62a4..4b053d10 100644 --- a/src/lib/dragRunDetection.ts +++ b/src/lib/dragRunDetection.ts @@ -19,7 +19,7 @@ * time slip; it is not sanctioned timing. */ -import type { GpsSample } from "@/types/racing"; +import type { GpsSample, Lap } from "@/types/racing"; import { calculateDistanceArray, interpolateSampleByDistance } from "./referenceUtils"; import { haversineDistance } from "./parserUtils"; import { METERS_PER_FOOT, FEET_PER_METER } from "./units"; @@ -311,3 +311,97 @@ export function detectDragRuns(samples: GpsSample[]): DragDetectionResult | null suggestedDistanceFt: suggested, }; } + +// ─── Run → Lap mapping ─────────────────────────────────────────────────────── + +/** + * Score detected runs at a distance and express them as ordinary `Lap`s, so the + * whole lap UI (table, map, charts, playback) works unchanged. + * + * Timing lines follow the circuit `buildLap` contract: line 0 is the launch + * (boundary 0 = lap start), lines 1..n-1 are the intermediate marks, and the + * last segment closes on the scoring mark — so for a 1/4-mile session + * `sectorTimes`/`sectorBoundaries` have 5 entries (launch→60, 60→330, 330→660, + * 660→1000, 1000→1320). + * + * A run that scored the final mark is complete: `lapTimeMs` is its ET and + * `endIndex` the sample at the stripe — which also makes the window's top speed + * the trap-style speed at the mark. A run that lifted early keeps its detector + * window and reached splits (`undefined` past the lift, which the optimal-lap + * calc already skips) and is flagged `incomplete` so nothing ranks it fastest. + * + * Marks are scored once at detection; switching distance is just re-mapping. + */ +export function dragRunsToLaps(samples: GpsSample[], runs: DragRun[], distanceFt: DragDistanceFt): Lap[] { + const markFts = DRAG_MARKS_FT.filter((m) => m <= distanceFt); + const laps: Lap[] = []; + + for (const run of runs) { + // Marks score strictly in ascending-prefix order (scoring stops at the lift). + const scored = markFts.map((ft) => run.marks.find((m) => m.markFt === ft)); + const finalMark = scored[markFts.length - 1]; + + const sectorTimes: (number | undefined)[] = []; + const sectorBoundaries: (number | undefined)[] = []; + sectorBoundaries.push(run.launchIndex); + for (let k = 0; k < markFts.length; k++) { + const prevElapsed = k === 0 ? 0 : scored[k - 1]?.elapsedMs; + const mark = scored[k]; + sectorTimes.push(mark !== undefined && prevElapsed !== undefined ? mark.elapsedMs - prevElapsed : undefined); + if (k < markFts.length - 1) sectorBoundaries.push(mark?.sampleIndex); + } + + const endIndex = finalMark ? finalMark.sampleIndex : run.endIndex; + const endTime = finalMark ? run.t0 + finalMark.elapsedMs : samples[run.endIndex].t; + + let maxSpeedMph = 0; + let maxSpeedKph = 0; + let minSpeedMph = Infinity; + let minSpeedKph = Infinity; + for (let i = run.launchIndex; i <= endIndex; i++) { + const s = samples[i]; + if (s.speedMph > maxSpeedMph) { + maxSpeedMph = s.speedMph; + maxSpeedKph = s.speedKph; + } + if (s.speedMph < minSpeedMph) { + minSpeedMph = s.speedMph; + minSpeedKph = s.speedKph; + } + } + + laps.push({ + lapNumber: run.runNumber, + startTime: run.t0, + endTime, + lapTimeMs: endTime - run.t0, + maxSpeedMph, + maxSpeedKph, + minSpeedMph, + minSpeedKph, + startIndex: run.launchIndex, + endIndex, + sectorTimes, + sectorBoundaries, + ...(finalMark ? {} : { incomplete: true as const }), + }); + } + + return laps; +} + +/** + * Cumulative time at each mark of a drag lap (prefix sums of `sectorTimes`) — + * the numbers a time slip prints. The last entry equals the ET for a complete + * run; entries past an incomplete run's lift are `undefined`. + */ +export function dragTimeSlipTimes(lap: Lap): (number | undefined)[] { + const times = lap.sectorTimes ?? []; + const out: (number | undefined)[] = []; + let cum: number | undefined = 0; + for (const t of times) { + cum = cum !== undefined && t !== undefined ? cum + t : undefined; + out.push(cum); + } + return out; +} diff --git a/src/lib/lapCalculation.test.ts b/src/lib/lapCalculation.test.ts index cd432e4d..daa119ae 100644 --- a/src/lib/lapCalculation.test.ts +++ b/src/lib/lapCalculation.test.ts @@ -5,6 +5,7 @@ import { formatLapTime, formatSectorTime, calculateOptimalLap, + fastestRankedLap, pairSprintRuns, } from "./lapCalculation"; import type { GpsSample, Course, Lap, LapCrossing } from "@/types/racing"; @@ -536,6 +537,39 @@ describe("calculateOptimalLap", () => { expect(result.optimalTimeMs).toBe(60000); expect(result.deltaToFastest).toBe(50000 - 60000); // negative — fastest beats theoretical optimal }); + + it("uses an incomplete lap's segments for the optimal but never for fastest", () => { + // Drag mode: an aborted run's window (12 s) is shorter than the real ET + // (60 s) but is not a comparable time — its best segment still counts. + const aborted: Lap = { ...makeLap(1, 12000, { s1: 15000, s2: undefined, s3: undefined }), incomplete: true }; + const complete = makeLap(2, 60000, { s1: 20000, s2: 20000, s3: 20000 }); + const result = calculateOptimalLap([aborted, complete])!; + expect(result.bestSegments).toEqual([15000, 20000, 20000]); + expect(result.deltaToFastest).toBe(60000 - 55000); // fastest = the complete lap, not the 12 s window + }); +}); + +// ─── fastestRankedLap ──────────────────────────────────────────────────────── + +describe("fastestRankedLap", () => { + it("returns null for no laps", () => { + expect(fastestRankedLap([])).toBeNull(); + }); + + it("picks the minimum lapTimeMs among complete laps", () => { + const laps = [makeLap(1, 61000), makeLap(2, 58000), makeLap(3, 59000)]; + expect(fastestRankedLap(laps)?.lapNumber).toBe(2); + }); + + it("skips incomplete laps even when their window is shortest", () => { + const laps = [{ ...makeLap(1, 10000), incomplete: true }, makeLap(2, 58000)]; + expect(fastestRankedLap(laps)?.lapNumber).toBe(2); + }); + + it("returns null when every lap is incomplete", () => { + const laps = [{ ...makeLap(1, 10000), incomplete: true }]; + expect(fastestRankedLap(laps)).toBeNull(); + }); }); // ─── calculateLaps: sprint (point-to-point) ────────────────────────────────── diff --git a/src/lib/lapCalculation.ts b/src/lib/lapCalculation.ts index 817a136c..e0b5a45d 100644 --- a/src/lib/lapCalculation.ts +++ b/src/lib/lapCalculation.ts @@ -479,9 +479,13 @@ export function calculateOptimalLap(laps: Lap[]): OptimalLapResult | null { const optimalTimeMs = bestSegments.reduce((a, b) => a + b, 0); - // Find fastest actual lap (single-pass to avoid stack overflow on huge inputs) + // Find fastest actual lap (single-pass to avoid stack overflow on huge inputs). + // Incomplete laps are skipped: their lapTimeMs is a data window, not a time — + // and when every lap is incomplete the final segment above was Infinity, so + // this point is only reached with at least one complete lap. let fastestLapMs = Infinity; for (const l of laps) { + if (l.incomplete) continue; if (l.lapTimeMs < fastestLapMs) fastestLapMs = l.lapTimeMs; } const deltaToFastest = fastestLapMs - optimalTimeMs; @@ -489,6 +493,23 @@ export function calculateOptimalLap(laps: Lap[]): OptimalLapResult | null { return { optimalTimeMs, bestSegments, deltaToFastest }; } +/** + * The fastest lap that may be ranked: minimum `lapTimeMs` over laps not flagged + * `incomplete` (whose "time" is just a data window — see `Lap.incomplete`). + * Every "fastest lap" pick (auto-select, trophy, persisted metadata) must go + * through this rather than a bare min-reduce. + */ +export function fastestRankedLap & { incomplete?: boolean }>( + laps: T[], +): T | null { + let best: T | null = null; + for (const lap of laps) { + if (lap.incomplete) continue; + if (!best || lap.lapTimeMs < best.lapTimeMs) best = lap; + } + return best; +} + /** * Determine the temporal order of S2 vs S3 crossings to infer driving direction. * diff --git a/src/types/racing.ts b/src/types/racing.ts index f733d454..6c2e1c94 100644 --- a/src/types/racing.ts +++ b/src/types/racing.ts @@ -180,6 +180,13 @@ export interface Lap { * start). `undefined` where the crossing was missed. Powers crop-to-sector. */ sectorBoundaries?: (number | undefined)[]; + /** + * The lap never reached its scoring distance: `lapTimeMs` is the duration of + * the recorded data window, NOT a comparable completed time — never rank it + * as fastest (see `fastestRankedLap`). Its partial `sectorTimes` still feed + * the optimal-lap calc. Currently only set by drag mode for short runs. + */ + incomplete?: boolean; } export interface FieldMapping { From 3c70124d7eaf8e2d9f73d88b63371e0786e623ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:36:20 +0000 Subject: [PATCH 03/46] plan 0022: drag orchestration + per-file persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDataLoader now consults detectDragRuns before accepting a waypoint result — the drag false-positive fix, since a strip's return road loops back near staging and waypoint mode would mis-time passes as laps. Detected runs pre-apply at the suggested distance (unpersisted, mirroring the waypoint branch) pending the user's prompt choice; applying persists the new FileMetadata.dragDistanceFt (feet, matching the lengthFt convention) so the session restores silently on reopen, and assigning a real course clears it. Distance switching re-maps the held runs without re-detection. Fastest-lap picks in useLapManagement and useReferenceLap now route through fastestRankedLap so incomplete runs can't be ranked. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g --- src/hooks/useDataLoader.test.ts | 35 ++++++++- src/hooks/useDataLoader.ts | 133 +++++++++++++++++++++++++++++++- src/hooks/useLapManagement.ts | 11 ++- src/hooks/useReferenceLap.ts | 22 +++--- src/lib/fileStorage.ts | 5 ++ 5 files changed, 186 insertions(+), 20 deletions(-) diff --git a/src/hooks/useDataLoader.test.ts b/src/hooks/useDataLoader.test.ts index cd39cc25..356e17c0 100644 --- a/src/hooks/useDataLoader.test.ts +++ b/src/hooks/useDataLoader.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { detectionMetadataPatch } from "./useDataLoader"; +import { detectionMetadataPatch, dragMetadataPatch } from "./useDataLoader"; const laps = [ { lapNumber: 1, lapTimeMs: 65000 }, @@ -32,3 +32,36 @@ describe("detectionMetadataPatch (auto-detect tagging)", () => { expect(patch).toMatchObject({ trackName: "OKC", courseName: "CW", sessionStartTime: 0 }); }); }); + +describe("dragMetadataPatch (drag-session tagging)", () => { + const runs = [ + { lapNumber: 1, lapTimeMs: 13400 }, + { lapNumber: 2, lapTimeMs: 12900 }, + { lapNumber: 3, lapTimeMs: 4200, incomplete: true }, // short window, aborted pass + ]; + + it("stores the distance, start time, and fastest complete run", () => { + const start = new Date(2026, 7, 28, 19, 30); + expect(dragMetadataPatch(1320, runs, start)).toEqual({ + dragDistanceFt: 1320, + sessionStartTime: start.getTime(), + fastestLapMs: 12900, + fastestLapNumber: 2, + }); + }); + + it("never caches an incomplete run's window as the fastest lap", () => { + const patch = dragMetadataPatch(1320, runs); + expect(patch.fastestLapNumber).toBe(2); + expect(patch.fastestLapMs).toBe(12900); + expect(patch.sessionStartTime).toBeUndefined(); + }); + + it("clears the fastest-lap badge when no run completes the distance", () => { + const patch = dragMetadataPatch(1320, [{ lapNumber: 1, lapTimeMs: 4200, incomplete: true }]); + expect(patch.dragDistanceFt).toBe(1320); + expect("fastestLapMs" in patch).toBe(true); + expect(patch.fastestLapMs).toBeUndefined(); + expect(patch.fastestLapNumber).toBeUndefined(); + }); +}); diff --git a/src/hooks/useDataLoader.ts b/src/hooks/useDataLoader.ts index 9a68a8db..c1410aa4 100644 --- a/src/hooks/useDataLoader.ts +++ b/src/hooks/useDataLoader.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from "react"; import { + GpsSample, ParsedData, Track, TrackCourseSelection, @@ -9,6 +10,14 @@ import { getFileMetadata, updateFileMetadata, type FileMetadata } from "@/lib/fi import { loadTracks } from "@/lib/trackStorage"; import { findNearestTrack } from "@/lib/trackUtils"; import { autoDetectCourse, tracksForRaceMode } from "@/lib/courseDetection"; +import { + detectDragRuns, + dragRunsToLaps, + isDragDistanceFt, + type DragDetectionResult, + type DragDistanceFt, +} from "@/lib/dragRunDetection"; +import { fastestRankedLap } from "@/lib/lapCalculation"; import { parseDatalogFile } from "@/lib/datalogParser"; import { ensureSampleFile, SAMPLE_FILE_NAME } from "@/lib/sampleData"; import type { useSessionData } from "@/hooks/useSessionData"; @@ -38,6 +47,18 @@ export interface UseDataLoaderReturn { detectionResult: CourseDetectionResult | null; allTracks: Track[]; gpsCenter: { lat: number; lon: number } | null; + + // Drag mode (plan 0022) — set when the loaded session was recognized as + // drag-strip runs (no course; laps are standing-start passes). + dragDetection: DragDetectionResult | null; + /** The active scoring distance; non-null means this is a drag session. */ + dragDistanceFt: DragDistanceFt | null; + /** Re-score the held runs at a distance, persisting the choice + fastest run. */ + applyDragDistance: (distanceFt: DragDistanceFt) => void; + /** Stand down drag mode in memory (metadata clearing rides selection changes). */ + clearDragSession: () => void; + /** Prompt escape: swap the pre-applied drag runs for the held waypoint laps. */ + handleUseWaypoint: () => void; } /** Pick the lap with the lowest lapTimeMs (linear, no Math.min spread). */ @@ -77,6 +98,26 @@ export function detectionMetadataPatch( return patch; } +/** + * The metadata patch to persist when a drag session's scoring distance is + * applied: the distance itself plus the fastest COMPLETE run for the browser + * badge — an incomplete run's data window must never be cached as a time, and + * a stale badge from a previous distance is cleared when no run completes the + * new one. Pure so the tag-on-apply behaviour stays testable. + */ +export function dragMetadataPatch( + distanceFt: DragDistanceFt, + laps: { lapNumber: number; lapTimeMs: number; incomplete?: boolean }[], + startDate?: Date, +): Partial> { + const patch: Partial> = { dragDistanceFt: distanceFt }; + if (startDate) patch.sessionStartTime = startDate.getTime(); + const fastest = fastestRankedLap(laps); + patch.fastestLapMs = fastest?.lapTimeMs; + patch.fastestLapNumber = fastest?.lapNumber; + return patch; +} + /** * File-load orchestration: connects sessionData (parsing), lapMgmt (lap calc), * sessionMeta (per-file kart/setup/weather metadata), and the track-prompt UI. @@ -95,11 +136,40 @@ export function useDataLoader({ const [gpsCenter, setGpsCenter] = useState<{ lat: number; lon: number } | null>(null); const [detectionResult, setDetectionResult] = useState(null); const [isLoadingSample, setIsLoadingSample] = useState(false); + const [dragDetection, setDragDetection] = useState(null); + const [dragDistanceFt, setDragDistanceFt] = useState(null); + + // Score held drag runs at a distance and swap the resulting run-laps in. + // Samples are passed explicitly: during a load, sessionData.data is still the + // previous session (setState hasn't flushed). + const applyDrag = useCallback( + ( + samples: GpsSample[], + drag: DragDetectionResult, + distanceFt: DragDistanceFt, + fileName?: string | null, + startDate?: Date, + persist = true, + ) => { + const dragLaps = dragRunsToLaps(samples, drag.runs, distanceFt); + lapMgmt.setLaps(dragLaps); + lapMgmt.setSelectedLapNumber(fastestRankedLap(dragLaps)?.lapNumber ?? null); + setDragDetection(drag); + setDragDistanceFt(distanceFt); + if (persist && fileName) { + updateFileMetadata(fileName, dragMetadataPatch(distanceFt, dragLaps, startDate)); + } + }, + [lapMgmt], + ); const handleDataLoaded = useCallback( async (parsedData: ParsedData, fileName?: string) => { sessionData.loadParsedData(parsedData, fileName); lapMgmt.setCurrentIndex(0); + // A previous file's drag state must never leak into this one. + setDragDetection(null); + setDragDistanceFt(null); // Try to restore track selection from metadata let courseToUse = lapMgmt.selectedCourse; @@ -129,6 +199,17 @@ export function useDataLoader({ restoredFromMeta = true; } sessionMeta.restoreFromMetadata(meta); + // A saved drag session restores silently: re-detect and re-map at the + // stored distance (a track/course restore above shadows a stale drag + // tag; a corrupt file that no longer detects falls through to normal + // detection). + if (!restoredFromMeta && isDragDistanceFt(meta.dragDistanceFt)) { + const drag = detectDragRuns(parsedData.samples); + if (drag) { + applyDrag(parsedData.samples, drag, meta.dragDistanceFt, fileName, parsedData.startDate); + return; + } + } } else { sessionMeta.restoreFromMetadata(null); } @@ -193,6 +274,20 @@ export function useDataLoader({ return; } + // No confident course match. Check for drag-strip data BEFORE accepting a + // waypoint result: a drag session's return road loops back near the + // staging lanes, so waypoint mode happily mis-times out-and-back passes + // as "laps". + const drag = detectDragRuns(parsedData.samples); + if (drag) { + // Pre-apply at the suggested distance (mirrors the waypoint branch's + // optimistic laps); nothing persists until the user confirms. + applyDrag(parsedData.samples, drag, drag.suggestedDistanceFt, undefined, undefined, false); + setDetectedTrack(null); + setTrackPromptOpen(true); + return; + } + if (detection && detection.isWaypointMode) { // Waypoint mode — apply laps and prompt the user to confirm lapMgmt.setLaps(detection.laps); @@ -207,7 +302,7 @@ export function useDataLoader({ setDetectedTrack(nearest as Track | null); setTrackPromptOpen(true); }, - [sessionData, lapMgmt, sessionMeta], + [sessionData, lapMgmt, sessionMeta, applyDrag], ); // The sample log is an ordinary seeded file now: ensure it exists, parse it, @@ -229,6 +324,9 @@ export function useDataLoader({ const handleTrackPromptSelect = useCallback( (sel: TrackCourseSelection) => { + // A real course supersedes any drag/waypoint pre-application. + setDragDetection(null); + setDragDistanceFt(null); lapMgmt.handleSelectionChange(sel); const samples = sessionData.data?.samples; if (!samples) return; @@ -238,6 +336,34 @@ export function useDataLoader({ [lapMgmt, sessionData.data], ); + // Re-score the held runs at a new distance (prompt apply + header switcher). + // Marks were all timed at detection, so this is a pure re-mapping. + const applyDragDistance = useCallback( + (distanceFt: DragDistanceFt) => { + const samples = sessionData.data?.samples; + if (!dragDetection || !samples) return; + applyDrag(samples, dragDetection, distanceFt, sessionData.currentFileName, sessionData.data?.startDate); + }, + [dragDetection, sessionData.data, sessionData.currentFileName, applyDrag], + ); + + const clearDragSession = useCallback(() => { + setDragDetection(null); + setDragDistanceFt(null); + }, []); + + // Prompt escape for a session where drag pre-applied its runs but a waypoint + // result also exists: swap in the waypoint laps and stand down drag mode. + // Nothing persists — matching the plain waypoint flow, where dismissing the + // prompt keeps its laps unpersisted. + const handleUseWaypoint = useCallback(() => { + if (!detectionResult?.isWaypointMode) return; + lapMgmt.setLaps(detectionResult.laps); + lapMgmt.setSelectedLapNumber(pickFastestLapNumber(detectionResult.laps)); + setDragDetection(null); + setDragDistanceFt(null); + }, [detectionResult, lapMgmt]); + return { handleDataLoaded, handleLoadSample, @@ -249,5 +375,10 @@ export function useDataLoader({ detectionResult, allTracks, gpsCenter, + dragDetection, + dragDistanceFt, + applyDragDistance, + clearDragSession, + handleUseWaypoint, }; } diff --git a/src/hooks/useLapManagement.ts b/src/hooks/useLapManagement.ts index 5f40de36..d73f2949 100644 --- a/src/hooks/useLapManagement.ts +++ b/src/hooks/useLapManagement.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { ParsedData, Lap, GpsSample, TrackCourseSelection, Course } from "@/types/racing"; -import { calculateLaps } from "@/lib/lapCalculation"; +import { calculateLaps, fastestRankedLap } from "@/lib/lapCalculation"; import { updateFileMetadata } from "@/lib/fileStorage"; /** @@ -63,11 +63,8 @@ export function useLapManagement(data: ParsedData | null, currentFileName: strin (course: Course, samples: GpsSample[], fileNameOverride?: string) => { const computedLaps = calculateLaps(samples, course); setLaps(computedLaps); - if (computedLaps.length > 0) { - const fastest = computedLaps.reduce( - (min, lap) => (lap.lapTimeMs < min.lapTimeMs ? lap : min), - computedLaps[0] - ); + const fastest = fastestRankedLap(computedLaps); + if (fastest) { setSelectedLapNumber(fastest.lapNumber); // Persist fastest lap into metadata (preserving all other tags). @@ -95,6 +92,8 @@ export function useLapManagement(data: ParsedData | null, currentFileName: strin updateFileMetadata(currentFileName, { trackName: newSelection.trackName, courseName: newSelection.courseName, + // Assigning a real course retires drag mode for this file. + dragDistanceFt: undefined, ...(data?.startDate ? { sessionStartTime: data.startDate.getTime() } : {}), }); } diff --git a/src/hooks/useReferenceLap.ts b/src/hooks/useReferenceLap.ts index 001115e4..ff30463f 100644 --- a/src/hooks/useReferenceLap.ts +++ b/src/hooks/useReferenceLap.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { ParsedData, Lap, GpsSample, Course } from "@/types/racing"; import { FileEntry, listFiles, getFile } from "@/lib/fileStorage"; import { parseDatalogFile } from "@/lib/datalogParser"; -import { calculateLaps, formatLapTime } from "@/lib/lapCalculation"; +import { calculateLaps, fastestRankedLap, formatLapTime } from "@/lib/lapCalculation"; import { calculateReferenceSpeed } from "@/lib/referenceUtils"; import { computeLapPace, type DeltaMethod } from "@/lib/lapDelta"; import { findSpeedEvents } from "@/lib/speedEvents"; @@ -33,12 +33,11 @@ export function useReferenceLap( }, [data, laps, referenceLapNumber, externalRefSamples]); // Get fastest lap samples for pace comparison when no reference selected + // (never an incomplete drag run — its window is not a comparable time). const fastestLapSamples = useMemo((): GpsSample[] => { - if (!data || laps.length === 0) return []; - const fastestLap = laps.reduce( - (min, lap) => (lap.lapTimeMs < min.lapTimeMs ? lap : min), - laps[0] - ); + if (!data) return []; + const fastestLap = fastestRankedLap(laps); + if (!fastestLap) return []; return data.samples.slice(fastestLap.startIndex, fastestLap.endIndex + 1); }, [data, laps]); @@ -53,15 +52,14 @@ export function useReferenceLap( }; }, [filteredSamples, referenceSamples, useKph, deltaMethod, deltaSampleMeters]); - // Calculate lap to fastest delta (direct lap time difference) + // Calculate lap to fastest delta (direct lap time difference). Incomplete + // drag runs have no comparable time, on either side of the subtraction. const lapToFastestDelta = useMemo((): number | null => { if (selectedLapNumber === null || laps.length === 0) return null; const selectedLap = laps.find((l) => l.lapNumber === selectedLapNumber); - if (!selectedLap) return null; - const fastestLap = laps.reduce( - (min, lap) => (lap.lapTimeMs < min.lapTimeMs ? lap : min), - laps[0] - ); + if (!selectedLap || selectedLap.incomplete) return null; + const fastestLap = fastestRankedLap(laps); + if (!fastestLap) return null; return selectedLap.lapTimeMs - fastestLap.lapTimeMs; }, [laps, selectedLapNumber]); diff --git a/src/lib/fileStorage.ts b/src/lib/fileStorage.ts index bfe5d1d2..7c4f8c46 100644 --- a/src/lib/fileStorage.ts +++ b/src/lib/fileStorage.ts @@ -56,6 +56,11 @@ export interface FileMetadata { // Fastest lap cache fastestLapMs?: number; fastestLapNumber?: number; + // Drag mode (plan 0022): the session's chosen scoring distance in feet + // (660 / 1000 / 1320 — validated with isDragDistanceFt on read). Presence + // marks the file as a drag session and restores it silently on reopen; + // assigning a real track/course clears it. + dragDistanceFt?: number; // Browser display-name override. When set, the file browser shows this instead // of the date/time derived name (used by the bundled sample log). displayName?: string; From 226618ec995d086f89c9b60641f5b1a63af05061 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:46:45 +0000 Subject: [PATCH 04/46] plan 0022: drag mode UI + i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-track prompt grows a "Drag Runs Detected" branch (precedence: course step > drag > waypoint > no-track) with a scoring-distance picker pre-seeded from the longest distance a run actually covered; the session header gains a 1/8 / 1000 ft / 1/4 switcher while drag mode is active. The lap table renders the classic time slip — cumulative 60/330/660/1000 ft columns with best-per-mark highlights — labels rows "Run N" through the existing lapLabels plumbing, hides Min Speed (meaningless at a standing start), badges incomplete passes instead of showing their window as an ET, and its footer forks to Best ET with the optimal built from the best splits of the day. The crop-to-segment dropdown self-derives drag mode (only drag laps carry sectorBoundaries without a course). All new strings ship in the seven locales. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012urKN2QdPpfSfjQ4Buux3g --- CLAUDE.md | 7 +- README.md | 1 + docs/plans/0022-drag-mode.md | 17 ++- src/components/LapSummaryWidget.tsx | 31 ++--- src/components/LapTable.tsx | 128 +++++++++++++++----- src/components/SectorCropSelect.tsx | 28 ++++- src/components/TrackPromptDialog.tsx | 66 +++++++++- src/components/graphview/GraphViewPanel.tsx | 12 +- src/components/tabs/GraphViewTab.tsx | 1 + src/components/tabs/LapTimesTab.tsx | 1 + src/contexts/SessionContext.tsx | 6 + src/locales/de/session.json | 25 +++- src/locales/de/tracks.json | 12 +- src/locales/en/session.json | 25 +++- src/locales/en/tracks.json | 12 +- src/locales/es/session.json | 25 +++- src/locales/es/tracks.json | 12 +- src/locales/fr/session.json | 25 +++- src/locales/fr/tracks.json | 12 +- src/locales/it/session.json | 25 +++- src/locales/it/tracks.json | 12 +- src/locales/ja/session.json | 25 +++- src/locales/ja/tracks.json | 12 +- src/locales/pt-BR/session.json | 25 +++- src/locales/pt-BR/tracks.json | 12 +- src/pages/Index.tsx | 62 ++++++++-- 26 files changed, 527 insertions(+), 92 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 16f89b43..37e59829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,8 @@ src/ │ ├── channels.ts # ★ Canonical channel registry + normalizeChannels() │ ├── courseDetection.ts # ★ Auto track/course/direction detection + waypoint mode (→ docs/subsystems.md) │ ├── courseSectors.ts # ★ Pure sector model: caps, normalizeCourseSectors, majorSectorLines (→ docs/subsystems.md) -│ ├── lapCalculation.ts # Start/finish + per-sector crossing detection → Lap[] +│ ├── lapCalculation.ts # Start/finish + per-sector crossing detection → Lap[]; fastestRankedLap = the ONLY way to pick "fastest" (skips incomplete drag runs) +│ ├── dragRunDetection.ts # ★ Drag mode (plan 0022): standing-start run detection at unknown venues (staged→launch state machine, marks at 60/330/660/1000/1320 ft, straightness+speed gate) + run→Lap mapping. useDataLoader consults it BEFORE accepting a waypoint result (a strip's return road fools waypoint mode); distance choice persists as FileMetadata.dragDistanceFt │ ├── lapDelta.ts # ★ Position-based lap delta (arc-length resample + segment-projected gap) │ ├── fileBrowserTree.ts # ★ Pure file-browser hierarchy (→ docs/subsystems.md) │ ├── sampleData.ts # ★ Bundled sample log seeded as an ordinary file (→ docs/subsystems.md) @@ -196,14 +197,14 @@ File Import (drag-drop / BLE download / file manager) | `ParsedData` | `samples[]`, `fieldMappings[]`, `bounds`, `duration`, `startDate?`, `dovexMetadata?`, `parserStats?` | | `ParserStats` | `totalRows`, `acceptedRows`, `rejected: { nanFields, zeroCoords, outOfRange, speedCap, teleportation, incompleteRow }` | | `DovexMetadata` | `datetime?`, `driver?`, `course?`, `shortName?`, `bestLapMs?`, `optimalMs?`, `lapTimesMs?[]` | -| `Lap` | `lapNumber`, `startTime/endTime`, `lapTimeMs`, speed stats, `startIndex/endIndex`, `sectors?` (S1/S2/S3 major rollup), `sectorTimes?` (fine-grained), `sectorBoundaries?` (per-line sample indices) | +| `Lap` | `lapNumber`, `startTime/endTime`, `lapTimeMs`, speed stats, `startIndex/endIndex`, `sectors?` (S1/S2/S3 major rollup), `sectorTimes?` (fine-grained), `sectorBoundaries?` (per-line sample indices), `incomplete?` (drag: lapTimeMs is a data window, never rank as fastest — use `fastestRankedLap`) | | `Course` | `name`, `type?: CourseType` (absent = `circuit`), `lengthFt?`, `startFinishA/B`, `finish?`+`dateCreated?` (sprint only), `sectors?: CourseSector[]`, deprecated `sector2/sector3` (legacy mirror), optional `layout?` (`{lat,lon}[]` outline) | | `CourseSector` | `{ line: SectorLine, major: boolean }` — one timing line after start/finish. `major` is circuit-only; sprint splits are stored unflagged | | `CourseType` | `'circuit' \| 'sprint'` — lap-to-lap vs point-to-point (start line ≠ finish line). Read via `isSprintCourse()`; see `docs/plans/0015-sprint-mode.md` | | `Track` | `name`, `shortName?` (max 8 chars), `courses[]` | | `CourseDetectionResult` | `track`, `course`, `direction?`, `laps[]`, `isWaypointMode`, `waypointNotice?` | | `FieldMapping` | `index`, `name` (canonical ChannelId or `custom:` slug), `label?`, `unit?`, `enabled` | -| `FileMetadata` | `fileName`, `trackName`, `courseName`, `weatherStation*?`, `sessionKartId?`, `sessionSetupId?`, `sessionSetupRev?` (frozen hash), `sessionEngine?`, `sessionStartTime?`, `fastestLapMs?`, `fastestLapNumber?`, `displayName?` (browser-name override — the bundled sample), `isSample?` (marks the sample so the browser can hide it), `postSession?` (`PostSessionData`: post-session tire pressures — single/halves/quarters — + a single weight, entered on the Notes tab; cloud-synced via metadata, held for later processing). Partial updates go through `updateFileMetadata(fileName, patch)` (read-merge-write — never clobbers untouched tags). | +| `FileMetadata` | `fileName`, `trackName`, `courseName`, `weatherStation*?`, `sessionKartId?`, `sessionSetupId?`, `sessionSetupRev?` (frozen hash), `sessionEngine?`, `sessionStartTime?`, `fastestLapMs?`, `fastestLapNumber?`, `displayName?` (browser-name override — the bundled sample), `isSample?` (marks the sample so the browser can hide it), `postSession?` (`PostSessionData`: post-session tire pressures — single/halves/quarters — + a single weight, entered on the Notes tab; cloud-synced via metadata, held for later processing), `dragDistanceFt?` (drag-mode scoring distance in feet — plan 0022; presence marks a drag session, cleared when a real course is assigned). Partial updates go through `updateFileMetadata(fileName, patch)` (read-merge-write — never clobbers untouched tags). | --- diff --git a/README.md b/README.md index 223fcbd4..7218230f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - Automatic track & course detection within 5 miles - Automatic driving direction detection (forward/reverse) - Waypoint mode — lap timing anywhere, no track needed +- Drag mode — standing-start runs auto-detected at unknown venues; pick 1/8 mile, 1000 ft or 1/4 mile and every pass gets a time-slip breakdown (60/330/660/1000 ft splits + ET), incomplete passes included - Interactive race line map with speed heatmap - Braking zone detection & visualization - Automatic lap detection via start/finish line diff --git a/docs/plans/0022-drag-mode.md b/docs/plans/0022-drag-mode.md index 3996d742..7c97a3bf 100644 --- a/docs/plans/0022-drag-mode.md +++ b/docs/plans/0022-drag-mode.md @@ -110,12 +110,17 @@ Distance switching is a pure re-map of the held `DragRun[]` — no re-detection. `TrackPromptDialog` precedence: course-step > drag > waypoint > no-track (title forks too). Header gets a distance `Select` next to the compact TrackEditor, -visible only in drag mode. `LapTable` renders cumulative time-slip columns -(via `dragTimeSlipTimes`), an Incomplete badge, Run labels through the -existing `lapLabels` plumbing, Min Speed hidden (always ~0 at launch), footer -Best ET. `SessionContext` carries `dragSession` + `onSetDragDistance`. -Mark labels stay imperial in v1 — drag increments are defined in feet; a -metric follow-up can localize them. +visible only in drag mode (the switcher lives in `Index.tsx`, which already +holds the `useDataLoader` handle — so `SessionContext` only carries the +read-only `dragDistanceFt`, leaner than the originally planned +`dragSession`/`onSetDragDistance` pair). `LapTable` renders cumulative +time-slip columns (via `dragTimeSlipTimes`), an Incomplete badge, Run labels +through the existing `lapLabels` plumbing, Min Speed hidden (always ~0 at +launch), footer Best ET. `SectorCropSelect` self-derives drag mode — a null +course with `sectorBoundaries` only ever comes from drag laps, and the +boundary count encodes the distance — so the crop dropdown needed no prop +threading through `GraphViewPanel`. Mark labels stay imperial in v1 — drag +increments are defined in feet; a metric follow-up can localize them. ## Follow-ups (out of scope for v1) diff --git a/src/components/LapSummaryWidget.tsx b/src/components/LapSummaryWidget.tsx index 6e896fe0..42209e39 100644 --- a/src/components/LapSummaryWidget.tsx +++ b/src/components/LapSummaryWidget.tsx @@ -1,5 +1,5 @@ import { Lap, Course, courseHasSectors } from '@/types/racing'; -import { formatLapTime, formatSectorTime, calculateOptimalLap } from '@/lib/lapCalculation'; +import { formatLapTime, calculateOptimalLap, fastestRankedLap } from '@/lib/lapCalculation'; import { Trophy, Sparkles, Timer } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -13,9 +13,8 @@ interface LapSummaryWidgetProps { export function LapSummaryWidget({ laps, course, selectedLap, paceDiff }: LapSummaryWidgetProps) { if (laps.length === 0) return null; - // Find fastest lap - const fastestLap = laps.reduce((min, lap) => - lap.lapTimeMs < min.lapTimeMs ? lap : min, laps[0]); + // Find fastest lap (never an incomplete drag run — its time is just a window) + const fastestLap = fastestRankedLap(laps); // Calculate optimal lap if sectors available const showSectors = courseHasSectors(course); @@ -39,17 +38,19 @@ export function LapSummaryWidget({ laps, course, selectedLap, paceDiff }: LapSum )} -
- - - - - Fastest Lap - - - {formatLapTime(fastestLap.lapTimeMs)} - -
+ {fastestLap && ( +
+ + + + + Fastest Lap + + + {formatLapTime(fastestLap.lapTimeMs)} + +
+ )} {optimalLap && (
diff --git a/src/components/LapTable.tsx b/src/components/LapTable.tsx index 7a32b917..a45c3f83 100644 --- a/src/components/LapTable.tsx +++ b/src/components/LapTable.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Lap, courseHasSectors, isSprintCourse, Course, GpsSample } from '@/types/racing'; import { formatLapTime, formatSectorTime, calculateOptimalLap } from '@/lib/lapCalculation'; import { normalizeCourseSectors, sectorLabels } from '@/lib/courseSectors'; +import { DRAG_MARKS_FT, dragTimeSlipTimes, type DragDistanceFt } from '@/lib/dragRunDetection'; import { Trophy, Zap, Snail, Target } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ExternalRefBar } from '@/components/ExternalRefBar'; @@ -51,12 +52,17 @@ interface LapTableProps { // course/engine/weight (leaderboard) or course/driver/date (share) descriptor. lapLabels?: Record; readOnlyDescriptor?: LeaderboardDescriptor; + // Drag mode (plan 0022): rows are standing-start runs scored at this distance. + // The sector columns become cumulative time-slip marks, Min Speed is hidden + // (~0 at every launch), and incomplete runs show a badge instead of an ET. + dragDistanceFt?: DragDistanceFt | null; } -export const LapTable = memo(function LapTable({ laps, course, samples, onLapSelect, selectedLapNumber, referenceLapNumber, onSetReference, externalRefLabel, savedFiles, onLoadFileForRef, onSelectExternalLap, onClearExternalRef, onRefreshSavedFiles, snapshotsForCourse, activeSnapshotId, canSnapshot, onLoadSnapshot, onClearSnapshot, onSaveSnapshot, overlayLines = [], onToggleOverlay, lapLabels, readOnlyDescriptor }: LapTableProps) { +export const LapTable = memo(function LapTable({ laps, course, samples, onLapSelect, selectedLapNumber, referenceLapNumber, onSetReference, externalRefLabel, savedFiles, onLoadFileForRef, onSelectExternalLap, onClearExternalRef, onRefreshSavedFiles, snapshotsForCourse, activeSnapshotId, canSnapshot, onLoadSnapshot, onClearSnapshot, onSaveSnapshot, overlayLines = [], onToggleOverlay, lapLabels, readOnlyDescriptor, dragDistanceFt }: LapTableProps) { const { t } = useTranslation('session'); const { useKph } = useSettingsContext(); + const drag = dragDistanceFt != null; const showSectors = courseHasSectors(course); // Point-to-point session: the rows are runs, not laps. The "lap" wording is // kept on purpose (autocross drivers use it, and so does the logger) — only @@ -129,12 +135,39 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel const showFull = view === 'full' && full !== null; const showSums = showFull && showSectorSums; + // Drag mode: cumulative time at each intermediate mark (the classic time + // slip), plus the fastest run per mark for highlighting. The scoring mark + // itself is the Time column, so it's excluded from the columns here. + const dragCols = useMemo(() => { + if (dragDistanceFt == null) return null; + const markFts = DRAG_MARKS_FT.filter((m) => m <= dragDistanceFt).slice(0, -1); + const slips = laps.map((lap) => dragTimeSlipTimes(lap)); + const fastestIdx: (number | null)[] = markFts.map((_, k) => { + let best: number | null = null; + let bestVal = Infinity; + for (let idx = 0; idx < slips.length; idx++) { + const v = slips[idx][k]; + if (v !== undefined && v < bestVal) { + bestVal = v; + best = idx; + } + } + return best; + }); + return { markFts, slips, fastestIdx }; + }, [dragDistanceFt, laps]); + // Memoize expensive lap statistics computation const lapStats = useMemo(() => { if (laps.length === 0) return null; - const fastestLapIdx = laps.reduce((minIdx, lap, idx, arr) => - lap.lapTimeMs < arr[minIdx].lapTimeMs ? idx : minIdx, 0); + // Incomplete drag runs are never "fastest" — their lapTimeMs is a data + // window, not a comparable time. null when every row is incomplete. + let fastestLapIdx: number | null = null; + for (let i = 0; i < laps.length; i++) { + if (laps[i].incomplete) continue; + if (fastestLapIdx === null || laps[i].lapTimeMs < laps[fastestLapIdx].lapTimeMs) fastestLapIdx = i; + } const fastestSpeedIdx = laps.reduce((maxIdx, lap, idx, arr) => { const currentMax = useKph ? arr[maxIdx].maxSpeedKph : arr[maxIdx].maxSpeedMph; @@ -172,10 +205,12 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel }); } - const optimalLap = showSectors ? calculateOptimalLap(laps) : null; + // Drag laps carry sectorTimes without a course, and the optimal ET (best + // splits of the day, incomplete runs included) is exactly what racers want. + const optimalLap = showSectors || drag ? calculateOptimalLap(laps) : null; return { fastestLapIdx, fastestSpeedIdx, slowestMinSpeedIdx, fastestS1Idx, fastestS2Idx, fastestS3Idx, optimalLap }; - }, [laps, useKph, showSectors]); + }, [laps, useKph, showSectors, drag]); // Calculate average lap distance const avgLapLength = useMemo(() => { @@ -215,6 +250,13 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel const speedUnit = useKph ? 'kph' : 'mph'; const getMaxSpeed = (lap: Lap) => useKph ? lap.maxSpeedKph : lap.maxSpeedMph; const getMinSpeed = (lap: Lap) => useKph ? lap.minSpeedKph : lap.minSpeedMph; + // Static per-key calls keep the typed t() happy (no dynamic key strings). + const dragMarkLabel = (ft: number): string => + ft === 60 ? t('drag.mark60') + : ft === 330 ? t('drag.mark330') + : ft === 660 ? t('drag.markEighth') + : ft === 1000 ? t('drag.mark1000') + : t('drag.markQuarter'); const hasExternalRefProps = savedFiles && onLoadFileForRef && onSelectExternalLap && onClearExternalRef; const hasSnapshotProps = onLoadSnapshot && onClearSnapshot && onSaveSnapshot; @@ -301,9 +343,15 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel {t('lapTable.colRef')} - {t('lapTable.colLap')} + {drag ? t('lapTable.colRun') : t('lapTable.colLap')} {t('lapTable.colTime')} - {showFull ? ( + {dragCols ? ( + dragCols.markFts.map((ft) => ( + + {dragMarkLabel(ft)} + + )) + ) : showFull ? ( full.labels.map((label, k) => ( {showSums && full.isGroupStart[k] && ( @@ -326,7 +374,7 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel )} {t('lapTable.colTopSpeed')} - {t('lapTable.colMinSpeed')} + {!drag && {t('lapTable.colMinSpeed')}} @@ -377,9 +425,31 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel
- {formatLapTime(lap.lapTimeMs)} + {lap.incomplete ? ( + + —{' '} + + {t('lapTable.incomplete')} + + + ) : ( + formatLapTime(lap.lapTimeMs) + )} - {showFull ? ( + {dragCols ? ( + dragCols.markFts.map((ft, k) => { + const v = dragCols.slips[idx][k]; + const isFastestMark = dragCols.fastestIdx[k] === idx; + return ( + + {v !== undefined ? formatSectorTime(v) : '—'} + + ); + }) + ) : showFull ? ( full.labels.map((_, k) => { const t = lap.sectorTimes?.[k]; const isFastestSeg = full.fastestIdx[k] === idx; @@ -427,16 +497,18 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel )} - -
- - {getMinSpeed(lap).toFixed(1)} {speedUnit} - - {hasSlowestMinSpeed && ( - - )} -
- + {!drag && ( + +
+ + {getMinSpeed(lap).toFixed(1)} {speedUnit} + + {hasSlowestMinSpeed && ( + + )} +
+ + )} ); })} @@ -446,12 +518,14 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel {/* Summary */}
-
- {t('lapTable.bestLap')}: - - {formatLapTime(laps[fastestLapIdx].lapTimeMs)} - -
+ {fastestLapIdx !== null && ( +
+ {drag ? t('lapTable.bestEt') : t('lapTable.bestLap')}: + + {formatLapTime(laps[fastestLapIdx].lapTimeMs)} + +
+ )} {optimalLap && ( <>
@@ -471,7 +545,7 @@ export const LapTable = memo(function LapTable({ laps, course, samples, onLapSel {avgLapLength !== null && (
- {sprint ? t('lapTable.avgRunLength') : t('lapTable.avgLapLength')}:{' '} + {sprint || drag ? t('lapTable.avgRunLength') : t('lapTable.avgLapLength')}:{' '} {t('lapTable.avgLapValue', { diff --git a/src/components/SectorCropSelect.tsx b/src/components/SectorCropSelect.tsx index 961434b4..c5794a2c 100644 --- a/src/components/SectorCropSelect.tsx +++ b/src/components/SectorCropSelect.tsx @@ -18,6 +18,8 @@ interface CropOption { key: string; label: string; range: [number, number]; + /** Label is already display-ready (drag segments) — skip the "Sector X" wrapper. */ + raw?: boolean; } /** @@ -32,11 +34,27 @@ export function SectorCropSelect({ const { t } = useTranslation('session'); const lap = selectedLapNumber === null ? null : laps.find((l) => l.lapNumber === selectedLapNumber) ?? null; + // Drag runs carry sectorBoundaries with no course — the only session shape + // that does — so a null course + boundaries means the segments are the marks + // of a drag time slip (their count encodes the scoring distance). + const dragSegmentLabels = useMemo(() => { + if (course || !lap?.sectorBoundaries?.length) return null; + const all = [ + t('drag.segLaunchTo60'), + t('drag.seg60To330'), + t('drag.seg330To660'), + t('drag.seg660To1000'), + t('drag.seg1000To1320'), + ]; + return all.slice(0, lap.sectorBoundaries.length); + }, [course, lap, t]); + const options = useMemo(() => { const opts: CropOption[] = [{ key: 'full', label: 'Full lap', range: [0, Math.max(0, filteredLength - 1)] }]; - if (!course || !lap || !lap.sectorBoundaries || lap.sectorBoundaries.length === 0) return opts; + if (!lap || !lap.sectorBoundaries || lap.sectorBoundaries.length === 0) return opts; + if (!course && !dragSegmentLabels) return opts; - const labels = sectorLabels(course); + const labels = dragSegmentLabels ?? sectorLabels(course!); const lineCount = labels.length; const bounds = lap.sectorBoundaries; // absolute sample indices, [0] = lap start const clamp = (n: number) => Math.max(0, Math.min(n, filteredLength - 1)); @@ -48,10 +66,10 @@ export function SectorCropSelect({ const startRel = clamp(startAbs - lap.startIndex); const endRel = clamp(endAbs - lap.startIndex); if (endRel <= startRel) continue; - opts.push({ key: `s-${k}`, label: labels[k], range: [startRel, endRel] }); + opts.push({ key: `s-${k}`, label: labels[k], range: [startRel, endRel], raw: dragSegmentLabels !== null }); } return opts; - }, [course, lap, filteredLength]); + }, [course, lap, filteredLength, dragSegmentLabels]); const hasSectorOptions = options.length > 1; const disabled = !hasSectorOptions; @@ -80,7 +98,7 @@ export function SectorCropSelect({ {currentKey === 'custom' && } {options.map((o) => ( ))} diff --git a/src/components/TrackPromptDialog.tsx b/src/components/TrackPromptDialog.tsx index 4306bed7..b171e8c6 100644 --- a/src/components/TrackPromptDialog.tsx +++ b/src/components/TrackPromptDialog.tsx @@ -17,6 +17,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Track, TrackCourseSelection, CourseDetectionResult, Lap, GpsSample } from '@/types/racing'; +import type { DragDetectionResult, DragDistanceFt } from '@/lib/dragRunDetection'; import { AddCourseDialog } from '@/components/track-editor/AddCourseDialog'; import { AddTrackDialog } from '@/components/track-editor/AddTrackDialog'; import { useTrackEditorForm } from '@/hooks/useTrackEditorForm'; @@ -35,6 +36,12 @@ interface TrackPromptDialogProps { initialCenter?: { lat: number; lon: number } | null; /** Auto-detection result with direction and waypoint info */ detectionResult?: CourseDetectionResult | null; + /** Detected standing-start drag runs (plan 0022) — shows the drag branch. */ + dragDetection?: DragDetectionResult | null; + /** Apply drag timing at the chosen scoring distance. */ + onSelectDragDistance?: (distanceFt: DragDistanceFt) => void; + /** Swap the pre-applied drag runs for the held waypoint laps. */ + onUseWaypoint?: () => void; /** Current session laps (e.g. waypoint-mode laps) — feeds the create-course * "Generate outline" picker so the outline can be drawn right here. */ laps?: Lap[]; @@ -45,11 +52,13 @@ interface TrackPromptDialogProps { export function TrackPromptDialog({ open, onOpenChange, detectedTrack, tracks: initialTracks, onSelect, initialCenter, detectionResult, + dragDetection, onSelectDragDistance, onUseWaypoint, laps, samples, }: TrackPromptDialogProps) { const { t } = useTranslation('tracks'); const [tracks, setTracks] = useState(initialTracks); const [selectedCourseName, setSelectedCourseName] = useState(''); + const [dragDistance, setDragDistance] = useState(1320); const [isAddCourseOpen, setIsAddCourseOpen] = useState(false); const [isAddTrackOpen, setIsAddTrackOpen] = useState(false); // A track is just a name now, so creating one here is a two-step flow: make @@ -75,6 +84,11 @@ export function TrackPromptDialog({ if (open) setCreatedTrackName(null); }, [open]); + // Seed the distance picker with the longest distance a run actually covered. + useEffect(() => { + if (open && dragDetection) setDragDistance(dragDetection.suggestedDistanceFt); + }, [open, dragDetection]); + useEffect(() => { if (open && detectionResult && !detectionResult.isWaypointMode) { setSelectedCourseName(detectionResult.course.name); @@ -180,12 +194,13 @@ export function TrackPromptDialog({ - {track ? t('prompt.selectCourse') : t('prompt.noTrackDetected')} + {track ? t('prompt.selectCourse') : dragDetection ? t('prompt.dragTitle') : t('prompt.noTrackDetected')} - {/* Course step: a track is selected/created — pick (or add) its course */} - {track && (inCourseStep || !detectionResult?.isWaypointMode) ? ( + {/* Course step: a track is selected/created — pick (or add) its course. + Precedence: course step > drag > waypoint > no-track. */} + {track && (inCourseStep || (!detectionResult?.isWaypointMode && !dragDetection)) ? (

{inCourseStep ? ( @@ -232,6 +247,51 @@ export function TrackPromptDialog({

+ ) : dragDetection ? ( + /* Drag runs detected (plan 0022): standing-start passes at an + unknown venue — pick the scoring distance for the time slip. */ +
+
+
+ +
+

{t('prompt.dragTiming')}

+

+ {t('prompt.dragNotice')} +

+

+ }} /> +

+
+
+
+
+ + +
+
+ + +
+ {detectionResult?.isWaypointMode && ( + + )} +
) : detectionResult?.isWaypointMode ? ( /* Waypoint mode notice */
diff --git a/src/components/graphview/GraphViewPanel.tsx b/src/components/graphview/GraphViewPanel.tsx index 64959300..a4b29991 100644 --- a/src/components/graphview/GraphViewPanel.tsx +++ b/src/components/graphview/GraphViewPanel.tsx @@ -69,6 +69,9 @@ export interface GraphViewPanelProps { allSamples?: GpsSample[]; laps?: Lap[]; selectedLapNumber?: number | null; + /** lapNumber → display label override ("Run N" in drag mode, submitter names + * in read-only leaderboard views) for the main graph header. */ + lapLabels?: Record; paceData?: (number | null)[]; // Multi-lap overlay (extra racing lines drawn on the MiniMap) overlayLines?: OverlayLine[]; @@ -199,10 +202,11 @@ export function GraphViewPanel(props: GraphViewPanelProps) { ? (props.overlayLines ?? []).filter((o) => o.id !== splitOverlayId) : props.overlayLines; - const mainLapLabel = props.selectedLapNumber != null - ? (props.lapTimeMs != null - ? `${t('header.lap', { number: props.selectedLapNumber })} · ${formatLapTime(props.lapTimeMs)}` - : t('header.lap', { number: props.selectedLapNumber })) + const mainLapName = props.selectedLapNumber != null + ? props.lapLabels?.[props.selectedLapNumber] ?? t('header.lap', { number: props.selectedLapNumber }) + : null; + const mainLapLabel = mainLapName != null + ? (props.lapTimeMs != null ? `${mainLapName} · ${formatLapTime(props.lapTimeMs)}` : mainLapName) : t('header.allLaps'); // Single-line legend (CSS ellipsis on overflow): main lap, then the overlays diff --git a/src/components/tabs/GraphViewTab.tsx b/src/components/tabs/GraphViewTab.tsx index 669a31ae..8144648f 100644 --- a/src/components/tabs/GraphViewTab.tsx +++ b/src/components/tabs/GraphViewTab.tsx @@ -13,6 +13,7 @@ export const GraphViewTab = memo(function GraphViewTab() { fieldMappings={s.fieldMappings} course={s.course} lapTimeMs={s.selectedLapTimeMs} + lapLabels={s.lapLabels} paceDiff={s.paceDiff} paceDiffLabel={s.paceDiffLabel} deltaTopSpeed={s.deltaTopSpeed} diff --git a/src/components/tabs/LapTimesTab.tsx b/src/components/tabs/LapTimesTab.tsx index aa2ab03c..2dca8f89 100644 --- a/src/components/tabs/LapTimesTab.tsx +++ b/src/components/tabs/LapTimesTab.tsx @@ -30,6 +30,7 @@ export const LapTimesTab = memo(function LapTimesTab() { onToggleOverlay={s.onToggleOverlay} lapLabels={s.lapLabels} readOnlyDescriptor={s.readOnlyDescriptor} + dragDistanceFt={s.dragDistanceFt} />
); diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx index ab58e797..f6ed68e5 100644 --- a/src/contexts/SessionContext.tsx +++ b/src/contexts/SessionContext.tsx @@ -13,6 +13,7 @@ import type { OverlayLine } from '@/lib/lapOverlays'; import type { SaveSnapshotResult } from '@/hooks/useLapSnapshots'; import type { PluginSnapshot } from '@/plugins/panels'; import type { LeaderboardDescriptor } from '@/lib/leaderboardSession'; +import type { DragDistanceFt } from '@/lib/dragRunDetection'; /** * Session-scoped state and handlers shared by the three main view tabs @@ -50,6 +51,11 @@ export interface SessionContextValue { referenceLapNumber: number | null; isAllLaps: boolean; + // ── Drag mode (plan 0022) ───────────────────────────────────────────────── + /** Active drag scoring distance; non-null marks a drag session (course is + * null, the laps are standing-start runs, the lap table shows a time slip). */ + dragDistanceFt?: DragDistanceFt | null; + // ── Read-only leaderboard view (plan 0005) ──────────────────────────────── /** True when this is a synthetic, read-only leaderboard session (no saving, * no video/weather/snapshots, alert-coloured header). */ diff --git a/src/locales/de/session.json b/src/locales/de/session.json index 28030344..03d3f04f 100644 --- a/src/locales/de/session.json +++ b/src/locales/de/session.json @@ -23,7 +23,13 @@ "setupNotConfigured": "Session-Setup nicht konfiguriert", "setupNoneRed": "Noch kein Fahrzeug oder Setup gespeichert — zum Hinzufügen klicken", "setupNoneOrange": "Kein Setup für diese Session gespeichert — zum Konfigurieren klicken", - "help": "Hilfe" + "help": "Hilfe", + "run": "Lauf {{number}}", + "allRuns": "Alle Läufe", + "dragEighth": "1/8 mi", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mi", + "dragDistanceTitle": "Wertungsdistanz" }, "lapTable": { "noLaps": "Keine Runden erkannt.", @@ -46,7 +52,10 @@ "delta": "Delta", "avgLapLength": "Durchschnittliche Rundenlänge", "avgRunLength": "Durchschnittliche Lauflänge", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "Lauf", + "bestEt": "Beste ET", + "incomplete": "Unvollständig" }, "snapshots": { "trigger": "Snapshots", @@ -219,5 +228,17 @@ "noSetup": "Kein Setup", "saveSelection": "Auswahl speichern", "openGarage": "Garage öffnen" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "Start–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/de/tracks.json b/src/locales/de/tracks.json index 5cf02ca9..55a24f21 100644 --- a/src/locales/de/tracks.json +++ b/src/locales/de/tracks.json @@ -99,7 +99,17 @@ "detectedLaps_other": "{{count}} Runden vom Wegpunkt erkannt.", "createTrack": "Strecke erstellen", "useWaypoint": "Wegpunkt verwenden", - "noTrackNotice": "Keine bekannte Strecke passt zu diesen GPS-Daten. Erstelle eine neue Strecke und füge dann einen Kurs hinzu, um die Rundenzeitmessung zu aktivieren." + "noTrackNotice": "Keine bekannte Strecke passt zu diesen GPS-Daten. Erstelle eine neue Strecke und füge dann einen Kurs hinzu, um die Rundenzeitmessung zu aktivieren.", + "dragTitle": "Drag-Läufe erkannt", + "dragTiming": "Drag-Zeitmessung", + "dragNotice": "Keine bekannte Strecke passt zu diesen Daten, aber sie sehen nach Drag-Strip-Läufen aus: stehende Starts gefolgt von Beschleunigung auf gerader Linie.", + "dragRunsFound_one": "{{count}} Lauf aus dem Stand erkannt.", + "dragRunsFound_other": "{{count}} Läufe aus dem Stand erkannt.", + "dragDistance": "Wertungsdistanz", + "dragApply": "Drag-Zeitmessung verwenden", + "dragEighth": "1/8 Meile (660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 Meile (1320 ft)" }, "submit": { "toastCouldNotRead": "Deine Strecken konnten nicht gelesen werden", diff --git a/src/locales/en/session.json b/src/locales/en/session.json index a17f869c..befb3b5e 100644 --- a/src/locales/en/session.json +++ b/src/locales/en/session.json @@ -22,7 +22,13 @@ "setupNotConfigured": "Session setup not configured", "setupNoneRed": "No vehicle or setup saved yet — click to add one", "setupNoneOrange": "No setup saved for this session — click to configure", - "help": "Help" + "help": "Help", + "run": "Run {{number}}", + "allRuns": "All Runs", + "dragEighth": "1/8 mi", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mi", + "dragDistanceTitle": "Drag scoring distance" }, "lapTable": { "noLaps": "No laps detected.", @@ -45,7 +51,10 @@ "delta": "Delta", "avgLapLength": "Avg Lap Length", "avgRunLength": "Avg Run Length", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "Run", + "bestEt": "Best ET", + "incomplete": "Incomplete" }, "snapshots": { "trigger": "Snapshots", @@ -218,5 +227,17 @@ "noSetup": "No setup", "saveSelection": "Save Selection", "openGarage": "Open Garage" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "Launch–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/en/tracks.json b/src/locales/en/tracks.json index 139e8989..378f0871 100644 --- a/src/locales/en/tracks.json +++ b/src/locales/en/tracks.json @@ -98,7 +98,17 @@ "detectedLaps_other": "Detected {{count}} laps from waypoint.", "createTrack": "Create Track", "useWaypoint": "Use Waypoint", - "noTrackNotice": "No known track matches this GPS data. Create a new track, then add a course to enable lap timing." + "noTrackNotice": "No known track matches this GPS data. Create a new track, then add a course to enable lap timing.", + "dragTitle": "Drag Runs Detected", + "dragTiming": "Drag Timing", + "dragNotice": "No known track matched this data, but it looks like drag-strip passes: standing starts followed by straight-line pulls.", + "dragRunsFound_one": "Detected {{count}} standing-start run.", + "dragRunsFound_other": "Detected {{count}} standing-start runs.", + "dragDistance": "Scoring distance", + "dragApply": "Use Drag Timing", + "dragEighth": "1/8 mile (660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mile (1320 ft)" }, "submit": { "toastCouldNotRead": "Could not read your tracks", diff --git a/src/locales/es/session.json b/src/locales/es/session.json index 8b8bd716..6a1696d4 100644 --- a/src/locales/es/session.json +++ b/src/locales/es/session.json @@ -23,7 +23,13 @@ "setupNotConfigured": "Configuración de sesión no establecida", "setupNoneRed": "Aún no hay vehículo ni reglaje guardado — haz clic para añadir uno", "setupNoneOrange": "No hay reglaje guardado para esta sesión — haz clic para configurar", - "help": "Ayuda" + "help": "Ayuda", + "run": "Pasada {{number}}", + "allRuns": "Todas las pasadas", + "dragEighth": "1/8 mi", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mi", + "dragDistanceTitle": "Distancia de cronometraje" }, "lapTable": { "noLaps": "No se detectaron vueltas.", @@ -46,7 +52,10 @@ "delta": "Delta", "avgLapLength": "Longitud media de vuelta", "avgRunLength": "Longitud media de manga", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "Pasada", + "bestEt": "Mejor ET", + "incomplete": "Incompleta" }, "snapshots": { "trigger": "Instantáneas", @@ -219,5 +228,17 @@ "noSetup": "Sin reglaje", "saveSelection": "Guardar selección", "openGarage": "Abrir garaje" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "Salida–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/es/tracks.json b/src/locales/es/tracks.json index dbdc8b80..04b26ac9 100644 --- a/src/locales/es/tracks.json +++ b/src/locales/es/tracks.json @@ -99,7 +99,17 @@ "detectedLaps_other": "Detectadas {{count}} vueltas desde el waypoint.", "createTrack": "Crear pista", "useWaypoint": "Usar waypoint", - "noTrackNotice": "Ninguna pista conocida coincide con estos datos de GPS. Crea una nueva pista y luego añade un trazado para habilitar el cronometraje de vueltas." + "noTrackNotice": "Ninguna pista conocida coincide con estos datos de GPS. Crea una nueva pista y luego añade un trazado para habilitar el cronometraje de vueltas.", + "dragTitle": "Pasadas de aceleración detectadas", + "dragTiming": "Cronometraje de aceleración", + "dragNotice": "Ninguna pista conocida coincide con estos datos, pero parecen pasadas de una pista de aceleración: salidas desde parado seguidas de tiradas en línea recta.", + "dragRunsFound_one": "Detectada {{count}} pasada desde parado.", + "dragRunsFound_other": "Detectadas {{count}} pasadas desde parado.", + "dragDistance": "Distancia de cronometraje", + "dragApply": "Usar cronometraje de aceleración", + "dragEighth": "1/8 de milla (660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 de milla (1320 ft)" }, "submit": { "toastCouldNotRead": "No se pudieron leer tus pistas", diff --git a/src/locales/fr/session.json b/src/locales/fr/session.json index 2ad23669..8a1d2d44 100644 --- a/src/locales/fr/session.json +++ b/src/locales/fr/session.json @@ -23,7 +23,13 @@ "setupNotConfigured": "Configuration de session non définie", "setupNoneRed": "Aucun véhicule ni réglage enregistré — cliquez pour en ajouter un", "setupNoneOrange": "Aucun réglage enregistré pour cette session — cliquez pour configurer", - "help": "Aide" + "help": "Aide", + "run": "Run {{number}}", + "allRuns": "Tous les runs", + "dragEighth": "1/8 mi", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mi", + "dragDistanceTitle": "Distance de chronométrage" }, "lapTable": { "noLaps": "Aucun tour détecté.", @@ -46,7 +52,10 @@ "delta": "Delta", "avgLapLength": "Longueur moyenne du tour", "avgRunLength": "Longueur moyenne de la manche", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "Run", + "bestEt": "Meilleur ET", + "incomplete": "Incomplet" }, "snapshots": { "trigger": "Instantanés", @@ -219,5 +228,17 @@ "noSetup": "Aucun réglage", "saveSelection": "Enregistrer la sélection", "openGarage": "Ouvrir le garage" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "Départ–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/fr/tracks.json b/src/locales/fr/tracks.json index d51ef0a7..0620ed2e 100644 --- a/src/locales/fr/tracks.json +++ b/src/locales/fr/tracks.json @@ -99,7 +99,17 @@ "detectedLaps_other": "Détecté {{count}} tours depuis le waypoint.", "createTrack": "Créer un circuit", "useWaypoint": "Utiliser le waypoint", - "noTrackNotice": "Aucun circuit connu ne correspond à ces données GPS. Créez un nouveau circuit, puis ajoutez un tracé pour activer le chronométrage des tours." + "noTrackNotice": "Aucun circuit connu ne correspond à ces données GPS. Créez un nouveau circuit, puis ajoutez un tracé pour activer le chronométrage des tours.", + "dragTitle": "Runs d'accélération détectés", + "dragTiming": "Chronométrage drag", + "dragNotice": "Aucun circuit connu ne correspond à ces données, mais elles ressemblent à des runs de dragster : départs arrêtés suivis d'accélérations en ligne droite.", + "dragRunsFound_one": "{{count}} run départ arrêté détecté.", + "dragRunsFound_other": "{{count}} runs départ arrêté détectés.", + "dragDistance": "Distance de chronométrage", + "dragApply": "Utiliser le chrono drag", + "dragEighth": "1/8 de mile (660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 de mile (1320 ft)" }, "submit": { "toastCouldNotRead": "Impossible de lire vos circuits", diff --git a/src/locales/it/session.json b/src/locales/it/session.json index 89a87f78..77262c89 100644 --- a/src/locales/it/session.json +++ b/src/locales/it/session.json @@ -23,7 +23,13 @@ "setupNotConfigured": "Configurazione sessione non impostata", "setupNoneRed": "Nessun veicolo o assetto salvato — clicca per aggiungerne uno", "setupNoneOrange": "Nessun assetto salvato per questa sessione — clicca per configurare", - "help": "Aiuto" + "help": "Aiuto", + "run": "Prova {{number}}", + "allRuns": "Tutte le prove", + "dragEighth": "1/8 mi", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mi", + "dragDistanceTitle": "Distanza di cronometraggio" }, "lapTable": { "noLaps": "Nessun giro rilevato.", @@ -46,7 +52,10 @@ "delta": "Delta", "avgLapLength": "Lunghezza media del giro", "avgRunLength": "Lunghezza media della manche", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "Prova", + "bestEt": "Miglior ET", + "incomplete": "Incompleta" }, "snapshots": { "trigger": "Istantanee", @@ -219,5 +228,17 @@ "noSetup": "Nessun assetto", "saveSelection": "Salva selezione", "openGarage": "Apri garage" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "Partenza–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/it/tracks.json b/src/locales/it/tracks.json index 43fffd14..50bb7190 100644 --- a/src/locales/it/tracks.json +++ b/src/locales/it/tracks.json @@ -99,7 +99,17 @@ "detectedLaps_other": "Rilevati {{count}} giri dal waypoint.", "createTrack": "Crea pista", "useWaypoint": "Usa waypoint", - "noTrackNotice": "Nessuna pista nota corrisponde a questi dati GPS. Crea una nuova pista, poi aggiungi un tracciato per abilitare il cronometraggio dei giri." + "noTrackNotice": "Nessuna pista nota corrisponde a questi dati GPS. Crea una nuova pista, poi aggiungi un tracciato per abilitare il cronometraggio dei giri.", + "dragTitle": "Rilevate prove di accelerazione", + "dragTiming": "Cronometraggio drag", + "dragNotice": "Nessuna pista conosciuta corrisponde a questi dati, ma sembrano passaggi su una pista di accelerazione: partenze da fermo seguite da tirate in rettilineo.", + "dragRunsFound_one": "Rilevata {{count}} prova con partenza da fermo.", + "dragRunsFound_other": "Rilevate {{count}} prove con partenza da fermo.", + "dragDistance": "Distanza di cronometraggio", + "dragApply": "Usa cronometraggio drag", + "dragEighth": "1/8 di miglio (660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 di miglio (1320 ft)" }, "submit": { "toastCouldNotRead": "Impossibile leggere le tue piste", diff --git a/src/locales/ja/session.json b/src/locales/ja/session.json index 9bd58e76..2bec03f1 100644 --- a/src/locales/ja/session.json +++ b/src/locales/ja/session.json @@ -23,7 +23,13 @@ "setupNotConfigured": "セッションのセットアップが未設定です", "setupNoneRed": "車両またはセットアップがまだ保存されていません — クリックして追加", "setupNoneOrange": "このセッションのセットアップが保存されていません — クリックして設定", - "help": "ヘルプ" + "help": "ヘルプ", + "run": "ラン {{number}}", + "allRuns": "全ラン", + "dragEighth": "1/8マイル", + "dragThousand": "1000 ft", + "dragQuarter": "1/4マイル", + "dragDistanceTitle": "ドラッグ計測距離" }, "lapTable": { "noLaps": "ラップが検出されませんでした。", @@ -46,7 +52,10 @@ "delta": "デルタ", "avgLapLength": "平均ラップ長", "avgRunLength": "平均走行距離", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "ラン", + "bestEt": "ベストET", + "incomplete": "未完走" }, "snapshots": { "trigger": "スナップショット", @@ -219,5 +228,17 @@ "noSetup": "セットアップなし", "saveSelection": "選択を保存", "openGarage": "ガレージを開く" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "スタート–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/ja/tracks.json b/src/locales/ja/tracks.json index cb6bc150..d7ca8533 100644 --- a/src/locales/ja/tracks.json +++ b/src/locales/ja/tracks.json @@ -99,7 +99,17 @@ "detectedLaps_other": "ウェイポイントから{{count}}ラップを検出しました。", "createTrack": "トラックを作成", "useWaypoint": "ウェイポイントを使用", - "noTrackNotice": "このGPSデータに一致する既知のトラックがありません。新しいトラックを作成し、コースを追加してラップ計測を有効にしてください。" + "noTrackNotice": "このGPSデータに一致する既知のトラックがありません。新しいトラックを作成し、コースを追加してラップ計測を有効にしてください。", + "dragTitle": "ドラッグ走行を検出", + "dragTiming": "ドラッグ計測", + "dragNotice": "既知のコースに一致しませんでしたが、ドラッグストリップの走行(静止スタートからの直線加速)のようです。", + "dragRunsFound_one": "{{count}}本のスタンディングスタート走行を検出しました。", + "dragRunsFound_other": "{{count}}本のスタンディングスタート走行を検出しました。", + "dragDistance": "計測距離", + "dragApply": "ドラッグ計測を使用", + "dragEighth": "1/8マイル(660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4マイル(1320 ft)" }, "submit": { "toastCouldNotRead": "トラックを読み取れませんでした", diff --git a/src/locales/pt-BR/session.json b/src/locales/pt-BR/session.json index d2a5221a..046aab2e 100644 --- a/src/locales/pt-BR/session.json +++ b/src/locales/pt-BR/session.json @@ -23,7 +23,13 @@ "setupNotConfigured": "Configuração da sessão não definida", "setupNoneRed": "Nenhum veículo ou acerto salvo ainda — clique para adicionar um", "setupNoneOrange": "Nenhum acerto salvo para esta sessão — clique para configurar", - "help": "Ajuda" + "help": "Ajuda", + "run": "Passada {{number}}", + "allRuns": "Todas as passadas", + "dragEighth": "1/8 mi", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 mi", + "dragDistanceTitle": "Distância de cronometragem" }, "lapTable": { "noLaps": "Nenhuma volta detectada.", @@ -46,7 +52,10 @@ "delta": "Delta", "avgLapLength": "Comprimento médio da volta", "avgRunLength": "Comprimento médio da bateria", - "avgLapValue": "{{feet}} ft / {{meters}} m" + "avgLapValue": "{{feet}} ft / {{meters}} m", + "colRun": "Passada", + "bestEt": "Melhor ET", + "incomplete": "Incompleta" }, "snapshots": { "trigger": "Instantâneos", @@ -219,5 +228,17 @@ "noSetup": "Sem acerto", "saveSelection": "Salvar seleção", "openGarage": "Abrir garagem" + }, + "drag": { + "mark60": "60 ft", + "mark330": "330 ft", + "markEighth": "1/8", + "mark1000": "1000 ft", + "markQuarter": "1/4", + "segLaunchTo60": "Largada–60 ft", + "seg60To330": "60–330 ft", + "seg330To660": "330–660 ft", + "seg660To1000": "660–1000 ft", + "seg1000To1320": "1000–1320 ft" } } diff --git a/src/locales/pt-BR/tracks.json b/src/locales/pt-BR/tracks.json index 8eebf0a0..8cc4fee1 100644 --- a/src/locales/pt-BR/tracks.json +++ b/src/locales/pt-BR/tracks.json @@ -99,7 +99,17 @@ "detectedLaps_other": "Detectadas {{count}} voltas do waypoint.", "createTrack": "Criar pista", "useWaypoint": "Usar waypoint", - "noTrackNotice": "Nenhuma pista conhecida corresponde a estes dados de GPS. Crie uma nova pista e depois adicione um traçado para habilitar a cronometragem de voltas." + "noTrackNotice": "Nenhuma pista conhecida corresponde a estes dados de GPS. Crie uma nova pista e depois adicione um traçado para habilitar a cronometragem de voltas.", + "dragTitle": "Arrancadas detectadas", + "dragTiming": "Cronometragem de arrancada", + "dragNotice": "Nenhuma pista conhecida corresponde a estes dados, mas eles parecem passadas de arrancada: largadas paradas seguidas de acelerações em linha reta.", + "dragRunsFound_one": "Detectada {{count}} passada com largada parada.", + "dragRunsFound_other": "Detectadas {{count}} passadas com largada parada.", + "dragDistance": "Distância de cronometragem", + "dragApply": "Usar cronometragem de arrancada", + "dragEighth": "1/8 de milha (660 ft)", + "dragThousand": "1000 ft", + "dragQuarter": "1/4 de milha (1320 ft)" }, "submit": { "toastCouldNotRead": "Não foi possível ler suas pistas", diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 1249e2ae..11192881 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -45,6 +45,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { Course, ParsedData } from "@/types/racing"; import { parseDatalogFile } from "@/lib/datalogParser"; import { calculateDistanceArray } from "@/lib/referenceUtils"; +import type { DragDistanceFt } from "@/lib/dragRunDetection"; import { formatAxisDistance } from "@/lib/chartAxis"; import { cn } from "@/lib/utils"; import { usePanelsForSlot, PanelSlot } from "@/plugins/panels"; @@ -386,8 +387,19 @@ export default function Index() { handleDataLoaded, handleLoadSample, isLoadingSample, handleTrackPromptSelect, trackPromptOpen, setTrackPromptOpen, detectedTrack, detectionResult, allTracks, gpsCenter, + dragDetection, dragDistanceFt, applyDragDistance, clearDragSession, handleUseWaypoint, } = dataLoader; + // Assigning a course from the header TrackEditor also stands down an active + // drag session (its metadata tag is cleared by handleSelectionChange itself). + const handleSelectionChangeWithDragClear = useCallback( + (sel: Parameters[0]) => { + clearDragSession(); + handleSelectionChange(sel); + }, + [clearDragSession, handleSelectionChange], + ); + // Open a saved session by file name — used by the setup/vehicle history cards to // jump straight to the session that holds a card's fastest lap. Loads + parses // the blob like FilesTab does, closes the garage drawer, and (when sitting on a @@ -517,9 +529,23 @@ export default function Index() { brakeMaxG: (settings.brakeMaxG ?? 150) / 100, }), [settings.brakingEntryThreshold, settings.brakingExitThreshold, settings.brakingMinDuration, settings.brakingSmoothingAlpha, settings.brakingZoneColor, settings.brakingZoneWidth, settings.brakingGraphWindow, settings.brakeMaxG]); - const selectedLapTimeMs = selectedLapNumber !== null - ? (laps.find((l) => l.lapNumber === selectedLapNumber)?.lapTimeMs ?? null) + // An incomplete drag run's lapTimeMs is just its data window, not a time — + // suppress it so labels degrade to a bare "Run N" instead of a bogus ET. + const selectedLapForTime = selectedLapNumber !== null + ? laps.find((l) => l.lapNumber === selectedLapNumber) ?? null : null; + const selectedLapTimeMs = selectedLapForTime && !selectedLapForTime.incomplete + ? selectedLapForTime.lapTimeMs + : null; + + // Drag sessions label rows "Run N" through the same lapLabels plumbing the + // read-only leaderboard view uses, so the header dropdown, lap table, and + // graph header all pick it up with no extra wiring. + const dragLapLabels = useMemo(() => { + if (dragDistanceFt == null) return null; + return Object.fromEntries(laps.map((l) => [l.lapNumber, t("header.run", { number: l.lapNumber })])); + }, [dragDistanceFt, laps, t]); + const effectiveLapLabels = dragLapLabels ?? lapLabels; const isAllLaps = selectedLapNumber === null; const minRange = Math.min(10, Math.floor(filteredSamples.length / 10)); @@ -627,8 +653,9 @@ export default function Index() { selectedLapTimeMs, referenceLapNumber, isAllLaps, + dragDistanceFt, readOnly, - lapLabels, + lapLabels: effectiveLapLabels, readOnlyDescriptor, hasReference, paceDiff, @@ -693,7 +720,7 @@ export default function Index() { visibleRange, minRange, selectedCourse, filteredBounds, laps, selectedLapNumber, selectedLapTimeMs, referenceLapNumber, isAllLaps, - readOnly, lapLabels, readOnlyDescriptor, + dragDistanceFt, readOnly, effectiveLapLabels, readOnlyDescriptor, hasReference, paceDiff, paceDiffLabel, slicedPaceData, slicedReferenceSpeedData, deltaTopSpeed, deltaMinSpeed, lapToFastestDelta, refAvgTopSpeed, refAvgMinSpeed, externalRefLabel, savedFiles, @@ -885,19 +912,35 @@ export default function Index() { {!readOnly && ( - + + )} + + {!readOnly && dragDistanceFt != null && ( + )} {laps.length > 0 && ( setSsidPrefix(e.target.value)} + placeholder="X4" + autoComplete="off" + /> +

{t("insta360.ssidPrefixHint")}

+
+
+ + setPassphrase(e.target.value)} + autoComplete="off" + /> +

{t("insta360.passphraseHint")}

+
+ +
+ )} + + {(phase === "connecting" || phase === "listing") && ( +
+ + {phase === "connecting" ? t("insta360.connecting") : t("insta360.listing")} +
+ )} + + {phase === "error" && classified && ( + { setError(null); setPhase("idle"); }} + cancelLabel={t("insta360.back")} + onAction={() => void connect()} + actionLabel={tLogger("errors.actionRetry")} + /> + )} + + {phase === "ready" && ( +
+
+ + {t("insta360.connected", { camera: camera?.cameraType ?? "Insta360" })} + +
+ + +
+
+ {files.length === 0 ? ( +

{t("insta360.noRecordings")}

+ ) : ( +
+ {files.map((f) => { + const when = formatWhen(f, i18n.language); + return ( +
+
+
+ {f.name} + {f.is360 && ( + + {t("insta360.badge360")} + + )} +
+
+ {when ? `${when} · ` : ""}{formatDuration(f.durationMs)} + {f.width > 0 ? ` · ${f.width}×${f.height}` : ""} + {f.segmentCount > 1 ? ` · ${t("insta360.segments", { count: f.segmentCount })}` : ""} +
+
+ +
+ ); + })} +
+ )} +
+ )} + + + ); +} diff --git a/src/components/insta360/Insta360ViewLayer.tsx b/src/components/insta360/Insta360ViewLayer.tsx new file mode 100644 index 00000000..138b40a6 --- /dev/null +++ b/src/components/insta360/Insta360ViewLayer.tsx @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { NativePlayerElement } from "@/lib/insta360/nativePlayer"; +import { dragToPose } from "@/lib/insta360/pose"; +import { DEFAULT_VIEW_POSE, type ViewPose } from "@/lib/insta360/types"; + +interface Insta360ViewLayerProps { + player: NativePlayerElement; + /** The on-screen picture size (CSS px) — drag distances are scaled to it. */ + width: number; + height: number; + /** Locked: the layer is inert and touches fall through to the player UI. */ + locked: boolean; +} + +/** + * Drag-to-point for a 360° camera stream (plan 0025). The picture follows the + * finger the way every 360° player behaves; each move becomes an absolute + * pose the shell steers the native player to. Poses are sent one at a time — + * a drag that outruns the bridge collapses to the latest pose, never a + * queue. Pinch/zoom is deliberately not here yet. + */ +export function Insta360ViewLayer({ player, width, height, locked }: Insta360ViewLayerProps) { + const poseRef = useRef(player.pose ?? DEFAULT_VIEW_POSE); + const lastRef = useRef<{ x: number; y: number } | null>(null); + const inflightRef = useRef(false); + const pendingRef = useRef(null); + const aliveRef = useRef(true); + + useEffect(() => { + aliveRef.current = true; + return () => { aliveRef.current = false; }; + }, []); + + const flush = useCallback(() => { + if (inflightRef.current) return; + const next = pendingRef.current; + if (!next) return; + pendingRef.current = null; + inflightRef.current = true; + void player + .setViewPose(next) + .then((reached) => { poseRef.current = reached; }) + .catch(() => { /* the next drag re-sends; a closed player just ignores us */ }) + .finally(() => { + inflightRef.current = false; + if (aliveRef.current) flush(); + }); + }, [player]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (locked) return; + e.preventDefault(); + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + lastRef.current = { x: e.clientX, y: e.clientY }; + poseRef.current = player.pose ?? poseRef.current; + }, [locked, player]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + const last = lastRef.current; + if (!last || locked) return; + const dx = e.clientX - last.x; + const dy = e.clientY - last.y; + lastRef.current = { x: e.clientX, y: e.clientY }; + const next = dragToPose(pendingRef.current ?? poseRef.current, dx, dy, width, height); + pendingRef.current = next; + flush(); + }, [locked, width, height, flush]); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + if (!lastRef.current) return; + lastRef.current = null; + e.stopPropagation(); + flush(); + }, [flush]); + + if (locked) return null; + return ( +
e.stopPropagation()} + /> + ); +} diff --git a/src/hooks/useVideoSync.ts b/src/hooks/useVideoSync.ts index ab87e310..e72eb5c8 100644 --- a/src/hooks/useVideoSync.ts +++ b/src/hooks/useVideoSync.ts @@ -3,6 +3,8 @@ import { GpsSample } from "@/types/racing"; import { saveVideoSync, loadVideoSync, VideoSyncRecord, VideoSyncChunk } from "@/lib/videoStorage"; import { loadSessionVideo, hasSessionVideo, deleteSessionVideo, getSessionVideoMeta, type StoredVideoMeta } from "@/lib/videoFileStorage"; import { getNativeStoredVideo, storeNativeVideo } from "@/lib/nativeVideoStore"; +import { NativePlayerElement, type VideoSurface } from "@/lib/insta360/nativePlayer"; +import type { Insta360CameraFile } from "@/lib/insta360/types"; import { isNativeApp } from "@/lib/platform"; import type { OverlaySettings } from "@/components/video-overlays/types"; import { DEFAULT_OVERLAY_SETTINGS } from "@/components/video-overlays/types"; @@ -24,6 +26,18 @@ interface UseVideoSyncOptions { dataIsPlaying: boolean; } +/** + * Native shell: a video streamed by the shell's own player (plan 0025 — + * Insta360 camera recordings) instead of a