Merge pull request #69 from AmanTahiliani/feat/issue-68-polish-the-race-story-section-chapter-st

Polish the Race Story section (chapter strip, playback controls, map empty-state, graph) (#68)
This commit is contained in:
Aman Tahiliani
2026-07-12 13:44:00 -04:00
committed by GitHub
20 changed files with 900 additions and 237 deletions

View File

@@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'
import { BookOpen } from 'lucide-react'
import type { Chapter } from '../types' import type { Chapter } from '../types'
import { import {
activeChapterIndex, activeChapterIndex,
@@ -5,6 +7,7 @@ import {
chapterLapRange, chapterLapRange,
chapterStartScrub, chapterStartScrub,
} from '../lib/chapters' } from '../lib/chapters'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/chapters.css' import '../styles/chapters.css'
interface Props { interface Props {
@@ -14,6 +17,8 @@ interface Props {
tRange: number tRange: number
tourActive: boolean tourActive: boolean
tourChapterIndex: number | null tourChapterIndex: number | null
/** Explicit selection from a chapter click; wins over scrub-derived active. */
selectedChapterIndex?: number | null
onChapterClick: (index: number, scrub: number) => void onChapterClick: (index: number, scrub: number) => void
onTourToggle: () => void onTourToggle: () => void
} }
@@ -25,19 +30,38 @@ export function ChapterStrip({
tRange, tRange,
tourActive, tourActive,
tourChapterIndex, tourChapterIndex,
selectedChapterIndex = null,
onChapterClick, onChapterClick,
onTourToggle, onTourToggle,
}: Props) { }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
const highlightedIndex = tourActive
? tourChapterIndex
: (selectedChapterIndex ?? activeIndex)
useEffect(() => {
if (highlightedIndex === null || !scrollRef.current) return
const card = scrollRef.current.querySelector<HTMLElement>(
`[data-testid="chapter-card-${highlightedIndex}"]`,
)
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, [highlightedIndex])
if (chapters.length === 0) { if (chapters.length === 0) {
return ( return (
<div className="chapter-strip" data-testid="chapter-strip"> <div className="chapter-strip" data-testid="chapter-strip">
<p className="chapter-strip-empty">No story chapters for this session.</p> <EmptyStateCard
icon={BookOpen}
title="No story chapters"
hint="This session does not have enough race-control or position data to build narrative chapters."
testId="chapter-strip-empty"
className="chapter-strip-empty-card"
/>
</div> </div>
) )
} }
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
return ( return (
<div className="chapter-strip" data-testid="chapter-strip"> <div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header"> <div className="chapter-strip-header">
@@ -53,34 +77,39 @@ export function ChapterStrip({
</button> </button>
</div> </div>
</div> </div>
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters"> <div className="chapter-strip-scroll-wrap">
{chapters.map((chapter, index) => { <div
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1) ref={scrollRef}
const isActive = tourActive className="chapter-strip-scroll"
? tourChapterIndex === index role="list"
: activeIndex === index aria-label="Race story chapters"
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 = highlightedIndex === index
const headline = chapter.headline || chapter.title
return ( return (
<button <button
key={`${chapter.kind}-${chapter.start_lap}-${index}`} key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button" type="button"
role="listitem" role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`} className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)} onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`} data-testid={`chapter-card-${index}`}
> >
<div className="chapter-card-top"> <div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}> <span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)} {chapterKindLabel(chapter.kind)}
</span> </span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span> <span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div> </div>
<span className="chapter-headline">{headline}</span> <span className="chapter-headline">{headline}</span>
</button> </button>
) )
})} })}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -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 (
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
<Icon size={32} className="empty-state-icon" aria-hidden />
<div className="empty-state-title">{title}</div>
<div className="empty-state-desc">{hint}</div>
</div>
)
}

View File

