diff --git a/frontend/src/components/ChapterStrip.tsx b/frontend/src/components/ChapterStrip.tsx index 13b504e..1a3b801 100644 --- a/frontend/src/components/ChapterStrip.tsx +++ b/frontend/src/components/ChapterStrip.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' +import { BookOpen } from 'lucide-react' import type { Chapter } from '../types' import { activeChapterIndex, @@ -5,6 +7,7 @@ import { chapterLapRange, chapterStartScrub, } from '../lib/chapters' +import { EmptyStateCard } from './EmptyStateCard' import '../styles/chapters.css' interface Props { @@ -28,16 +31,32 @@ export function ChapterStrip({ onChapterClick, onTourToggle, }: Props) { + const scrollRef = useRef(null) + const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange) + const highlightedIndex = tourActive ? tourChapterIndex : activeIndex + + useEffect(() => { + if (highlightedIndex === null || !scrollRef.current) return + const card = scrollRef.current.querySelector( + `[data-testid="chapter-card-${highlightedIndex}"]`, + ) + card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' }) + }, [highlightedIndex]) + if (chapters.length === 0) { return (
-

No story chapters for this session.

+
) } - const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange) - return (
@@ -53,34 +72,41 @@ export function ChapterStrip({
-
- {chapters.map((chapter, index) => { - const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1) - const isActive = tourActive - ? tourChapterIndex === index - : activeIndex === index - const headline = chapter.headline || chapter.title +
+
+ {chapters.map((chapter, index) => { + const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1) + const isActive = tourActive + ? tourChapterIndex === index + : activeIndex === index + const headline = chapter.headline || chapter.title - return ( - - ) - })} + return ( + + ) + })} +
) diff --git a/frontend/src/components/EmptyStateCard.tsx b/frontend/src/components/EmptyStateCard.tsx new file mode 100644 index 0000000..30607e9 --- /dev/null +++ b/frontend/src/components/EmptyStateCard.tsx @@ -0,0 +1,20 @@ +import type { LucideIcon } from 'lucide-react' +import type { ReactNode } from 'react' + +interface Props { + icon: LucideIcon + title: string + hint: ReactNode + testId?: string + className?: string +} + +export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) { + return ( +
+ +
{title}
+
{hint}
+
+ ) +} diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index df54579..0e12ec2 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -1,13 +1,30 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { LineChart } from 'lucide-react' import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types' import { fetchReplayFrames, fetchTrackOutline } from '../api' import { ReplayTrackMap } from './ReplayTrackMap' import { ChapterStrip } from './ChapterStrip' +import { EmptyStateCard } from './EmptyStateCard' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' -import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters' +import { + chapterBandFill, + chapterEndScrub, + chapterStartScrub, + chapterTourDurations, + deCollideYPositions, + decimatedPositionLabels, +} from '../lib/chapters' +import { isReplayMapAvailable } from '../lib/replayMap' +import '../styles/race-story.css' const CHAPTER_TOUR_MS = 90_000 +const CHART_W = 640 +const CHART_H = 180 +const CHART_PL = 40 +const CHART_PR = 48 +const CHART_PT = 8 +const CHART_PB = 20 interface Props { data: { @@ -51,7 +68,6 @@ export function RaceStoryCanvas({ data }: Props) { const svgRef = useRef(null) const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) - // Position Evolution Chart Logic const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const hasChartData = hasPositions && allTimes.length > 0 const chartTiming = useMemo(() => { @@ -62,18 +78,38 @@ export function RaceStoryCanvas({ data }: Props) { }, [allTimes, hasChartData]) const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0 const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0) + const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0 const replayQuery = useQuery({ queryKey: ['replay-frames', session?.session_key, 5000], queryFn: () => fetchReplayFrames(session!.session_key, 5000), - enabled: mapOpen && Boolean(session?.session_key), + enabled: canProbeMap, }) const outlineQuery = useQuery({ queryKey: ['track-outline', circuitKey, outlineYear], queryFn: () => fetchTrackOutline(circuitKey, outlineYear), - enabled: mapOpen && circuitKey > 0 && outlineYear > 0, + enabled: canProbeMap, }) + const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading) + const mapAvailable = useMemo( + () => + canProbeMap && + isReplayMapAvailable( + replayQuery.data, + outlineQuery.data, + replayQuery.isError || outlineQuery.isError, + ), + [canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError], + ) + const showMapPanel = mapOpen && mapAvailable + + useEffect(() => { + if (!mapAvailable && mapOpen) { + setMapOpen(false) + } + }, [mapAvailable, mapOpen]) + useEffect(() => { if (!isPlaying || !chartTiming) return @@ -203,7 +239,7 @@ export function RaceStoryCanvas({ data }: Props) { pos: p.position, }) } - const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number)) + const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number)) for (const [dNum, samples] of byDriver.entries()) { samples.sort((a, b) => a.t - b.t) if (samples.length > 0 && !dnfSet.has(dNum)) { @@ -211,16 +247,16 @@ export function RaceStoryCanvas({ data }: Props) { } } - const getInterpPos = (samples: {t: number, pos: number}[], t: number) => { + const getInterpPos = (samples: { t: number; pos: number }[], t: number) => { if (!samples || samples.length === 0) return null if (t <= samples[0].t) return samples[0].pos if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos for (let i = 0; i < samples.length - 1; i++) { - if (samples[i].t <= t && samples[i+1].t >= t) { - const dt = samples[i+1].t - samples[i].t + if (samples[i].t <= t && samples[i + 1].t >= t) { + const dt = samples[i + 1].t - samples[i].t if (dt === 0) return samples[i].pos const frac = (t - samples[i].t) / dt - return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac + return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac } } return null @@ -244,24 +280,25 @@ export function RaceStoryCanvas({ data }: Props) { const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2) const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour])) const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym])) + const positionLabels = decimatedPositionLabels(maxPos) - const W = 640 - const H = 180 - const PL = 40 - const PR = 48 - const PT = 8 - const PB = 20 + const W = CHART_W + const H = CHART_H + const PL = CHART_PL + const PR = CHART_PR + const PT = CHART_PT + const PB = CHART_PB const plotW = W - PL - PR const plotH = H - PT - PB const toX = (t: number) => PL + t * plotW const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH - const winner = results.find(r => r.position === 1) - const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : [] - const lapTicks: { lap: number, t: number }[] = [] + const winner = results.find((r) => r.position === 1) + const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : [] + const lapTicks: { lap: number; t: number }[] = [] const lapInterval = winnerLaps.length < 30 ? 5 : 10 - + for (const lap of winnerLaps) { if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) { const t = (new Date(lap.date_start).getTime() - tMin) / tRange @@ -271,16 +308,15 @@ export function RaceStoryCanvas({ data }: Props) { } } - // Safety Car / VSC periods const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = [] let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - + for (const msg of rc) { const t = new Date(msg.date).getTime() const m = msg.message?.toUpperCase() || '' const cat = msg.category?.toUpperCase() || '' - + if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') { if (!activeSC) activeSC = { start: t, type: 'VSC' } } else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') { @@ -296,6 +332,30 @@ export function RaceStoryCanvas({ data }: Props) { scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type }) } + const chapterBands = chapters + .map((chapter, index) => { + const startT = chapterStartScrub(chapter, tMin, tRange) + const endT = chapterEndScrub(chapter, tMin, tRange) + if (startT === null) return null + const end = endT ?? startT + return { + key: `${chapter.kind}-${index}`, + start: Math.min(startT, end), + end: Math.max(startT, end), + fill: chapterBandFill(chapter.kind), + } + }) + .filter((band): band is NonNullable => band !== null) + + const labelCandidates = Array.from(byDriver.entries()) + .map(([dNum, samples]) => { + const last = samples[samples.length - 1] + if (!last) return null + return { key: dNum, y: toY(last.pos) } + }) + .filter((item): item is { key: number; y: number } => item !== null) + const labelYByDriver = deCollideYPositions(labelCandidates, 12) + const handlePointerMove = (e: React.PointerEvent) => { setIsPlaying(false) stopChapterTour() @@ -307,14 +367,34 @@ export function RaceStoryCanvas({ data }: Props) { } chartContent = ( -
+
+ {chapterBands.map((band) => { + const x1 = toX(Math.max(0, band.start)) + const x2 = toX(Math.min(1, band.end)) + if (x2 <= PL || x1 >= W - PR) return null + return ( + + ) + })} + {scPeriods.map((sc, i) => { const startT = (sc.start - tMin) / tRange const endT = sc.end ? (sc.end - tMin) / tRange : 1 @@ -333,33 +413,41 @@ export function RaceStoryCanvas({ data }: Props) { ) })} - {Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => ( + {positionLabels.map((pos) => ( P{pos} ))} - {lapTicks.map(tick => ( + {lapTicks.map((tick) => ( - - + + L{tick.lap} @@ -372,11 +460,12 @@ export function RaceStoryCanvas({ data }: Props) { const last = samples[samples.length - 1] const isHovered = hoverDriver === dNum const isFaded = hoverDriver !== null && !isHovered + const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0 - const driverPits = pit_stops.filter(p => p.driver_number === dNum) + const driverPits = pit_stops.filter((p) => p.driver_number === dNum) return ( - setHoverDriver(dNum)} @@ -392,30 +481,30 @@ export function RaceStoryCanvas({ data }: Props) { className="rs-driver-line" pathLength={1} /> - + {driverPits.map((p, i) => { const t = (new Date(p.date).getTime() - tMin) / tRange if (t < 0 || t > 1) return null const pos = getInterpPos(samples, t) if (pos === null) return null return ( - ) })} - + {last && ( -
+
-
- {[1, 10, 30].map((speed) => ( + + {[1, 10, 30].map((speed) => ( + + ))} + {mapProbeSettled && mapAvailable && ( + <> + - ))} -
- + + )}
) @@ -506,32 +603,36 @@ export function RaceStoryCanvas({ data }: Props) { onTourToggle={toggleChapterTour} /> )} -
+
{hasChartData ? ( chartContent ) : ( -
- Lap-by-lap positions not available. This session does not - have ingested position samples in /api/v1/race-hub. -
+ + This session does not have ingested position samples in{' '} + /api/v1/race-hub. + + } + testId="race-story-no-positions" + className="race-story-empty-card" + /> )}
- {mapOpen && ( -
- {circuitKey > 0 && outlineYear > 0 ? ( - - ) : ( -
track identity unavailable for replay map
- )} + {showMapPanel && ( +
+
)}
@@ -542,30 +643,29 @@ export function RaceStoryCanvas({ data }: Props) { const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 const currentPos = scrubTime !== null ? i + 1 : r.position const isWinner = i === 0 && r.position === 1 - const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : '' - + const pClass = + currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : '' + let currentPoints: number | string = r.points if (scrubTime !== null) { const isSprint = data.session?.session_type?.toLowerCase().includes('sprint') const ptsArray = isSprint ? [8, 7, 6, 5, 4, 3, 2, 1] : [25, 18, 15, 12, 10, 8, 6, 4, 2, 1] currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0 } - + return ( -
setHoverDriver(r.driver_number)} onMouseLeave={() => setHoverDriver(null)} >
-
- {currentPos} -
+
{currentPos}
-
{r.name_acronym || r.driver_number} @@ -583,12 +683,12 @@ export function RaceStoryCanvas({ data }: Props) { Grid
- +
{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)} {isWinner ? 'Time' : 'Gap'}
- +
0 ? 'var(--text)' : 'var(--text-3)' }}> {currentPoints} diff --git a/frontend/src/components/ReplayTrackMap.tsx b/frontend/src/components/ReplayTrackMap.tsx index d3ec274..51be4c4 100644 --- a/frontend/src/components/ReplayTrackMap.tsx +++ b/frontend/src/components/ReplayTrackMap.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from 'react' +import { Loader2, MapPin, Satellite } from 'lucide-react' import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types' import { buildOutlinePath } from '../lib/trackmap' import { interpolateReplayCars, replayCarToSvg } from '../lib/replay' +import { EmptyStateCard } from './EmptyStateCard' import '../styles/replay-map.css' interface Props { @@ -60,7 +62,13 @@ export function ReplayTrackMap({ if (loading) { return (
-
loading replay GPS...
+
) } @@ -68,7 +76,13 @@ export function ReplayTrackMap({ if (error) { return (
-
replay GPS unavailable for this session
+
) } @@ -76,7 +90,13 @@ export function ReplayTrackMap({ if (!outline || !outlinePath) { return (
-
track outline unavailable for this session
+
) } @@ -84,7 +104,13 @@ export function ReplayTrackMap({ if (!replay?.frames?.length || cars.length === 0) { return (
-
historical GPS unavailable for this session
+
) } diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts index 2ced668..c8f45c5 100644 --- a/frontend/src/lib/chapters.ts +++ b/frontend/src/lib/chapters.ts @@ -80,3 +80,52 @@ export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): num const perChapter = totalMs / chapters.length return chapters.map(() => perChapter) } + +/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */ +export function decimatedPositionLabels(maxPos: number): number[] { + if (maxPos <= 1) return [1] + const labels = new Set([1]) + for (let pos = 5; pos < maxPos; pos += 5) { + labels.add(pos) + } + labels.add(maxPos) + return [...labels].sort((a, b) => a - b) +} + +/** Subtle background fill for chapter time-bands on the position graph. */ +export function chapterBandFill(kind: string): string { + switch (kind) { + case 'safety_car': + return 'rgba(255, 153, 0, 0.14)' + case 'virtual_safety_car': + return 'rgba(255, 204, 0, 0.12)' + case 'red_flag': + return 'rgba(230, 36, 41, 0.12)' + case 'pit_phase': + return 'rgba(96, 165, 250, 0.08)' + case 'decisive_swing': + return 'rgba(34, 197, 94, 0.08)' + case 'finish': + return 'rgba(255, 204, 0, 0.06)' + default: + return 'rgba(255, 255, 255, 0.03)' + } +} + +/** Minimum vertical spacing between colliding labels (SVG units). */ +export function deCollideYPositions( + items: ReadonlyArray<{ key: string | number; y: number }>, + minGap = 12, +): Map { + if (items.length === 0) return new Map() + const sorted = [...items].sort((a, b) => a.y - b.y) + const adjusted = sorted.map((item) => ({ ...item })) + for (let i = 1; i < adjusted.length; i++) { + const prev = adjusted[i - 1] + const curr = adjusted[i] + if (curr.y - prev.y < minGap) { + curr.y = prev.y + minGap + } + } + return new Map(adjusted.map((item) => [item.key, item.y])) +} diff --git a/frontend/src/lib/replayMap.ts b/frontend/src/lib/replayMap.ts new file mode 100644 index 0000000..63a9f8b --- /dev/null +++ b/frontend/src/lib/replayMap.ts @@ -0,0 +1,14 @@ +import type { ReplayFramesResponse, TrackOutline } from '../types' + +/** Whether replay map can render (probe uses existing frames + outline queries). */ +export function isReplayMapAvailable( + replay: ReplayFramesResponse | null | undefined, + outline: TrackOutline | null | undefined, + hasError: boolean, +): boolean { + if (hasError) return false + const frames = replay?.frames ?? [] + if (frames.length < 2) return false + if (!outline?.points?.length) return false + return true +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 797c6c0..32e0005 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -520,6 +520,17 @@ a { color: inherit; text-decoration: none; } color: var(--text-3); } +.empty-state-icon { + display: block; + margin: 0 auto var(--s3); + color: var(--text-3); +} + +.race-story-empty-card { + padding: var(--s6) var(--s5); + margin: 0; +} + .loading-state { padding: var(--s7) 0; text-align: center; diff --git a/frontend/src/styles/chapters.css b/frontend/src/styles/chapters.css index f841341..f131c36 100644 --- a/frontend/src/styles/chapters.css +++ b/frontend/src/styles/chapters.css @@ -9,6 +9,7 @@ align-items: center; justify-content: space-between; gap: var(--s4); + flex-wrap: wrap; } .chapter-strip-title { @@ -44,6 +45,31 @@ color: var(--text); } +.chapter-strip-scroll-wrap { + position: relative; +} + +.chapter-strip-scroll-wrap::before, +.chapter-strip-scroll-wrap::after { + content: ''; + position: absolute; + top: 0; + bottom: var(--s2); + width: 28px; + pointer-events: none; + z-index: 1; +} + +.chapter-strip-scroll-wrap::before { + left: 0; + background: linear-gradient(to right, var(--bg), transparent); +} + +.chapter-strip-scroll-wrap::after { + right: 0; + background: linear-gradient(to left, var(--bg), transparent); +} + .chapter-strip-scroll { display: flex; gap: var(--s3); @@ -51,10 +77,26 @@ padding-bottom: var(--s2); scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: var(--border-2) transparent; +} + +.chapter-strip-scroll::-webkit-scrollbar { + height: 4px; +} + +.chapter-strip-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.chapter-strip-scroll::-webkit-scrollbar-thumb { + background: var(--border-2); + border-radius: 999px; } .chapter-card { flex: 0 0 min(240px, 72vw); + min-height: 88px; display: flex; flex-direction: column; gap: var(--s2); @@ -64,7 +106,7 @@ background: var(--surface); text-align: left; cursor: pointer; - scroll-snap-align: start; + scroll-snap-align: center; transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; } @@ -145,10 +187,21 @@ -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; + flex: 1; } -.chapter-strip-empty { - font-size: 12px; - color: var(--text-3); - padding: var(--s3) 0; +.chapter-strip-empty-card { + padding: var(--s6) var(--s5); + margin: 0; +} + +.chapter-strip-empty-card .empty-state-icon { + margin: 0 auto var(--s3); + color: var(--text-3); +} + +@media (max-width: 900px) { + .chapter-strip-header { + align-items: flex-start; + } } diff --git a/frontend/src/styles/race-story.css b/frontend/src/styles/race-story.css new file mode 100644 index 0000000..e151b7a --- /dev/null +++ b/frontend/src/styles/race-story.css @@ -0,0 +1,146 @@ +.rs-replay-shell { + display: grid; + grid-template-columns: 1fr; + gap: var(--s5); + align-items: stretch; +} + +.rs-replay-shell--split { + grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr); +} + +.rs-replay-main { + min-width: 0; +} + +.rs-chart-container { + --rs-chart-max-width: 100%; +} + +.rs-chart-container--full { + --rs-chart-max-width: 100%; +} + +.rs-segmented-control { + display: inline-flex; + align-items: stretch; + flex-wrap: wrap; + gap: 0; + margin-top: var(--s3); + border: 1px solid var(--border-2); + border-radius: var(--s1); + background: var(--surface); + overflow: hidden; +} + +.rs-segment { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + padding: 0 var(--s4); + font-family: var(--f-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-3); + background: transparent; + border: none; + border-right: 1px solid var(--border); + cursor: pointer; + transition: background 0.12s, color 0.12s; +} + +.rs-segment:last-child { + border-right: none; +} + +.rs-segment:hover:not(:disabled) { + color: var(--text); + background: var(--surface-h); +} + +.rs-segment:focus-visible { + outline: 2px solid var(--red); + outline-offset: -2px; + z-index: 1; +} + +.rs-segment.active { + color: var(--text); + background: rgba(230, 36, 41, 0.12); + box-shadow: inset 0 -2px 0 var(--red); +} + +.rs-segment:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.rs-segment-divider { + width: 1px; + align-self: stretch; + background: var(--border); + flex-shrink: 0; +} + +.rs-replay-map-slot { + min-width: 0; +} + +.rs-position-chart-svg { + width: 100%; + min-width: 280px; + max-width: var(--rs-chart-max-width, 640px); + display: block; +} + +.rs-chart-axis-label { + fill: var(--text-3); + font-family: var(--f-mono); + font-size: 9px; +} + +.rs-chart-grid-line { + stroke: var(--border); + stroke-width: 0.5; +} + +.rs-chart-lap-tick { + stroke: var(--border); + stroke-width: 1; +} + +@media (max-width: 900px) { + .rs-replay-shell--split { + grid-template-columns: 1fr; + } + + .rs-segmented-control { + width: 100%; + } + + .rs-segment { + flex: 1 1 auto; + } +} + +@media (max-width: 560px) { + .rs-segmented-control { + flex-direction: column; + align-items: stretch; + } + + .rs-segment { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .rs-segment:last-child { + border-bottom: none; + } + + .rs-segment-divider { + display: none; + } +} diff --git a/frontend/src/styles/replay-map.css b/frontend/src/styles/replay-map.css index 3915e6b..29f5ebd 100644 --- a/frontend/src/styles/replay-map.css +++ b/frontend/src/styles/replay-map.css @@ -1,76 +1,9 @@ -.rs-replay-shell { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr); - gap: var(--s5); - align-items: stretch; -} - -.rs-replay-main { - min-width: 0; -} - -.rs-replay-tools { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--s3); - margin-top: var(--s3); -} - -.rs-tool-btn, -.rs-speed-btn { - height: 28px; - padding: 0 var(--s4); - font-family: var(--f-mono); - font-size: 10px; - font-weight: 700; - color: var(--text-2); - background: var(--surface); - border: 1px solid var(--border-2); - border-radius: 2px; - cursor: pointer; -} - -.rs-tool-btn:hover, -.rs-speed-btn:hover { - color: var(--text); - border-color: var(--text-3); -} - -.rs-tool-btn.active, -.rs-speed-btn.active { - color: var(--text); - border-color: var(--red); - background: rgba(230, 36, 41, 0.12); -} - -.rs-speed-group { - display: inline-flex; - gap: 2px; -} - -.rs-replay-map-slot { - min-width: 0; -} - -.rs-replay-map-placeholder { - min-height: 244px; - display: grid; - place-items: center; - padding: var(--s5); - color: var(--text-3); - font-family: var(--f-mono); - font-size: 11px; - text-align: center; - border: 1px dashed var(--border-2); - background: var(--surface); -} - .replay-map-panel { min-height: 244px; height: 100%; border: 1px solid var(--border); background: var(--surface); + border-radius: var(--s2); } .replay-map-stage { @@ -143,19 +76,15 @@ stroke-width: 1.2; } -.replay-map-empty { +.replay-map-empty-card { min-height: 244px; display: grid; - place-items: center; - padding: var(--s5); - color: var(--text-3); - font-family: var(--f-mono); - font-size: 11px; - text-align: center; + place-content: center; + padding: var(--s6); + margin: 0; } -@media (max-width: 860px) { - .rs-replay-shell { - grid-template-columns: 1fr; - } +.replay-map-empty-card .empty-state-icon { + margin: 0 auto var(--s3); + color: var(--text-3); } diff --git a/frontend/src/test/ChapterStrip.test.tsx b/frontend/src/test/ChapterStrip.test.tsx index bbd5907..c3cc5a7 100644 --- a/frontend/src/test/ChapterStrip.test.tsx +++ b/frontend/src/test/ChapterStrip.test.tsx @@ -68,6 +68,25 @@ describe('ChapterStrip', () => { expect(screen.getByText('L12–L15')).toBeInTheDocument() }) + it('renders an empty-state card when there are no chapters', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument() + }) + it('highlights the active chapter from scrub time', () => { const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange renderStrip({ scrubTime: scrub }) diff --git a/frontend/src/test/RaceStoryCanvas.test.tsx b/frontend/src/test/RaceStoryCanvas.test.tsx index 161cf56..7de9ef5 100644 --- a/frontend/src/test/RaceStoryCanvas.test.tsx +++ b/frontend/src/test/RaceStoryCanvas.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RaceStoryCanvas } from '../components/RaceStoryCanvas' -import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types' +import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types' vi.mock('../api', () => ({ fetchReplayFrames: vi.fn(), @@ -28,9 +28,35 @@ const replay: ReplayFramesResponse = { session_key: 99, interval_ms: 5000, start_time: '2025-05-25T13:00:00Z', - frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }], + frames: [ + { t: 0, cars: { '1': { x: 10, y: 20 } } }, + { t: 5000, cars: { '1': { x: 50, y: 50 } } }, + ], } +const chapters: Chapter[] = [ + { + kind: 'start', + title: 'Start', + headline: 'Lights out', + start_lap: 1, + end_lap: 1, + start_time: '2025-05-25T13:00:00Z', + end_time: '2025-05-25T13:01:00Z', + driver_numbers: [], + }, + { + kind: 'safety_car', + title: 'Safety Car', + headline: 'Incident brings out the Safety Car', + start_lap: 2, + end_lap: 2, + start_time: '2025-05-25T13:05:00Z', + end_time: '2025-05-25T13:05:00Z', + driver_numbers: [], + }, +] + const raceHub: RaceHub = { source: 'local', session_key: 99, @@ -103,14 +129,14 @@ const raceHub: RaceHub = { race_control: [], weather: [], laps: [], - chapters: [], + chapters, } -function renderCanvas() { +function renderCanvas(overrides: Partial = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) return render( - + , ) } @@ -122,14 +148,53 @@ describe('RaceStoryCanvas replay map', () => { mockFetchTrackOutline.mockResolvedValue(outline) }) - it('fetches replay frames lazily when the map panel opens', async () => { + it('probes replay frames on mount and opens the map when data is available', async () => { renderCanvas() - expect(mockFetchReplayFrames).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Map' })) - await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000)) expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025) + + const mapToggle = await screen.findByTestId('replay-map-toggle') + fireEvent.click(mapToggle) + expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument() + expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument() + }) + + it('hides the map toggle when replay frames are unavailable', async () => { + mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] }) + renderCanvas() + + await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled()) + await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument()) + }) + + it('uses full-width chart layout when the map is closed', async () => { + renderCanvas() + + await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled()) + const chart = screen.getByTestId('position-chart') + expect(chart).toHaveClass('rs-chart-container--full') + expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument() + expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument() + }) + + it('syncs active chapter highlight when a chapter card is clicked', async () => { + renderCanvas() + + fireEvent.click(screen.getByTestId('chapter-card-1')) + await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')) + expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active') + }) + + it('renders the empty-state card when positions are unavailable', () => { + renderCanvas({ + datasets: { positions: { status: 'missing', source: 'local', count: 0 } }, + positions: [], + chapters: [], + }) + + expect(screen.getByTestId('race-story-no-positions')).toBeInTheDocument() + expect(screen.getByText('Lap-by-lap positions not available')).toBeInTheDocument() }) }) diff --git a/frontend/src/test/ReplayTrackMap.test.tsx b/frontend/src/test/ReplayTrackMap.test.tsx index 2aa46bf..bed53c3 100644 --- a/frontend/src/test/ReplayTrackMap.test.tsx +++ b/frontend/src/test/ReplayTrackMap.test.tsx @@ -44,9 +44,10 @@ const results: EnrichedResult[] = [ ] describe('ReplayTrackMap', () => { - it('renders an empty state when replay frames are missing', () => { + it('renders an empty-state card when replay frames are missing', () => { render() - expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i) + expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument() + expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument() }) it('renders car labels from result metadata', () => { diff --git a/frontend/src/test/chapters.test.ts b/frontend/src/test/chapters.test.ts index 4fb0ed6..290d936 100644 --- a/frontend/src/test/chapters.test.ts +++ b/frontend/src/test/chapters.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' import { activeChapterIndex, + chapterBandFill, chapterKindLabel, chapterLapRange, chapterStartScrub, chapterTourDurations, + deCollideYPositions, + decimatedPositionLabels, } from '../lib/chapters' import type { Chapter } from '../types' @@ -60,4 +63,27 @@ describe('chapters lib', () => { it('splits 90s evenly across chapters', () => { expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000]) }) + + it('decimates position axis labels', () => { + expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22]) + }) + + it('returns chapter band fills by kind', () => { + expect(chapterBandFill('safety_car')).toContain('rgba') + expect(chapterBandFill('virtual_safety_car')).toContain('rgba') + }) + + it('de-collides overlapping label y positions', () => { + const adjusted = deCollideYPositions( + [ + { key: 'a', y: 10 }, + { key: 'b', y: 12 }, + { key: 'c', y: 30 }, + ], + 12, + ) + expect(adjusted.get('a')).toBe(10) + expect(adjusted.get('b')).toBe(22) + expect(adjusted.get('c')).toBe(34) + }) }) diff --git a/frontend/src/test/replayMap.test.ts b/frontend/src/test/replayMap.test.ts new file mode 100644 index 0000000..14545ed --- /dev/null +++ b/frontend/src/test/replayMap.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { isReplayMapAvailable } from '../lib/replayMap' +import type { ReplayFramesResponse, TrackOutline } from '../types' + +const outline: TrackOutline = { + circuit_key: 1, + bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 }, + points: [{ x: 0, y: 0 }], +} + +const replay: ReplayFramesResponse = { + session_key: 1, + interval_ms: 5000, + start_time: '2025-05-25T13:00:00Z', + frames: [ + { t: 0, cars: {} }, + { t: 5000, cars: {} }, + ], +} + +describe('isReplayMapAvailable', () => { + it('returns true when frames and outline are present', () => { + expect(isReplayMapAvailable(replay, outline, false)).toBe(true) + }) + + it('returns false when frames are sparse', () => { + expect(isReplayMapAvailable({ ...replay, frames: [{ t: 0, cars: {} }] }, outline, false)).toBe(false) + }) + + it('returns false on query error', () => { + expect(isReplayMapAvailable(replay, outline, true)).toBe(false) + }) +}) diff --git a/tests/visual/__snapshots__/desktop/race-story.png b/tests/visual/__snapshots__/desktop/race-story.png new file mode 100644 index 0000000..37f8b19 Binary files /dev/null and b/tests/visual/__snapshots__/desktop/race-story.png differ diff --git a/tests/visual/__snapshots__/mobile/race-story.png b/tests/visual/__snapshots__/mobile/race-story.png new file mode 100644 index 0000000..551f7a1 Binary files /dev/null and b/tests/visual/__snapshots__/mobile/race-story.png differ diff --git a/tests/visual/__snapshots__/tablet/race-story.png b/tests/visual/__snapshots__/tablet/race-story.png new file mode 100644 index 0000000..d87b667 Binary files /dev/null and b/tests/visual/__snapshots__/tablet/race-story.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts index 931fc94..fe66d1e 100644 --- a/tests/visual/helpers.ts +++ b/tests/visual/helpers.ts @@ -36,6 +36,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P await waitForScreenshotReady(page) } +export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise { + await page.goto(`/race-hub?session_key=${sessionKey}`) + await expect(page.getByTestId('race-hub')).toBeVisible() + await page.getByRole('tab', { name: 'Race Story' }).click() + await expect(page.getByTestId('position-chart')).toBeVisible() + await waitForScreenshotReady(page) +} + export async function gotoDataLibraryReady(page: Page): Promise { await page.goto('/admin') await expect(page.getByTestId('data-library')).toBeVisible() diff --git a/tests/visual/race-story.spec.ts b/tests/visual/race-story.spec.ts new file mode 100644 index 0000000..6332da2 --- /dev/null +++ b/tests/visual/race-story.spec.ts @@ -0,0 +1,9 @@ +import { test } from '@playwright/test' +import { gotoRaceStoryReady, screenshotPage } from './helpers' + +test.describe('Race Story visual regression', () => { + test('race-story', async ({ page }) => { + await gotoRaceStoryReady(page) + await screenshotPage(page, 'race-story') + }) +})