From 212d8f9632d99641de09b6c3c3c05fec5ba87d61 Mon Sep 17 00:00:00 2001 From: Aileen Villanueva Date: Sat, 23 May 2026 14:35:50 -0700 Subject: [PATCH 1/4] Fix track walk interaction and data loading --- src/pwa/src/pages/track-walk/TrackWalk.vue | 54 +++++++-- .../src/pages/track-walk/trackWalkModel.ts | 60 ++++++++++ src/pwa/src/shared/ui/core/TrackMap.vue | 1 + src/pwa/tests/trackWalkModel.test.ts | 107 ++++++++++++++++++ 4 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/pwa/src/pages/track-walk/trackWalkModel.ts create mode 100644 src/pwa/tests/trackWalkModel.test.ts diff --git a/src/pwa/src/pages/track-walk/TrackWalk.vue b/src/pwa/src/pages/track-walk/TrackWalk.vue index 4e86dc4..7147d43 100644 --- a/src/pwa/src/pages/track-walk/TrackWalk.vue +++ b/src/pwa/src/pages/track-walk/TrackWalk.vue @@ -33,6 +33,7 @@ import PageShell from '@/shared/ui/PageShell.vue' import CoachFloat from '@/shared/ui/CoachFloat.vue' import TrackMap from '@/shared/ui/core/TrackMap.vue' import CornerScorecard from '@/shared/ui/core/CornerScorecard.vue' +import { buildTurnIdToCornerIndex, pickTrackWalkSessionId } from './trackWalkModel' const router = useRouter() const save = useSaveStore() @@ -113,6 +114,8 @@ interface MarkerPin extends MarkerRow { } const markerPins = ref([]) const selectedMarker = ref(null) +const cornerPins = ref>([]) +const turnIdToCornerIndex = ref>({}) const KIND_COLOR: Record = { brake_ref: 'fill-ui-warn', // amber — slow-down landmark @@ -244,11 +247,13 @@ interface BridgeCornerRow { } onMounted(async () => { + if (sessionStore.sessions.length === 0) { + await sessionStore.fetchSessions() + } + // Pick the most recent session that actually has laps. Falls back to the // active session if it's the only one. No magic 'demo-session' fallback. - const sid = sessionStore.activeSessionId - ?? sessionStore.sessions.find(s => (s.lap_count ?? 0) > 0)?.session_id - ?? null + const sid = pickTrackWalkSessionId(sessionStore.activeSessionId, sessionStore.sessions) if (sid) { sessionUsed.value = sid @@ -331,6 +336,17 @@ async function resolvePinPositions() { if (closest) c.svgTurnId = closest.id }) + cornerPins.value = corners.value.map((c) => { + const pt = r.getPointAtProgress(c.progress) + return { id: c.id, x: pt.x, y: pt.y } + }) + + turnIdToCornerIndex.value = buildTurnIdToCornerIndex( + corners.value, + r.trackTurns, + (progress: number) => r.getPointAtProgress(progress), + ) + // Phase 1: project each marker's `distance` along the track path to an // SVG (x, y). Markers without a distance are skipped (we don't have lat/ // lon → SVG projection here; track-path-relative distance is the source). @@ -364,6 +380,19 @@ async function resolvePinPositions() { tryOnce() } +function openCorner(index: number) { + cursorIndex.value = index + selectedMarker.value = null + state.value = 'corner-detail' + audio.playSfx('cursor_select') +} + +function openCornerByTurnId(turnId: number) { + const index = turnIdToCornerIndex.value[turnId] + if (index == null || !corners.value[index]) return + openCorner(index) +} + useKeyboard((e: KeyboardEvent) => { if (state.value === 'corner-detail') { if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'b') { @@ -499,11 +528,22 @@ const idleCoachLine = computed(() => { ref="trackMapRef" class="opacity-60 text-slate" :activeTurnId="selectedCorner.svgTurnId" - @turn-click="(id: number) => { - const idx = corners.findIndex(c => c.svgTurnId === id) - if (idx !== -1) { cursorIndex = idx; state = 'corner-detail' } - }" + @turn-click="openCornerByTurnId" > + + + + diff --git a/src/pwa/src/pages/track-walk/trackWalkModel.ts b/src/pwa/src/pages/track-walk/trackWalkModel.ts new file mode 100644 index 0000000..c3c3b84 --- /dev/null +++ b/src/pwa/src/pages/track-walk/trackWalkModel.ts @@ -0,0 +1,60 @@ +import type { SessionSummary } from '@/entities/session/model/sessionStore' + +interface CornerProgressLike { + progress: number +} + +interface TrackTurnLike { + id: number + cx: number + cy: number +} + +interface PointLike { + x: number + y: number +} + +export function pickTrackWalkSessionId( + activeSessionId: string | null, + sessions: SessionSummary[], +): string | null { + const withLaps = sessions.find((session) => (session.lap_count ?? 0) > 0) + const activeSession = activeSessionId + ? sessions.find((session) => session.session_id === activeSessionId) + : null + + if (activeSession && (activeSession.lap_count ?? 0) > 0) { + return activeSessionId + } + + return withLaps?.session_id ?? activeSessionId ?? null +} + +export function buildTurnIdToCornerIndex( + corners: CornerProgressLike[], + trackTurns: TrackTurnLike[], + getPointAtProgress: (progress: number) => PointLike, +): Record { + const mapping: Record = {} + + trackTurns.forEach((turn) => { + let closestIndex = -1 + let minDistance = Number.POSITIVE_INFINITY + + corners.forEach((corner, index) => { + const point = getPointAtProgress(corner.progress) + const distance = Math.hypot(turn.cx - point.x, turn.cy - point.y) + if (distance < minDistance) { + minDistance = distance + closestIndex = index + } + }) + + if (closestIndex !== -1) { + mapping[turn.id] = closestIndex + } + }) + + return mapping +} diff --git a/src/pwa/src/shared/ui/core/TrackMap.vue b/src/pwa/src/shared/ui/core/TrackMap.vue index c6b5450..c119d9f 100644 --- a/src/pwa/src/shared/ui/core/TrackMap.vue +++ b/src/pwa/src/shared/ui/core/TrackMap.vue @@ -85,6 +85,7 @@ onMounted(updateCarPos) :aria-current="activeTurnId === t.id" style="transform-origin: center; transform-box: fill-box;" > + bool: """Check if a session has any telemetry frames.""" if not state.has_duckdb: diff --git a/src/pitwall/features/coaching/bp_coaching.py b/src/pitwall/features/coaching/bp_coaching.py index 80f5712..bb71416 100644 --- a/src/pitwall/features/coaching/bp_coaching.py +++ b/src/pitwall/features/coaching/bp_coaching.py @@ -88,6 +88,32 @@ def _score_insights(bursts): for i,ins in enumerate(insights[:3],1): ins["rank"]=i return insights[:3] + +def _normalize_debrief_bundle(bundle: dict) -> dict: + """Expose a stable debrief contract to the PWA. + + `session_analyzer` historically returned `narrative_md` + `next_focus`, + while the PWA's coach store expects `narrative` + `focus`. Keep the + legacy keys intact, but always populate the frontend-facing aliases so + fallback/non-ADK paths render instead of silently disappearing. + """ + narrative = bundle.get("narrative") + if not isinstance(narrative, str) or not narrative.strip(): + legacy_narrative = bundle.get("narrative_md") + narrative = legacy_narrative if isinstance(legacy_narrative, str) else "" + + focus = bundle.get("focus") + if not isinstance(focus, list): + focus = bundle.get("next_focus") + if isinstance(focus, list): + focus = [str(item).strip() for item in focus if str(item).strip()][:3] + else: + focus = [] + + bundle["narrative"] = narrative + bundle["focus"] = focus + return bundle + @bp.route("/insights", methods=["GET"]) def get_insights(): """Return top-3 prioritised driver insights from the current session bursts.""" @@ -115,6 +141,7 @@ def coach_debrief(): frames = load_session_frames(sid) if not frames: return jsonify({"error":"no telemetry — push frames or pass vbo_path","session_id":sid}), 400 bundle = analyze_session(session_id=sid, frames=frames, coach=state.coach if state.has_coach else None, driver_level=getattr(state.coach,"driver_level","intermediate") if state.coach else "intermediate") + bundle = _normalize_debrief_bundle(bundle) if state.has_adk: try: adk_prompt = f"Generate a post-session debrief for session '{sid}', driver '{driver_id}'. Query DuckDB for lap times, corner grades, coaching notes. Structure: 1 highlight sentence, then FOCUS list of 3 items." @@ -132,7 +159,9 @@ def coach_debrief(): adk_narrative, _em_val = extract_emotion(adk_narrative) if _em_val != "neutral": bundle["emotion"] = _em_val - bundle["narrative"]=adk_narrative; bundle["narrative_source"]="adk" + if adk_narrative.strip(): + bundle["narrative"] = adk_narrative + bundle["narrative_source"] = "adk" except (ConnectionError, TimeoutError, OSError, RuntimeError, json.JSONDecodeError) as _e: log.warning("ADK debrief failed (%s: %s)", type(_e).__name__, _e) with state.bundles_lock: state.session_bundles[sid] = bundle diff --git a/src/pitwall/features/session/bp_analysis.py b/src/pitwall/features/session/bp_analysis.py index c7770e3..f51426d 100644 --- a/src/pitwall/features/session/bp_analysis.py +++ b/src/pitwall/features/session/bp_analysis.py @@ -10,7 +10,7 @@ from flask import Blueprint, request, jsonify from pitwall.state import state, SIM_DIR -from pitwall.db import db_conn, DuckDbUnavailable, session_has_telemetry +from pitwall.db import db_conn, DuckDbUnavailable, session_exists, session_has_telemetry from pitwall.features.session.laps import detect_laps, lap_sectors, quantile from pitwall.features.track.track_json import load_track_json, corner_bounds_from_track @@ -20,8 +20,13 @@ def _laps_or_400(sid: str): Inlined from pitwall.helpers.laps_or_400 — Flask-coupled, used only here. """ - if not session_has_telemetry(sid): + if not session_exists(sid): return None, (jsonify({"error": "session not found", "session_id": sid}), 404) + if not session_has_telemetry(sid): + return None, (jsonify({ + "error": "no telemetry recorded yet", + "session_id": sid, + }), 409) laps = detect_laps(sid) if not laps: return None, (jsonify({ diff --git a/src/pitwall/features/track/bp_track.py b/src/pitwall/features/track/bp_track.py index 56d248c..a4f7ca1 100644 --- a/src/pitwall/features/track/bp_track.py +++ b/src/pitwall/features/track/bp_track.py @@ -4,7 +4,7 @@ from datetime import datetime from flask import Blueprint, request, jsonify from pitwall.state import state, SIM_DIR -from pitwall.db import db_conn, DuckDbUnavailable, session_has_telemetry, iso +from pitwall.db import db_conn, DuckDbUnavailable, session_exists, session_has_telemetry, iso from pitwall.features.session.laps import detect_laps, lap_sectors, quantile from pitwall.features.session.driver_profile import compute_profile from pitwall.features.track.track_json import load_track_json, corner_bounds_from_track @@ -15,8 +15,13 @@ def _laps_or_400(sid: str): Inlined from pitwall.helpers.laps_or_400 — Flask-coupled, used only here. """ - if not session_has_telemetry(sid): + if not session_exists(sid): return None, (jsonify({"error": "session not found", "session_id": sid}), 404) + if not session_has_telemetry(sid): + return None, (jsonify({ + "error": "no telemetry recorded yet", + "session_id": sid, + }), 409) laps = detect_laps(sid) if not laps: return None, (jsonify({ diff --git a/src/pwa/src/app/App.vue b/src/pwa/src/app/App.vue index 74d8e18..c151ba3 100644 --- a/src/pwa/src/app/App.vue +++ b/src/pwa/src/app/App.vue @@ -32,6 +32,7 @@ const viewport = useViewport() const isPortrait = computed(() => viewport.isPortrait) const handleGlobalKey = (e: KeyboardEvent) => { + if ((e as KeyboardEvent & { __pitwallHintTap?: boolean }).__pitwallHintTap) return if (isPortrait.value) return // Block input in portrait // Allow toggling pause with Escape anywhere except Title screen diff --git a/src/pwa/src/entities/coach/model/coachStore.ts b/src/pwa/src/entities/coach/model/coachStore.ts index 90a1874..110373c 100644 --- a/src/pwa/src/entities/coach/model/coachStore.ts +++ b/src/pwa/src/entities/coach/model/coachStore.ts @@ -25,9 +25,11 @@ export interface DebriefResponse { scorecard?: Record highlights?: any[] narrative?: string + narrative_md?: string narrative_source?: string emotion?: string focus?: string[] + next_focus?: string[] [key: string]: any } @@ -63,6 +65,32 @@ export interface ConversationRow { recorded_at: string | null } +function normalizeFocusItems(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value + .map((item) => String(item).trim()) + .filter(Boolean) + .slice(0, 3) +} + +function normalizeDebriefResponse(payload: DebriefResponse): DebriefResponse { + const narrative = + typeof payload.narrative === 'string' && payload.narrative.trim() + ? payload.narrative + : typeof payload.narrative_md === 'string' && payload.narrative_md.trim() + ? payload.narrative_md + : payload.narrative + + const focus = normalizeFocusItems(payload.focus) + const nextFocus = focus.length > 0 ? focus : normalizeFocusItems(payload.next_focus) + + return { + ...payload, + narrative, + focus: nextFocus, + } +} + // ── Store ──────────────────────────────────────────────────────────────────── export const useCoachStore = defineStore('coach', { @@ -156,11 +184,12 @@ export const useCoachStore = defineStore('coach', { loading.start('Analyzing Session...', 'adk') this.debriefError = null try { - this.debrief = await bridge.post('/coach/debrief', { + const response = await bridge.post('/coach/debrief', { session_id: opts.sessionId, driver_id: opts.driverId ?? '', vbo_path: opts.vboPath, }) + this.debrief = normalizeDebriefResponse(response) } catch (e: any) { this.debriefError = e.message ?? String(e) console.warn('[coachStore] fetchDebrief failed:', e) diff --git a/src/pwa/src/features/coach-interaction/model/cueStore.ts b/src/pwa/src/features/coach-interaction/model/cueStore.ts index 6da278c..9efcc91 100644 --- a/src/pwa/src/features/coach-interaction/model/cueStore.ts +++ b/src/pwa/src/features/coach-interaction/model/cueStore.ts @@ -8,6 +8,31 @@ export interface Cue { timestamp: number } +function normalizeCue(payload: unknown): Cue | null { + if (!payload || typeof payload !== 'object') return null + + const raw = payload as Record + const text = typeof raw.text === 'string' ? raw.text : '' + if (!text.trim()) return null + + const idSource = raw.id ?? raw.phrase_id ?? raw.burst_id ?? raw.ts + const timestamp = + typeof raw.timestamp === 'number' + ? raw.timestamp + : typeof raw.ts === 'number' + ? raw.ts + : Date.now() + + return { + id: idSource == null ? String(timestamp) : String(idSource), + text, + emotion: typeof raw.emotion === 'string' && raw.emotion.trim() + ? raw.emotion + : 'neutral', + timestamp, + } +} + export const useCueStore = defineStore('cue', { state: () => ({ activeCue: null as Cue | null, @@ -39,12 +64,22 @@ export const useCueStore = defineStore('cue', { _connect(sid: string) { this._es = new EventSource(`${API_BASE}/cues/stream?session_id=${sid}`) - - this._es.onmessage = (e) => { - const cue = JSON.parse(e.data) as Cue - this.queue.push(cue) - this._retryCount = 0 // reset on successful message + + const handleCue = (e: MessageEvent) => { + try { + const cue = normalizeCue(JSON.parse(e.data)) + if (!cue) return + this.queue.push(cue) + this._retryCount = 0 // reset on successful message + } catch (err) { + console.error('Failed to parse cue', err) + } } + + // The bridge emits named `cue` SSE events. Keep `onmessage` as a + // compatibility fallback for older unnamed-message streams. + this._es.addEventListener('cue', handleCue as EventListener) + this._es.onmessage = handleCue this._es.onerror = () => { this.activeCue = null @@ -67,6 +102,7 @@ export const useCueStore = defineStore('cue', { this._es?.close() this._es = null this._retryCount = 0 + this.clearQueue() } } }) diff --git a/src/pwa/src/pages/pre-brief/PreBrief.vue b/src/pwa/src/pages/pre-brief/PreBrief.vue index 1a322fe..9cb6741 100644 --- a/src/pwa/src/pages/pre-brief/PreBrief.vue +++ b/src/pwa/src/pages/pre-brief/PreBrief.vue @@ -67,6 +67,18 @@ onMounted(async () => { const cursorIndex = ref(0) // 0-3 for goals, 4 for confirm useKeyboard((e: KeyboardEvent) => { + if (e.key === 'w' || e.key === 'W' || e.key === ' ') { + audio.playSfx('cursor_select') + router.push('/analysis/track') + return + } + + if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'b') { + audio.playSfx('cancel') + router.push('/garage') + return + } + if (phase.value !== 'goals') return if (e.key === 'ArrowDown') { @@ -83,12 +95,6 @@ useKeyboard((e: KeyboardEvent) => { } } else if (e.key === 's' || e.key === 'S') { confirmSelection() - } else if (e.key === 'w' || e.key === 'W' || e.key === ' ') { - audio.playSfx('cursor_select') - router.push('/analysis/track') - } else if (e.key === 'Escape' || e.key === 'Backspace' || e.key === 'b') { - audio.playSfx('cancel') - router.push('/garage') } }) diff --git a/src/pwa/src/pages/track-walk/TrackWalk.vue b/src/pwa/src/pages/track-walk/TrackWalk.vue index 7147d43..ebfff2e 100644 --- a/src/pwa/src/pages/track-walk/TrackWalk.vue +++ b/src/pwa/src/pages/track-walk/TrackWalk.vue @@ -33,7 +33,7 @@ import PageShell from '@/shared/ui/PageShell.vue' import CoachFloat from '@/shared/ui/CoachFloat.vue' import TrackMap from '@/shared/ui/core/TrackMap.vue' import CornerScorecard from '@/shared/ui/core/CornerScorecard.vue' -import { buildTurnIdToCornerIndex, pickTrackWalkSessionId } from './trackWalkModel' +import { buildTrackWalkCornerPins, buildTurnIdToCornerIndex, pickTrackWalkSessionId } from './trackWalkModel' const router = useRouter() const save = useSaveStore() @@ -55,6 +55,7 @@ interface CornerView { apex: number | null exit: number | null time: number | null + statsSource: 'session' | 'none' /** * Honest deltas: only `apex` populated when the bridge returns * `gold_delta_kmh`. The other three stay null until the bridge @@ -71,17 +72,17 @@ interface CornerView { // canonical form here is "Turn N" (the sonoma JSON convention), and // "The Carousel" for T6. const STATIC_CORNERS: CornerView[] = [ - { id: 'T1', progress: 8, name: 'Turn 1', tip: 'Keep it pinned, eyes up the hill.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T2', progress: 14, name: 'Turn 2', tip: 'Brake at the bridge, late apex — rolls off camber on exit.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T3', progress: 18, name: 'Turn 3', tip: 'Crest the hill, do not lift. T3 is a give-away — sacrifice for T3a/T4.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T4', progress: 24, name: 'Turn 4', tip: 'Downhill braking — rear gets light. Trail brake gently.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T5', progress: 30, name: 'Turn 5', tip: 'Throwaway corner — preserve T6 entry, do not rush the throttle.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T6', progress: 45, name: 'The Carousel', tip: 'Long constant radius. Distance is king — cut the inside, do not open up.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T7', progress: 55, name: 'Turn 7', tip: 'Single apex, treat as double — cut entry, rotate, hit second apex.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T8', progress: 65, name: 'Turn 8', tip: 'Esses begin. Rhythm is everything — link the inputs.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T9', progress: 70, name: 'Turn 9', tip: 'Open up nine — straight shot to ten.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T10', progress: 80, name: 'Turn 10', tip: 'Fastest corner. Most drivers brake when they only need a lift.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, - { id: 'T11', progress: 90, name: 'Turn 11', tip: 'No painted brake board — the bump is the reference. Wait for the car to settle.', grade: '--', entry: null, apex: null, exit: null, time: null, deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T1', progress: 8, name: 'Turn 1', tip: 'Keep it pinned, eyes up the hill.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T2', progress: 14, name: 'Turn 2', tip: 'Brake at the bridge, late apex — rolls off camber on exit.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T3', progress: 18, name: 'Turn 3', tip: 'Crest the hill, do not lift. T3 is a give-away — sacrifice for T3a/T4.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T4', progress: 24, name: 'Turn 4', tip: 'Downhill braking — rear gets light. Trail brake gently.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T5', progress: 30, name: 'Turn 5', tip: 'Throwaway corner — preserve T6 entry, do not rush the throttle.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T6', progress: 45, name: 'The Carousel', tip: 'Long constant radius. Distance is king — cut the inside, do not open up.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T7', progress: 55, name: 'Turn 7', tip: 'Single apex, treat as double — cut entry, rotate, hit second apex.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T8', progress: 65, name: 'Turn 8', tip: 'Esses begin. Rhythm is everything — link the inputs.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T9', progress: 70, name: 'Turn 9', tip: 'Open up nine — straight shot to ten.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T10', progress: 80, name: 'Turn 10', tip: 'Fastest corner. Most drivers brake when they only need a lift.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, + { id: 'T11', progress: 90, name: 'Turn 11', tip: 'No painted brake board — the bump is the reference. Wait for the car to settle.', grade: '--', entry: null, apex: null, exit: null, time: null, statsSource: 'none', deltas: { entry: null, apex: null, exit: null, time: null } }, ] const corners = ref(STATIC_CORNERS.map(c => ({ ...c, deltas: { ...c.deltas } }))) @@ -91,6 +92,7 @@ const state = ref<'idle' | 'corner-detail' | 'no-data' | 'marker-detail'>('idle' const sessionUsed = ref(null) const selectedCorner = computed(() => corners.value[cursorIndex.value]) +const hasSessionMetrics = computed(() => corners.value.some((corner) => corner.statsSource === 'session')) // ── Phase 1: Marker pins layer ───────────────────────────────────────────── @@ -101,8 +103,11 @@ interface MarkerRow { kind: string corner: string | null distance: number | null + at_offset_m_from_entry?: number | null lat?: number lon?: number + note?: string + source?: string } const markers = ref([]) @@ -120,10 +125,46 @@ const turnIdToCornerIndex = ref>({}) const KIND_COLOR: Record = { brake_ref: 'fill-ui-warn', // amber — slow-down landmark apex_ref: 'fill-ui-good', // green — turn-in / apex landmark + turn_in_ref: 'fill-ui-info', // cyan — initial steering reference reference: 'fill-ui-info', // blue — generic reference visual: 'fill-silver', // grey — visual-only landmark } +function markerKindLabel(kind: string): string { + return kind.replaceAll('_', ' ') +} + +function markerKindSummary(kind: string): string { + if (kind === 'brake_ref') return 'Brake reference' + if (kind === 'apex_ref') return 'Apex reference' + if (kind === 'turn_in_ref') return 'Turn-in reference' + if (kind === 'visual') return 'Visual reference' + return 'Track reference' +} + +function markerKindDescription(kind: string): string { + if (kind === 'brake_ref') { + return 'Use this landmark to anchor the braking point before turn-in.' + } + if (kind === 'apex_ref') { + return 'Use this landmark to lock your eyes on the apex and repeat the line.' + } + if (kind === 'turn_in_ref') { + return 'Use this landmark as the cue to begin steering input into the corner.' + } + if (kind === 'visual') { + return 'Use this landmark to stay oriented and keep your vision farther ahead.' + } + return 'Reference point surfaced from the backend track guide.' +} + +function markerOffsetLabel(offset: number | null | undefined): string | null { + if (offset == null) return null + if (offset === 0) return 'at corner entry' + if (offset > 0) return `+${offset.toFixed(0)} m after entry` + return `${offset.toFixed(0)} m before entry` +} + // ── Phase 3: Layers menu ─────────────────────────────────────────────────── // Layer prefs persist across reloads via localStorage (single key, shared @@ -279,6 +320,7 @@ onMounted(async () => { c.apex = r.best_pass.apex_speed_kmh c.exit = r.best_pass.exit_speed_kmh c.time = r.best_pass.corner_time_s + c.statsSource = 'session' } // Per-leg gold deltas now all real — bridge populates entry / apex / // exit / time from sonoma_gold.json. Nulls only when the gold record @@ -314,8 +356,7 @@ onMounted(async () => { resolvePinPositions() }) -/** Resolve corner `progress` → SVG turn id via TrackMap helpers. Race-tolerant - * (no fixed setTimeout). Same pattern as TrackAtlas. */ +/** Resolve interactive overlays once the SVG is mounted. */ async function resolvePinPositions() { await nextTick() let tries = 5 @@ -325,28 +366,17 @@ async function resolvePinPositions() { if (--tries > 0) requestAnimationFrame(tryOnce) return } - corners.value.forEach((c) => { - const pt = r.getPointAtProgress(c.progress) - let closest: any = null - let minDist = Infinity - r.trackTurns.forEach((t: any) => { - const dist = Math.hypot(t.cx - pt.x, t.cy - pt.y) - if (dist < minDist) { minDist = dist; closest = t } - }) - if (closest) c.svgTurnId = closest.id - }) - - cornerPins.value = corners.value.map((c) => { - const pt = r.getPointAtProgress(c.progress) - return { id: c.id, x: pt.x, y: pt.y } + const resolvedCornerPins = buildTrackWalkCornerPins(corners.value, r.trackTurns) + cornerPins.value = resolvedCornerPins.map(({ id, x, y }) => ({ id, x, y })) + turnIdToCornerIndex.value = buildTurnIdToCornerIndex(corners.value) + + corners.value.forEach((corner, index) => { + const pin = resolvedCornerPins[index] + if (pin?.turnId != null) { + corner.svgTurnId = pin.turnId + } }) - turnIdToCornerIndex.value = buildTurnIdToCornerIndex( - corners.value, - r.trackTurns, - (progress: number) => r.getPointAtProgress(progress), - ) - // Phase 1: project each marker's `distance` along the track path to an // SVG (x, y). Markers without a distance are skipped (we don't have lat/ // lon → SVG projection here; track-path-relative distance is the source). @@ -442,11 +472,10 @@ useKeyboard((e: KeyboardEvent) => { const idleCoachLine = computed(() => { if (state.value === 'no-data') { - return `No telemetry on ${selectedCorner.value.name} yet — drive a session and these corners come alive.` + return `No completed backend lap has touched ${selectedCorner.value.name} yet — start and finish a real session and these corners come alive.` } - const graded = corners.value.filter(c => c.grade !== '--' && c.grade !== 'ungraded').length - if (graded === 0) { - return 'Tap any corner to walk through it. Grades populate after your first session.' + if (!hasSessionMetrics.value) { + return 'Tap any corner to walk through it. Grades and per-corner speeds populate after your first completed real session.' } return 'Tap any corner. Red grades mean you are losing time — those are tomorrow\'s focus.' }) @@ -454,7 +483,7 @@ const idleCoachLine = computed(() => { @@ -526,7 +555,7 @@ const idleCoachLine = computed(() => {
@@ -537,8 +566,11 @@ const idleCoachLine = computed(() => { :cx="pin.x" :cy="pin.y" r="72" - fill="rgba(0,0,0,0.001)" + fill="transparent" + pointer-events="all" class="cursor-pointer" + role="button" + tabindex="0" :aria-label="`Open ${corners[index]?.name ?? pin.id}`" @click="openCorner(index)" /> @@ -637,25 +669,38 @@ const idleCoachLine = computed(() => { "{{ selectedMarker.label }}" - {{ selectedMarker.kind.replace('_', ' ') }} + {{ markerKindLabel(selectedMarker.kind) }} · {{ selectedMarker.corner }}
-
- Brake reference — use this landmark to nail your braking point. The bridge, the cracks in the pavement, the boards — these are the things the coach refers to in pace-note shorthand. +
+ {{ markerKindSummary(selectedMarker.kind) }}
-
- Apex reference — aim for this on turn-in / through the corner. Visual lock on the curb or wall edge keeps your line consistent lap-over-lap. +
+ {{ markerKindDescription(selectedMarker.kind) }}
-
- Visual reference — peripheral landmark for staying oriented on a fast section. Useful when you need eyes far ahead. +
+ {{ selectedMarker.note }}
-
- Reference point on the track. +
+ Corner + {{ selectedMarker.corner ?? '—' }} + Distance + {{ selectedMarker.distance != null ? `${selectedMarker.distance.toFixed(0)} m` : '—' }} + Offset + {{ markerOffsetLabel(selectedMarker.at_offset_m_from_entry) ?? '—' }} + Source + {{ selectedMarker.source ? selectedMarker.source.toUpperCase() : '—' }} + GPS + + {{ selectedMarker.lat != null && selectedMarker.lon != null + ? `${selectedMarker.lat.toFixed(6)}, ${selectedMarker.lon.toFixed(6)}` + : '—' }} +
-
- distance from start/finish: {{ selectedMarker.distance.toFixed(0) }} m +
+ Marker id: {{ selectedMarker.id }}
A / B / Esc — close
diff --git a/src/pwa/src/pages/track-walk/trackWalkModel.ts b/src/pwa/src/pages/track-walk/trackWalkModel.ts index c3c3b84..57c193c 100644 --- a/src/pwa/src/pages/track-walk/trackWalkModel.ts +++ b/src/pwa/src/pages/track-walk/trackWalkModel.ts @@ -1,7 +1,7 @@ import type { SessionSummary } from '@/entities/session/model/sessionStore' -interface CornerProgressLike { - progress: number +interface CornerIdLike { + id: string } interface TrackTurnLike { @@ -10,9 +10,25 @@ interface TrackTurnLike { cy: number } -interface PointLike { +interface CornerPinLike { + id: string x: number y: number + turnId: number | null +} + +const TRACK_WALK_TURN_IDS: Record = { + T1: { primary: 5, aliases: [5] }, + T2: { primary: 0, aliases: [0] }, + T3: { primary: 6, aliases: [6, 7] }, + T4: { primary: 1, aliases: [1, 2] }, + T5: { primary: 3, aliases: [3] }, + T6: { primary: 4, aliases: [4] }, + T7: { primary: 8, aliases: [8, 16] }, + T8: { primary: 9, aliases: [9, 10] }, + T9: { primary: 11, aliases: [11, 12] }, + T10: { primary: 13, aliases: [13] }, + T11: { primary: 14, aliases: [14, 15] }, } export function pickTrackWalkSessionId( @@ -32,29 +48,41 @@ export function pickTrackWalkSessionId( } export function buildTurnIdToCornerIndex( - corners: CornerProgressLike[], - trackTurns: TrackTurnLike[], - getPointAtProgress: (progress: number) => PointLike, + corners: CornerIdLike[], ): Record { const mapping: Record = {} - trackTurns.forEach((turn) => { - let closestIndex = -1 - let minDistance = Number.POSITIVE_INFINITY - - corners.forEach((corner, index) => { - const point = getPointAtProgress(corner.progress) - const distance = Math.hypot(turn.cx - point.x, turn.cy - point.y) - if (distance < minDistance) { - minDistance = distance - closestIndex = index - } + corners.forEach((corner, index) => { + const config = TRACK_WALK_TURN_IDS[corner.id] + if (!config) return + config.aliases.forEach((turnId) => { + mapping[turnId] = index }) - - if (closestIndex !== -1) { - mapping[turn.id] = closestIndex - } }) return mapping } + +export function buildTrackWalkCornerPins( + corners: CornerIdLike[], + trackTurns: TrackTurnLike[], +): CornerPinLike[] { + const turnsById = new Map(trackTurns.map((turn) => [turn.id, turn])) + + return corners.map((corner) => { + const config = TRACK_WALK_TURN_IDS[corner.id] + const orderedIds = config + ? [config.primary, ...config.aliases.filter((turnId) => turnId !== config.primary)] + : [] + const turn = orderedIds + .map((turnId) => turnsById.get(turnId)) + .find((candidate) => candidate != null) + + return { + id: corner.id, + x: turn?.cx ?? 0, + y: turn?.cy ?? 0, + turnId: turn?.id ?? null, + } + }) +} diff --git a/src/pwa/src/shared/ui/core/CornerScorecard.vue b/src/pwa/src/shared/ui/core/CornerScorecard.vue index 9b60445..62b66f0 100644 --- a/src/pwa/src/shared/ui/core/CornerScorecard.vue +++ b/src/pwa/src/shared/ui/core/CornerScorecard.vue @@ -32,6 +32,7 @@ interface Corner { exit?: number | null time?: number | null deltas: CornerDeltas + statsSource?: 'session' | 'none' } defineProps<{ @@ -61,6 +62,11 @@ function deltaDisplay(d: number | null, kind: 'speed' | 'time' = 'speed'): { tex cls: better ? 'text-ui-good' : 'text-ui-bad', } } + +function statsHeading(corner: Corner): string { + if (corner.statsSource === 'session') return `YOUR BEST AT ${corner.id}` + return `NO RECORDED LAP AT ${corner.id}` +}