@@ -1,13 +1,30 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { LineChart } from 'lucide-react'
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types' import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
import { fetchReplayFrames, fetchTrackOutline } from '../api' import { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap' import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip' import { ChapterStrip } from './ChapterStrip'
import { EmptyStateCard } from './EmptyStateCard'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' 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 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 { interface Props {
data: { data: {
@@ -48,10 +65,10 @@ export function RaceStoryCanvas({ data }: Props) {
const [playbackSpeed, setPlaybackSpeed] = useState(10) const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false) const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null) const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) 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 allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
const hasChartData = hasPositions && allTimes.length > 0 const hasChartData = hasPositions && allTimes.length > 0
const chartTiming = useMemo(() => { const chartTiming = useMemo(() => {
@@ -62,18 +79,38 @@ export function RaceStoryCanvas({ data }: Props) {
}, [allTimes, hasChartData]) }, [allTimes, hasChartData])
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0 const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 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({ const replayQuery = useQuery({
queryKey: ['replay-frames', session?.session_key, 5000], queryKey: ['replay-frames', session?.session_key, 5000],
queryFn: () => fetchReplayFrames(session!.session_key, 5000), queryFn: () => fetchReplayFrames(session!.session_key, 5000),
enabled: mapOpen && Boolean(session?.session_key), enabled: canProbeMap,
}) })
const outlineQuery = useQuery({ const outlineQuery = useQuery({
queryKey: ['track-outline', circuitKey, outlineYear], queryKey: ['track-outline', circuitKey, outlineYear],
queryFn: () => fetchTrackOutline(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(() => { useEffect(() => {
if (!isPlaying || !chartTiming) return if (!isPlaying || !chartTiming) return
@@ -104,9 +141,14 @@ export function RaceStoryCanvas({ data }: Props) {
const jumpToChapter = (index: number, scrub: number) => { const jumpToChapter = (index: number, scrub: number) => {
setIsPlaying(false) setIsPlaying(false)
stopChapterTour() stopChapterTour()
setSelectedChapterIndex(index)
setScrubTime(scrub) setScrubTime(scrub)
} }
const clearChapterSelection = () => {
setSelectedChapterIndex(null)
}
const toggleChapterTour = () => { const toggleChapterTour = () => {
if (chapterTourActive) { if (chapterTourActive) {
stopChapterTour() stopChapterTour()
@@ -114,6 +156,7 @@ export function RaceStoryCanvas({ data }: Props) {
} }
if (!chartTiming || chapters.length === 0) return if (!chartTiming || chapters.length === 0) return
setIsPlaying(false) setIsPlaying(false)
setSelectedChapterIndex(null)
setChapterTourActive(true) setChapterTourActive(true)
setTourChapterIndex(0) setTourChapterIndex(0)
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0 const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
@@ -203,7 +246,7 @@ export function RaceStoryCanvas({ data }: Props) {
pos: p.position, 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()) { for (const [dNum, samples] of byDriver.entries()) {
samples.sort((a, b) => a.t - b.t) samples.sort((a, b) => a.t - b.t)
if (samples.length > 0 && !dnfSet.has(dNum)) { if (samples.length > 0 && !dnfSet.has(dNum)) {
@@ -211,16 +254,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 (!samples || samples.length === 0) return null
if (t <= samples[0].t) return samples[0].pos if (t <= samples[0].t) return samples[0].pos
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
for (let i = 0; i < samples.length - 1; i++) { for (let i = 0; i < samples.length - 1; i++) {
if (samples[i].t <= t && samples[i+1].t >= t) { if (samples[i].t <= t && samples[i + 1].t >= t) {
const dt = samples[i+1].t - samples[i].t const dt = samples[i + 1].t - samples[i].t
if (dt === 0) return samples[i].pos if (dt === 0) return samples[i].pos
const frac = (t - samples[i].t) / dt 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 return null
@@ -244,24 +287,25 @@ export function RaceStoryCanvas({ data }: Props) {
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2) 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 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 acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
const positionLabels = decimatedPositionLabels(maxPos)
const W = 640 const W = CHART_W
const H = 180 const H = CHART_H
const PL = 40 const PL = CHART_PL
const PR = 48 const PR = CHART_PR
const PT = 8 const PT = CHART_PT
const PB = 20 const PB = CHART_PB
const plotW = W - PL - PR const plotW = W - PL - PR
const plotH = H - PT - PB const plotH = H - PT - PB
const toX = (t: number) => PL + t * plotW const toX = (t: number) => PL + t * plotW
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
const winner = results.find(r => r.position === 1) const winner = results.find((r) => r.position === 1)
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : [] const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
const lapTicks: { lap: number, t: number }[] = [] const lapTicks: { lap: number; t: number }[] = []
const lapInterval = winnerLaps.length < 30 ? 5 : 10 const lapInterval = winnerLaps.length < 30 ? 5 : 10
for (const lap of winnerLaps) { for (const lap of winnerLaps) {
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) { if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
const t = (new Date(lap.date_start).getTime() - tMin) / tRange const t = (new Date(lap.date_start).getTime() - tMin) / tRange
@@ -271,16 +315,15 @@ export function RaceStoryCanvas({ data }: Props) {
} }
} }
// Safety Car / VSC periods
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = [] const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null 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()) const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
for (const msg of rc) { for (const msg of rc) {
const t = new Date(msg.date).getTime() const t = new Date(msg.date).getTime()
const m = msg.message?.toUpperCase() || '' const m = msg.message?.toUpperCase() || ''
const cat = msg.category?.toUpperCase() || '' const cat = msg.category?.toUpperCase() || ''
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') { if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
if (!activeSC) activeSC = { start: t, type: 'VSC' } if (!activeSC) activeSC = { start: t, type: 'VSC' }
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') { } else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
@@ -296,9 +339,34 @@ export function RaceStoryCanvas({ data }: Props) {
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type }) 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<typeof band> => 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<SVGRectElement>) => { const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false) setIsPlaying(false)
stopChapterTour() stopChapterTour()
clearChapterSelection()
if (!svgRef.current) return if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect() const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left const x = e.clientX - rect.left
@@ -307,14 +375,34 @@ export function RaceStoryCanvas({ data }: Props) {
} }
chartContent = ( chartContent = (
<div className="rs-chart-container scroll-x" data-testid="position-chart"> <div
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
data-testid="position-chart"
>
<svg <svg
ref={svgRef} ref={svgRef}
className="rs-position-chart-svg"
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
role="img" role="img"
aria-label="Position evolution chart" aria-label="Position evolution chart"
> >
{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 (
<rect
key={band.key}
x={x1}
y={PT}
width={Math.max(0, x2 - x1)}
height={plotH}
fill={band.fill}
data-testid="chapter-band"
/>
)
})}
{scPeriods.map((sc, i) => { {scPeriods.map((sc, i) => {
const startT = (sc.start - tMin) / tRange const startT = (sc.start - tMin) / tRange
const endT = sc.end ? (sc.end - tMin) / tRange : 1 const endT = sc.end ? (sc.end - tMin) / tRange : 1
@@ -333,33 +421,41 @@ export function RaceStoryCanvas({ data }: Props) {
) )
})} })}
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => ( {positionLabels.map((pos) => (
<g key={pos}> <g key={pos}>
<line <line
x1={PL} x1={PL}
x2={W - PR} x2={W - PR}
y1={toY(pos)} y1={toY(pos)}
y2={toY(pos)} y2={toY(pos)}
stroke="var(--border)" className="rs-chart-grid-line"
strokeWidth={0.5}
/> />
<text <text
x={PL - 4} x={PL - 4}
y={toY(pos) + 4} y={toY(pos) + 4}
textAnchor="end" textAnchor="end"
fill="var(--text-3)" className="rs-chart-axis-label"
fontSize={8}
fontFamily="var(--f-mono)"
> >
P{pos} P{pos}
</text> </text>
</g> </g>
))} ))}
{lapTicks.map(tick => ( {lapTicks.map((tick) => (
<g key={`lap-${tick.lap}`}> <g key={`lap-${tick.lap}`}>
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} /> <line
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)"> x1={toX(tick.t)}
x2={toX(tick.t)}
y1={H - PB}
y2={H - PB + 4}
className="rs-chart-lap-tick"
/>
<text
x={toX(tick.t)}
y={H - PB + 14}
textAnchor="middle"
className="rs-chart-axis-label"
>
L{tick.lap} L{tick.lap}
</text> </text>
</g> </g>
@@ -372,11 +468,12 @@ export function RaceStoryCanvas({ data }: Props) {
const last = samples[samples.length - 1] const last = samples[samples.length - 1]
const isHovered = hoverDriver === dNum const isHovered = hoverDriver === dNum
const isFaded = hoverDriver !== null && !isHovered 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 ( return (
<g <g
key={dNum} key={dNum}
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }} style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
onMouseEnter={() => setHoverDriver(dNum)} onMouseEnter={() => setHoverDriver(dNum)}
@@ -392,30 +489,30 @@ export function RaceStoryCanvas({ data }: Props) {
className="rs-driver-line" className="rs-driver-line"
pathLength={1} pathLength={1}
/> />
{driverPits.map((p, i) => { {driverPits.map((p, i) => {
const t = (new Date(p.date).getTime() - tMin) / tRange const t = (new Date(p.date).getTime() - tMin) / tRange
if (t < 0 || t > 1) return null if (t < 0 || t > 1) return null
const pos = getInterpPos(samples, t) const pos = getInterpPos(samples, t)
if (pos === null) return null if (pos === null) return null
return ( return (
<circle <circle
key={`pit-${i}`} key={`pit-${i}`}
cx={toX(t)} cx={toX(t)}
cy={toY(pos)} cy={toY(pos)}
r={3} r={3}
fill="var(--bg)" fill="var(--bg)"
stroke={color} stroke={color}
strokeWidth={2} strokeWidth={2}
className="rs-pit-dot" className="rs-pit-dot"
/> />
) )
})} })}
{last && ( {last && (
<text <text
x={toX(last.t) + 6} x={toX(last.t) + 6}
y={toY(last.pos) + 4} y={labelY + 4}
fill={color} fill={color}
fontSize={isHovered ? 11 : 9} fontSize={isHovered ? 11 : 9}
fontFamily="var(--f-mono)" fontFamily="var(--f-mono)"
@@ -451,42 +548,54 @@ export function RaceStoryCanvas({ data }: Props) {
fill="transparent" fill="transparent"
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerLeave={() => { onPointerLeave={() => {
if (!isPlaying) setScrubTime(null) if (!isPlaying) {
clearChapterSelection()
setScrubTime(null)
}
}} }}
style={{ cursor: 'crosshair', touchAction: 'none' }} style={{ cursor: 'crosshair', touchAction: 'none' }}
/> />
</svg> </svg>
<div className="rs-replay-tools" aria-label="Race replay controls"> <div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
<button <button
type="button" type="button"
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`} className={`rs-segment ${isPlaying ? 'active' : ''}`}
onClick={() => { onClick={() => {
setScrubTime((current) => current ?? 0) setScrubTime((current) => current ?? 0)
stopChapterTour() stopChapterTour()
clearChapterSelection()
setIsPlaying((current) => !current) setIsPlaying((current) => !current)
}} }}
aria-pressed={isPlaying}
> >
{isPlaying ? 'Pause' : 'Play'} {isPlaying ? 'Pause' : 'Play'}
</button> </button>
<div className="rs-speed-group" aria-label="Playback speed"> <span className="rs-segment-divider" aria-hidden />
{[1, 10, 30].map((speed) => ( {[1, 10, 30].map((speed) => (
<button
key={speed}
type="button"
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)}
aria-pressed={playbackSpeed === speed}
>
{speed}x
</button>
))}
{mapProbeSettled && mapAvailable && (
<>
<span className="rs-segment-divider" aria-hidden />
<button <button
key={speed}
type="button" type="button"
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`} className={`rs-segment ${mapOpen ? 'active' : ''}`}
onClick={() => setPlaybackSpeed(speed)} onClick={() => setMapOpen((current) => !current)}
aria-pressed={mapOpen}
data-testid="replay-map-toggle"
> >
{speed}x Map
</button> </button>
))} </>
</div> )}
<button
type="button"
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
onClick={() => setMapOpen((current) => !current)}
>
Map
</button>
</div> </div>
</div> </div>
) )
@@ -502,36 +611,41 @@ export function RaceStoryCanvas({ data }: Props) {
tRange={chartTiming.tRange} tRange={chartTiming.tRange}
tourActive={chapterTourActive} tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex} tourChapterIndex={tourChapterIndex}
selectedChapterIndex={selectedChapterIndex}
onChapterClick={jumpToChapter} onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour} onTourToggle={toggleChapterTour}
/> />
)} )}
<div className="rs-replay-shell"> <div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
<div className="rs-replay-main"> <div className="rs-replay-main">
{hasChartData ? ( {hasChartData ? (
chartContent chartContent
) : ( ) : (
<div className="analysis-notice"> <EmptyStateCard
<strong>Lap-by-lap positions not available.</strong> This session does not icon={LineChart}
have ingested position samples in <code>/api/v1/race-hub</code>. title="Lap-by-lap positions not available"
</div> hint={
<>
This session does not have ingested position samples in{' '}
<code>/api/v1/race-hub</code>.
</>
}
testId="race-story-no-positions"
className="race-story-empty-card"
/>
)} )}
</div> </div>
{mapOpen && ( {showMapPanel && (
<div className="rs-replay-map-slot"> <div className="rs-replay-map-slot" data-testid="replay-map-slot">
{circuitKey > 0 && outlineYear > 0 ? ( <ReplayTrackMap
<ReplayTrackMap outline={outlineQuery.data}
outline={outlineQuery.data} replay={replayQuery.data}
replay={replayQuery.data} tMs={replayTMs}
tMs={replayTMs} drivers={drivers}
drivers={drivers} results={results}
results={results} loading={outlineQuery.isLoading || replayQuery.isLoading}
loading={outlineQuery.isLoading || replayQuery.isLoading} error={outlineQuery.isError || replayQuery.isError}
error={outlineQuery.isError || replayQuery.isError} />
/>
) : (
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -542,30 +656,29 @@ export function RaceStoryCanvas({ data }: Props) {
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0 const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
const currentPos = scrubTime !== null ? i + 1 : r.position const currentPos = scrubTime !== null ? i + 1 : r.position
const isWinner = i === 0 && r.position === 1 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 let currentPoints: number | string = r.points
if (scrubTime !== null) { if (scrubTime !== null) {
const isSprint = data.session?.session_type?.toLowerCase().includes('sprint') 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] 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 currentPoints = currentPos <= ptsArray.length ? ptsArray[currentPos - 1] : 0
} }
return ( return (
<div <div
key={r.driver_number} key={r.driver_number}
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`} className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
onMouseEnter={() => setHoverDriver(r.driver_number)} onMouseEnter={() => setHoverDriver(r.driver_number)}
onMouseLeave={() => setHoverDriver(null)} onMouseLeave={() => setHoverDriver(null)}
> >
<div className="rs-driver-left"> <div className="rs-driver-left">
<div className={`rs-pos-col ${pClass}`}> <div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
{currentPos}
</div>
<div className="rs-driver-cell"> <div className="rs-driver-cell">
<div <div
className="rs-driver-color" className="rs-driver-color"
style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }} style={{ background: r.team_colour ? `#${r.team_colour}` : 'var(--border)' }}
/> />
<div className="rs-driver-identity"> <div className="rs-driver-identity">
<span className="rs-driver-name">{r.name_acronym || r.driver_number}</span> <span className="rs-driver-name">{r.name_acronym || r.driver_number}</span>
@@ -583,12 +696,12 @@ export function RaceStoryCanvas({ data }: Props) {
</span> </span>
<span className="rs-metric-label">Grid</span> <span className="rs-metric-label">Grid</span>
</div> </div>
<div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}> <div className="rs-metric" style={{ width: '80px', opacity: scrubTime !== null ? 0.3 : 1 }}>
<span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span> <span>{isWinner ? formatDuration(r.duration) : formatGap(r.gap_to_leader)}</span>
<span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span> <span className="rs-metric-label">{isWinner ? 'Time' : 'Gap'}</span>
</div> </div>
<div className="rs-metric" style={{ width: '40px' }}> <div className="rs-metric" style={{ width: '40px' }}>
<span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}> <span style={{ color: Number(currentPoints) > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{currentPoints} {currentPoints}

View File

@@ -1,7 +1,9 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Loader2, MapPin, Satellite } from 'lucide-react'
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types' import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
import { buildOutlinePath } from '../lib/trackmap' import { buildOutlinePath } from '../lib/trackmap'
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay' import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
import { EmptyStateCard } from './EmptyStateCard'
import '../styles/replay-map.css' import '../styles/replay-map.css'
interface Props { interface Props {
@@ -60,7 +62,13 @@ export function ReplayTrackMap({
if (loading) { if (loading) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">loading replay GPS...</div> <EmptyStateCard
icon={Loader2}
title="Loading replay map"
hint="Fetching track outline and historical GPS frames."
testId="replay-map-loading"
className="replay-map-empty-card"
/>
</section> </section>
) )
} }
@@ -68,7 +76,13 @@ export function ReplayTrackMap({
if (error) { if (error) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">replay GPS unavailable for this session</div> <EmptyStateCard
icon={Satellite}
title="Replay GPS unavailable"
hint="This session does not have ingested location samples for the replay map."
testId="replay-map-error"
className="replay-map-empty-card"
/>
</section> </section>
) )
} }
@@ -76,7 +90,13 @@ export function ReplayTrackMap({
if (!outline || !outlinePath) { if (!outline || !outlinePath) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">track outline unavailable for this session</div> <EmptyStateCard
icon={MapPin}
title="Track outline unavailable"
hint="Circuit GPS outline data is missing for this session."
testId="replay-map-no-outline"
className="replay-map-empty-card"
/>
</section> </section>
) )
} }
@@ -84,7 +104,13 @@ export function ReplayTrackMap({
if (!replay?.frames?.length || cars.length === 0) { if (!replay?.frames?.length || cars.length === 0) {
return ( return (
<section className="replay-map-panel" data-testid="replay-track-map"> <section className="replay-map-panel" data-testid="replay-track-map">
<div className="replay-map-empty">historical GPS unavailable for this session</div> <EmptyStateCard
icon={Satellite}
title="Historical GPS unavailable"
hint="Fewer than two replay frames were returned for this session."
testId="replay-map-no-frames"
className="replay-map-empty-card"
/>
</section> </section>
) )
} }

View File

@@ -53,7 +53,10 @@ export function chapterEndScrub(
return Math.max(0, Math.min(1, (ms - tMin) / tRange)) return Math.max(0, Math.min(1, (ms - tMin) / tRange))
} }
/** Index of the chapter containing the current scrub position, if any. */ /** Index of the chapter containing the current scrub position, if any.
* Uses the same 01 clamped bounds as chapterStartScrub/chapterEndScrub so
* chapters whose timestamps fall outside the position-sample window still
* activate when the scrubber is parked at the clamped edge. */
export function activeChapterIndex( export function activeChapterIndex(
chapters: Chapter[], chapters: Chapter[],
scrubTime: number | null, scrubTime: number | null,
@@ -61,15 +64,13 @@ export function activeChapterIndex(
tRange: number, tRange: number,
): number | null { ): number | null {
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
const chartMs = tMin + scrubTime * tRange
for (let i = 0; i < chapters.length; i++) { for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i] const start = chapterStartScrub(chapters[i], tMin, tRange)
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN const end = chapterEndScrub(chapters[i], tMin, tRange)
const endRaw = ch.end_time ?? ch.start_time if (start === null || end === null) continue
const endMs = endRaw ? new Date(endRaw).getTime() : NaN const lo = Math.min(start, end)
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) { const hi = Math.max(start, end)
return i if (scrubTime >= lo && scrubTime <= hi) return i
}
} }
return null return null
} }
@@ -80,3 +81,52 @@ export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): num
const perChapter = totalMs / chapters.length const perChapter = totalMs / chapters.length
return chapters.map(() => perChapter) 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<number>([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<string | number, number> {
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]))
}

View File

@@ -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
}

View File

@@ -9,6 +9,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--s4); gap: var(--s4);
flex-wrap: wrap;
} }
.chapter-strip-title { .chapter-strip-title {
@@ -44,6 +45,31 @@
color: var(--text); 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 { .chapter-strip-scroll {
display: flex; display: flex;
gap: var(--s3); gap: var(--s3);
@@ -51,10 +77,26 @@
padding-bottom: var(--s2); padding-bottom: var(--s2);
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; -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 { .chapter-card {
flex: 0 0 min(240px, 72vw); flex: 0 0 min(240px, 72vw);
min-height: 88px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--s2); gap: var(--s2);
@@ -64,7 +106,7 @@
background: var(--surface); background: var(--surface);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
scroll-snap-align: start; scroll-snap-align: center;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
} }
@@ -145,10 +187,22 @@
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
flex: 1;
} }
.chapter-strip-empty { .chapter-strip-empty-card {
font-size: 12px; padding: var(--s6) var(--s5);
color: var(--text-3); margin: 0;
padding: var(--s3) 0; }
.chapter-strip-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
@media (max-width: 900px) {
.chapter-strip-header {
align-items: flex-start;
}
} }

View File

@@ -0,0 +1,157 @@
.race-story-empty-card {
padding: var(--s6) var(--s5);
margin: 0;
}
.race-story-empty-card .empty-state-icon {
display: block;
margin: 0 auto var(--s3);
color: var(--text-3);
}
.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;
}
}

View File

@@ -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 { .replay-map-panel {
min-height: 244px; min-height: 244px;
height: 100%; height: 100%;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
border-radius: var(--s2);
} }
.replay-map-stage { .replay-map-stage {
@@ -143,19 +76,16 @@
stroke-width: 1.2; stroke-width: 1.2;
} }
.replay-map-empty { .replay-map-empty-card {
min-height: 244px; min-height: 244px;
display: grid; display: grid;
place-items: center; place-content: center;
padding: var(--s5); padding: var(--s6);
color: var(--text-3); margin: 0;
font-family: var(--f-mono);
font-size: 11px;
text-align: center;
} }
@media (max-width: 860px) { .replay-map-empty-card .empty-state-icon {
.rs-replay-shell { display: block;
grid-template-columns: 1fr; margin: 0 auto var(--s3);
} color: var(--text-3);
} }

View File

@@ -68,6 +68,25 @@ describe('ChapterStrip', () => {
expect(screen.getByText('L12L15')).toBeInTheDocument() expect(screen.getByText('L12L15')).toBeInTheDocument()
}) })
it('renders an empty-state card when there are no chapters', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={[]}
scrubTime={null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-strip-empty')).toBeInTheDocument()
})
it('highlights the active chapter from scrub time', () => { it('highlights the active chapter from scrub time', () => {
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
renderStrip({ scrubTime: scrub }) renderStrip({ scrubTime: scrub })
@@ -84,4 +103,26 @@ describe('ChapterStrip', () => {
expect(index).toBe(1) expect(index).toBe(1)
expect(scrub).toBeCloseTo(0.6, 2) expect(scrub).toBeCloseTo(0.6, 2)
}) })
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={0}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
selectedChapterIndex={1}
onChapterClick={vi.fn()}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
})
}) })

View File

@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RaceStoryCanvas } from '../components/RaceStoryCanvas' import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types' import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
vi.mock('../api', () => ({ vi.mock('../api', () => ({
fetchReplayFrames: vi.fn(), fetchReplayFrames: vi.fn(),
@@ -28,9 +28,35 @@ const replay: ReplayFramesResponse = {
session_key: 99, session_key: 99,
interval_ms: 5000, interval_ms: 5000,
start_time: '2025-05-25T13:00:00Z', 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 = { const raceHub: RaceHub = {
source: 'local', source: 'local',
session_key: 99, session_key: 99,
@@ -103,14 +129,14 @@ const raceHub: RaceHub = {
race_control: [], race_control: [],
weather: [], weather: [],
laps: [], laps: [],
chapters: [], chapters,
} }
function renderCanvas() { function renderCanvas(overrides: Partial<RaceHub> = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render( return render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RaceStoryCanvas data={raceHub} /> <RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
@@ -122,14 +148,93 @@ describe('RaceStoryCanvas replay map', () => {
mockFetchTrackOutline.mockResolvedValue(outline) 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() renderCanvas()
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000)) await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025) 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(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('highlights out-of-window chapters after click (clamped scrub + selection)', async () => {
// Position samples start at 13:05; start chapter ends at 13:01 — outside the window.
renderCanvas({
positions: [
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 },
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 },
],
chapters: [
{
kind: 'start',
title: 'Start',
headline: 'Lights out before samples',
start_lap: 1,
end_lap: 1,
start_time: '2025-05-25T13:00:00Z',
end_time: '2025-05-25T13:01:00Z',
driver_numbers: [],
},
{
kind: 'finish',
title: 'Finish',
headline: 'Flag after samples',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
],
})
fireEvent.click(screen.getByTestId('chapter-card-0'))
await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active'))
expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active')
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()
}) })
}) })

View File

@@ -44,9 +44,10 @@ const results: EnrichedResult[] = [
] ]
describe('ReplayTrackMap', () => { describe('ReplayTrackMap', () => {
it('renders an empty state when replay frames are missing', () => { it('renders an empty-state card when replay frames are missing', () => {
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />) render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
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', () => { it('renders car labels from result metadata', () => {

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
activeChapterIndex, activeChapterIndex,
chapterBandFill,
chapterKindLabel, chapterKindLabel,
chapterLapRange, chapterLapRange,
chapterStartScrub, chapterStartScrub,
chapterTourDurations, chapterTourDurations,
deCollideYPositions,
decimatedPositionLabels,
} from '../lib/chapters' } from '../lib/chapters'
import type { Chapter } from '../types' import type { Chapter } from '../types'
@@ -57,7 +60,65 @@ describe('chapters lib', () => {
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1) expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
}) })
it('activates chapters whose timestamps clamp outside the position window', () => {
// Position samples only cover 13:0513:10; chapters sit before/after that window.
const tMin = new Date('2025-05-25T13:05:00Z').getTime()
const tMax = new Date('2025-05-25T13:10:00Z').getTime()
const tRange = tMax - tMin
const outOfWindow: 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: 'finish',
title: 'Finish',
headline: 'Chequered flag',
start_lap: 78,
end_lap: 78,
start_time: '2025-05-25T13:20:00Z',
end_time: '2025-05-25T13:21:00Z',
driver_numbers: [],
},
]
expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0)
expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0)
expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1)
expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull()
})
it('splits 90s evenly across chapters', () => { it('splits 90s evenly across chapters', () => {
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000]) 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)
})
}) })

View File

@@ -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)
})
})

View File

@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect( await expect(
page.getByRole('img', { name: 'Position evolution chart' }), page.getByRole('img', { name: 'Position evolution chart' }),
).toBeVisible() ).toBeVisible()
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible() await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible()
})
test('Race Story highlights a chapter card when clicked', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click()
const firstCard = page.getByTestId('chapter-card-0')
await expect(firstCard).toBeVisible()
await firstCard.click()
await expect(firstCard).toHaveClass(/active/)
}) })
test('strategy tab renders stint chart when stints are available', async ({ page }) => { test('strategy tab renders stint chart when stints are available', async ({ page }) => {
@@ -58,13 +68,15 @@ test.describe('Race Hub Weekend Workspace', () => {
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible() await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
}) })
test('Race Story shows missing notice when positions are unavailable', async ({ test('Race Story shows empty-state card when positions are unavailable', async ({
page, page,
}) => { }) => {
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
await page.getByRole('tab', { name: 'Race Story' }).click() await page.getByRole('tab', { name: 'Race Story' }).click()
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible() const empty = page.getByTestId('race-story-no-positions')
await expect(empty).toBeVisible()
await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible()
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible() await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -36,6 +36,14 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
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<void> { export async function gotoDataLibraryReady(page: Page): Promise<void> {
await page.goto('/admin') await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible() await expect(page.getByTestId('data-library')).toBeVisible()

View File

@@ -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')
})
})