Compare commits
10 Commits
faa173578c
...
v0.03.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 | ||
|
|
1dac73cdc5 | ||
|
|
02a84f67b4 | ||
|
|
f0a8e4e631 | ||
|
|
519053d340 | ||
|
|
e0d7b9981e | ||
|
|
79acf0579a | ||
|
|
82abbd6b35 |
BIN
docs/phase-2/19-21-20-race-story.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/phase-2/22-driver-profile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/phase-2/23-teammate-h2h.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/phase-2/24-rivalry-compare.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/phase-2/25-race-preview.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/phase-2/26-briefing-digest.png
Normal file
|
After Width: | Height: | Size: 683 KiB |
@@ -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 {
|
||||
@@ -14,6 +17,8 @@ interface Props {
|
||||
tRange: number
|
||||
tourActive: boolean
|
||||
tourChapterIndex: number | null
|
||||
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
||||
selectedChapterIndex?: number | null
|
||||
onChapterClick: (index: number, scrub: number) => void
|
||||
onTourToggle: () => void
|
||||
}
|
||||
@@ -25,19 +30,38 @@ export function ChapterStrip({
|
||||
tRange,
|
||||
tourActive,
|
||||
tourChapterIndex,
|
||||
selectedChapterIndex = null,
|
||||
onChapterClick,
|
||||
onTourToggle,
|
||||
}: 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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||
|
||||
return (
|
||||
<div className="chapter-strip" data-testid="chapter-strip">
|
||||
<div className="chapter-strip-header">
|
||||
@@ -53,12 +77,16 @@ export function ChapterStrip({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters">
|
||||
<div className="chapter-strip-scroll-wrap">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="chapter-strip-scroll"
|
||||
role="list"
|
||||
aria-label="Race story chapters"
|
||||
>
|
||||
{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 isActive = highlightedIndex === index
|
||||
const headline = chapter.headline || chapter.title
|
||||
|
||||
return (
|
||||
@@ -83,5 +111,6 @@ export function ChapterStrip({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
20
frontend/src/components/EmptyStateCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
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 ChartTiming {
|
||||
tMin: number
|
||||
tMax: number
|
||||
tRange: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -25,6 +48,48 @@ interface Props {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position samples are recorded for the entire session, including the pre-race
|
||||
* grid period. Use the winning driver's laps to define the race window so
|
||||
* that the x-axis starts at lights-out rather than at the earliest sample.
|
||||
*/
|
||||
function raceChartTiming(
|
||||
positions: PositionSample[],
|
||||
laps: Lap[],
|
||||
results: EnrichedResult[],
|
||||
): ChartTiming | null {
|
||||
const positionTimes = positions
|
||||
.map((position) => new Date(position.date).getTime())
|
||||
.filter(Number.isFinite)
|
||||
if (positionTimes.length === 0) return null
|
||||
|
||||
const fallbackMin = Math.min(...positionTimes)
|
||||
const fallbackMax = Math.max(...positionTimes)
|
||||
const fallback = {
|
||||
tMin: fallbackMin,
|
||||
tMax: fallbackMax,
|
||||
tRange: Math.max(fallbackMax - fallbackMin, 1),
|
||||
}
|
||||
|
||||
const winner = results.find((result) => result.position === 1)
|
||||
if (!winner) return fallback
|
||||
|
||||
const winnerLaps = laps
|
||||
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
|
||||
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
|
||||
.filter((lap) => Number.isFinite(lap.start))
|
||||
.sort((a, b) => a.lap_number - b.lap_number)
|
||||
if (winnerLaps.length === 0) return fallback
|
||||
|
||||
const firstLap = winnerLaps[0]
|
||||
const lastLap = winnerLaps[winnerLaps.length - 1]
|
||||
const finalLapDuration = lastLap.lap_duration ?? 0
|
||||
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
|
||||
if (tMax <= firstLap.start) return fallback
|
||||
|
||||
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
const {
|
||||
results,
|
||||
@@ -48,32 +113,46 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
||||
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(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(() => {
|
||||
if (!hasChartData) return null
|
||||
const tMin = new Date(allTimes[0]).getTime()
|
||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
||||
}, [allTimes, hasChartData])
|
||||
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||
const hasChartData = hasPositions && chartTiming !== null
|
||||
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
|
||||
|
||||
@@ -104,9 +183,14 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const jumpToChapter = (index: number, scrub: number) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
setSelectedChapterIndex(index)
|
||||
setScrubTime(scrub)
|
||||
}
|
||||
|
||||
const clearChapterSelection = () => {
|
||||
setSelectedChapterIndex(null)
|
||||
}
|
||||
|
||||
const toggleChapterTour = () => {
|
||||
if (chapterTourActive) {
|
||||
stopChapterTour()
|
||||
@@ -114,6 +198,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
}
|
||||
if (!chartTiming || chapters.length === 0) return
|
||||
setIsPlaying(false)
|
||||
setSelectedChapterIndex(null)
|
||||
setChapterTourActive(true)
|
||||
setTourChapterIndex(0)
|
||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
@@ -194,16 +279,19 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
|
||||
if (hasChartData && chartTiming) {
|
||||
const { tMin, tRange } = chartTiming
|
||||
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time)) continue
|
||||
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||
byDriver.get(p.driver_number)!.push({
|
||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
||||
t: normaliseTime(time),
|
||||
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 +299,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,34 +332,37 @@ 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) {
|
||||
for (let i = 0; i < winnerLaps.length; i++) {
|
||||
const lap = winnerLaps[i]
|
||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
||||
if (t >= 0 && t <= 1) {
|
||||
lapTicks.push({ lap: lap.lap_number, t })
|
||||
}
|
||||
const lapStart = new Date(lap.date_start).getTime()
|
||||
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
||||
? lapStart + lap.lap_duration * 1000
|
||||
: nextLapStart
|
||||
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
@@ -296,25 +387,73 @@ 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<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>) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
clearChapterSelection()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
if (rect.width <= 0) return
|
||||
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
|
||||
// before comparing with the fixed chart margins and plot width.
|
||||
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||
setScrubTime(t)
|
||||
}
|
||||
|
||||
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
|
||||
ref={svgRef}
|
||||
className="rs-position-chart-svg"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
||||
role="img"
|
||||
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) => {
|
||||
const startT = (sc.start - tMin) / tRange
|
||||
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||
@@ -333,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
)
|
||||
})}
|
||||
|
||||
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
||||
{positionLabels.map((pos) => (
|
||||
<g key={pos}>
|
||||
<line
|
||||
x1={PL}
|
||||
x2={W - PR}
|
||||
y1={toY(pos)}
|
||||
y2={toY(pos)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={0.5}
|
||||
className="rs-chart-grid-line"
|
||||
/>
|
||||
<text
|
||||
x={PL - 4}
|
||||
y={toY(pos) + 4}
|
||||
textAnchor="end"
|
||||
fill="var(--text-3)"
|
||||
fontSize={8}
|
||||
fontFamily="var(--f-mono)"
|
||||
className="rs-chart-axis-label"
|
||||
>
|
||||
P{pos}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{lapTicks.map(tick => (
|
||||
{lapTicks.map((tick) => (
|
||||
<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} />
|
||||
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
||||
<line
|
||||
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}
|
||||
</text>
|
||||
</g>
|
||||
@@ -372,8 +519,9 @@ 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 (
|
||||
<g
|
||||
@@ -394,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
/>
|
||||
|
||||
{driverPits.map((p, i) => {
|
||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||
if (t < 0 || t > 1) return null
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||
const t = normaliseTime(time)
|
||||
const pos = getInterpPos(samples, t)
|
||||
if (pos === null) return null
|
||||
return (
|
||||
@@ -415,7 +564,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
{last && (
|
||||
<text
|
||||
x={toX(last.t) + 6}
|
||||
y={toY(last.pos) + 4}
|
||||
y={labelY + 4}
|
||||
fill={color}
|
||||
fontSize={isHovered ? 11 : 9}
|
||||
fontFamily="var(--f-mono)"
|
||||
@@ -449,44 +598,57 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
width={plotW}
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
data-testid="position-chart-interaction"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) setScrubTime(null)
|
||||
if (!isPlaying) {
|
||||
clearChapterSelection()
|
||||
setScrubTime(null)
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
/>
|
||||
</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
|
||||
type="button"
|
||||
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
||||
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setScrubTime((current) => current ?? 0)
|
||||
stopChapterTour()
|
||||
clearChapterSelection()
|
||||
setIsPlaying((current) => !current)
|
||||
}}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
<div className="rs-speed-group" aria-label="Playback speed">
|
||||
<span className="rs-segment-divider" aria-hidden />
|
||||
{[1, 10, 30].map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
||||
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
||||
onClick={() => setPlaybackSpeed(speed)}
|
||||
aria-pressed={playbackSpeed === speed}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mapProbeSettled && mapAvailable && (
|
||||
<>
|
||||
<span className="rs-segment-divider" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
||||
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
||||
onClick={() => setMapOpen((current) => !current)}
|
||||
aria-pressed={mapOpen}
|
||||
data-testid="replay-map-toggle"
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -502,24 +664,32 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
tRange={chartTiming.tRange}
|
||||
tourActive={chapterTourActive}
|
||||
tourChapterIndex={tourChapterIndex}
|
||||
selectedChapterIndex={selectedChapterIndex}
|
||||
onChapterClick={jumpToChapter}
|
||||
onTourToggle={toggleChapterTour}
|
||||
/>
|
||||
)}
|
||||
<div className="rs-replay-shell">
|
||||
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
||||
<div className="rs-replay-main">
|
||||
{hasChartData ? (
|
||||
chartContent
|
||||
) : (
|
||||
<div className="analysis-notice">
|
||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||
</div>
|
||||
<EmptyStateCard
|
||||
icon={LineChart}
|
||||
title="Lap-by-lap positions not available"
|
||||
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>
|
||||
{mapOpen && (
|
||||
<div className="rs-replay-map-slot">
|
||||
{circuitKey > 0 && outlineYear > 0 ? (
|
||||
{showMapPanel && (
|
||||
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
||||
<ReplayTrackMap
|
||||
outline={outlineQuery.data}
|
||||
replay={replayQuery.data}
|
||||
@@ -529,9 +699,6 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||
error={outlineQuery.isError || replayQuery.isError}
|
||||
/>
|
||||
) : (
|
||||
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -542,7 +709,8 @@ 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) {
|
||||
@@ -559,9 +727,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
onMouseLeave={() => setHoverDriver(null)}
|
||||
>
|
||||
<div className="rs-driver-left">
|
||||
<div className={`rs-pos-col ${pClass}`}>
|
||||
{currentPos}
|
||||
</div>
|
||||
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
|
||||
<div className="rs-driver-cell">
|
||||
<div
|
||||
className="rs-driver-color"
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +76,13 @@ export function ReplayTrackMap({
|
||||
if (error) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +90,13 @@ export function ReplayTrackMap({
|
||||
if (!outline || !outlinePath) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -84,7 +104,13 @@ export function ReplayTrackMap({
|
||||
if (!replay?.frames?.length || cars.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { teamColor } from '../utils'
|
||||
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||
import '../styles/rivalry.css'
|
||||
|
||||
const PAD_L = 48
|
||||
const PAD_T = 16
|
||||
const PLOT_W = 882
|
||||
const PLOT_H = 316
|
||||
const GAP_PLOT_H = 200
|
||||
|
||||
function fmtPts(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
/** Up to ~7 evenly spaced round labels, always including the last round. */
|
||||
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
|
||||
const step = Math.max(1, Math.ceil(n / 7))
|
||||
const ticks: { x: number; label: string }[] = []
|
||||
for (let i = 0; i < n; i += step) {
|
||||
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||
}
|
||||
const last = labels[n - 1] ?? `R${n}`
|
||||
if (ticks[ticks.length - 1]?.label !== last) {
|
||||
ticks.push({ x: x(n - 1), label: last })
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
|
||||
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
id: 'a' | 'b'
|
||||
label: string
|
||||
drivers: ChampHubDriver[]
|
||||
value: number
|
||||
color: string
|
||||
onChange: (driverNumber: number) => void
|
||||
}
|
||||
|
||||
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
|
||||
return (
|
||||
<label className="rivalry-picker">
|
||||
<span className="rivalry-picker-label">{label}</span>
|
||||
<select
|
||||
className="rivalry-picker-select"
|
||||
style={{ borderLeft: `3px solid ${color}` }}
|
||||
value={value}
|
||||
data-testid={`rivalry-pick-${id}`}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
>
|
||||
{drivers.map((d) => (
|
||||
<option key={d.driver_number} value={d.driver_number}>
|
||||
{d.name_acronym} · {d.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
|
||||
const { drivers } = hub
|
||||
const [pickA, setPickA] = useState<number | null>(null)
|
||||
const [pickB, setPickB] = useState<number | null>(null)
|
||||
|
||||
// Default to the top two in the standings; fall back there if a picked
|
||||
// driver disappears (e.g. season switch re-fetches the hub).
|
||||
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
|
||||
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
|
||||
|
||||
const tally = useMemo(
|
||||
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
|
||||
[a, b, hub.round_labels],
|
||||
)
|
||||
|
||||
if (drivers.length < 2) {
|
||||
return (
|
||||
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
|
||||
Need at least two drivers in the standings to compare a rivalry.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colorA = teamColor(a.team_colour)
|
||||
const colorB = teamColor(b.team_colour)
|
||||
const sameTeam = a.team_name === b.team_name
|
||||
|
||||
const cumA = a.cumulative ?? []
|
||||
const cumB = b.cumulative ?? []
|
||||
const rounds = Math.min(cumA.length, cumB.length)
|
||||
const gaps = gapSeries(cumA, cumB)
|
||||
const lastGap = gaps[gaps.length - 1] ?? 0
|
||||
const strip = tally ? lastRounds(tally, 5) : []
|
||||
|
||||
const pickers = (
|
||||
<div className="rivalry-pickers">
|
||||
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
|
||||
<span className="rivalry-vs mono">vs</span>
|
||||
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (rounds === 0) {
|
||||
return (
|
||||
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||
{pickers}
|
||||
<div className="champ-chart-empty">
|
||||
No completed rounds yet — the rivalry will appear after the first race.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Points race scales.
|
||||
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
|
||||
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
|
||||
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
|
||||
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
|
||||
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
|
||||
const pxGrid = xTicks(rounds, hub.round_labels, px)
|
||||
|
||||
// Gap scales: symmetric around zero.
|
||||
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
|
||||
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
|
||||
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
|
||||
const gxGrid = xTicks(rounds, hub.round_labels, px)
|
||||
|
||||
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
|
||||
const gapCaption = gapLeader
|
||||
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
|
||||
: 'Dead level on points.'
|
||||
|
||||
return (
|
||||
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||
{pickers}
|
||||
|
||||
<div className="rivalry-h2h" data-testid="rivalry-h2h">
|
||||
<div className="rivalry-h2h-score">
|
||||
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
|
||||
{a.name_acronym}
|
||||
</span>
|
||||
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
|
||||
{tally ? `${tally.a}–${tally.b}` : '—'}
|
||||
</span>
|
||||
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
|
||||
{b.name_acronym}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rivalry-h2h-meta">
|
||||
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
|
||||
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
|
||||
</span>
|
||||
{strip.length > 0 && (
|
||||
<div className="rivalry-strip" data-testid="rivalry-strip">
|
||||
<span className="rivalry-strip-label">Last {strip.length}</span>
|
||||
{strip.map((r) => (
|
||||
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
|
||||
<span className="rivalry-chip-round mono">{r.label}</span>
|
||||
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
|
||||
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="rivalry-section" data-testid="rivalry-points-race">
|
||||
<div className="champ-chart-head">
|
||||
<span className="champ-chart-title mono">
|
||||
Points race — {a.name_acronym} vs {b.name_acronym}
|
||||
</span>
|
||||
<span className="champ-chart-meta">
|
||||
Rounds 1–{rounds} · {hub.season}
|
||||
</span>
|
||||
</div>
|
||||
<div className="champ-chart">
|
||||
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
|
||||
{pyGrid.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{pxGrid.map((g, i) => (
|
||||
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
))}
|
||||
{[
|
||||
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
|
||||
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
|
||||
].map((s) => (
|
||||
<g key={s.slot}>
|
||||
<polyline
|
||||
points={polyline(s.values, px, py)}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth="2.4"
|
||||
strokeDasharray={s.dash}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
|
||||
<text
|
||||
x={px(s.values.length - 1) + 8}
|
||||
y={py(s.values[s.values.length - 1]) + 4}
|
||||
fill={s.color}
|
||||
className="champ-chart-label"
|
||||
>
|
||||
{s.d.name_acronym}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rivalry-section" data-testid="rivalry-gap">
|
||||
<div className="champ-chart-head">
|
||||
<span className="champ-chart-title mono">
|
||||
Gap over season — {a.name_acronym} − {b.name_acronym}
|
||||
</span>
|
||||
<span className="champ-chart-meta">{gapCaption}</span>
|
||||
</div>
|
||||
<div className="champ-chart">
|
||||
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
|
||||
{gyGrid.map((g) => (
|
||||
<g key={g.label}>
|
||||
<line
|
||||
x1={PAD_L}
|
||||
y1={g.y}
|
||||
x2={930}
|
||||
y2={g.y}
|
||||
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
|
||||
strokeWidth={g.label === '0' ? 1.5 : 1}
|
||||
/>
|
||||
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{gxGrid.map((g, i) => (
|
||||
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
|
||||
{g.label}
|
||||
</text>
|
||||
))}
|
||||
<polyline
|
||||
points={polyline(gaps, px, gy)}
|
||||
fill="none"
|
||||
stroke={colorA}
|
||||
strokeWidth="2.4"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="rivalry-caption">
|
||||
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -53,7 +53,10 @@ export function chapterEndScrub(
|
||||
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 0–1 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(
|
||||
chapters: Chapter[],
|
||||
scrubTime: number | null,
|
||||
@@ -61,15 +64,13 @@ export function activeChapterIndex(
|
||||
tRange: number,
|
||||
): number | null {
|
||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||
const chartMs = tMin + scrubTime * tRange
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const ch = chapters[i]
|
||||
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
|
||||
const endRaw = ch.end_time ?? ch.start_time
|
||||
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
|
||||
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
|
||||
return i
|
||||
}
|
||||
const start = chapterStartScrub(chapters[i], tMin, tRange)
|
||||
const end = chapterEndScrub(chapters[i], tMin, tRange)
|
||||
if (start === null || end === null) continue
|
||||
const lo = Math.min(start, end)
|
||||
const hi = Math.max(start, end)
|
||||
if (scrubTime >= lo && scrubTime <= hi) return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -80,3 +81,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<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]))
|
||||
}
|
||||
|
||||
14
frontend/src/lib/replayMap.ts
Normal 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
|
||||
}
|
||||
61
frontend/src/lib/rivalry.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Pure helpers for the championship rivalry compare view.
|
||||
|
||||
/** One round that counted toward the head-to-head tally. */
|
||||
export interface H2HRound {
|
||||
round: number // 1-based season round
|
||||
label: string
|
||||
posA: number
|
||||
posB: number
|
||||
winner: 'a' | 'b'
|
||||
}
|
||||
|
||||
export interface H2HTally {
|
||||
a: number
|
||||
b: number
|
||||
/** Rounds counted in the tally, in season order. */
|
||||
rounds: H2HRound[]
|
||||
/** Rounds skipped because either driver had no finishing position. */
|
||||
skipped: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-round points gap (a − b) across the rounds both cumulative series
|
||||
* cover. Positive values mean driver A is ahead.
|
||||
*/
|
||||
export function gapSeries(a: number[], b: number[]): number[] {
|
||||
const n = Math.min(a.length, b.length)
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Race head-to-head: per round, the lower finishing position wins. Rounds
|
||||
* where either driver has no position (0 / missing) are skipped from the
|
||||
* tally, as are equal positions (defensive — races can't tie).
|
||||
*/
|
||||
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
|
||||
const n = Math.max(posA.length, posB.length)
|
||||
const rounds: H2HRound[] = []
|
||||
let a = 0
|
||||
let b = 0
|
||||
let skipped = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pa = posA[i] ?? 0
|
||||
const pb = posB[i] ?? 0
|
||||
if (pa <= 0 || pb <= 0 || pa === pb) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const winner = pa < pb ? 'a' : 'b'
|
||||
if (winner === 'a') a++
|
||||
else b++
|
||||
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
|
||||
}
|
||||
return { a, b, rounds, skipped }
|
||||
}
|
||||
|
||||
/** Last n counted rounds of a tally, for the mini strip. */
|
||||
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
|
||||
return tally.rounds.slice(-n)
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||
import { teamColor } from '../utils'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import { Meaning } from '../components/Meaning'
|
||||
import { TeammateH2H } from '../components/TeammateH2H'
|
||||
import { teammatePairs } from '../lib/h2h'
|
||||
import { pointsGapMeaning } from '../lib/meaning'
|
||||
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
||||
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||
|
||||
const GOLD = '#ffd700'
|
||||
const SILVER = '#c0c0c0'
|
||||
@@ -216,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
>
|
||||
Progression
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
|
||||
onClick={() => setView('rivalry')}
|
||||
data-testid="champ-tab-rivalry"
|
||||
>
|
||||
Rivalry
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||||
@@ -251,6 +260,7 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
||||
)}
|
||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||
{view === 'rivalry' && <RivalryCompare hub={hub} />}
|
||||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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,22 @@
|
||||
-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 {
|
||||
display: block;
|
||||
margin: 0 auto var(--s3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chapter-strip-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
157
frontend/src/styles/race-story.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,16 @@
|
||||
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 {
|
||||
display: block;
|
||||
margin: 0 auto var(--s3);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
134
frontend/src/styles/rivalry.css
Normal file
@@ -0,0 +1,134 @@
|
||||
.rivalry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
|
||||
.rivalry-pickers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s4);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rivalry-vs {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding-bottom: var(--s3);
|
||||
}
|
||||
|
||||
.rivalry-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.rivalry-picker-label {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rivalry-picker-select {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 13px;
|
||||
padding: var(--s2) var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rivalry-picker-select:focus {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.rivalry-h2h {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s4) var(--s5);
|
||||
padding: var(--s4) var(--s5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.rivalry-h2h-score {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.rivalry-h2h-code {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rivalry-h2h-num {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rivalry-h2h-meta {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rivalry-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rivalry-strip-label {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin-right: var(--s2);
|
||||
}
|
||||
|
||||
.rivalry-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: var(--s1) var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.rivalry-chip-round {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.rivalry-chip-winner {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rivalry-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.rivalry-caption {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ const drivers: ChampHubDriver[] = [
|
||||
cumulative: [25, 43, 68, 100],
|
||||
teammate_wins: 4,
|
||||
teammate_losses: 1,
|
||||
round_positions: [],
|
||||
},
|
||||
{
|
||||
driver_number: 4,
|
||||
@@ -89,6 +90,7 @@ const drivers: ChampHubDriver[] = [
|
||||
cumulative: [18, 36, 54, 80],
|
||||
teammate_wins: 3,
|
||||
teammate_losses: 2,
|
||||
round_positions: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 4,
|
||||
form: [25, 18, 25, 15, 25],
|
||||
cumulative: [25, 43, 68, 83, 108, 200],
|
||||
round_positions: [1, 2, 1, 3, 1, 1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
|
||||
position: 2,
|
||||
wins: 3,
|
||||
cumulative: [18, 36, 54, 80, 120, 160],
|
||||
round_positions: [2, 1, 2, 2, 2, 2],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 16,
|
||||
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
|
||||
position: 3,
|
||||
wins: 1,
|
||||
cumulative: [15, 28, 40, 60, 90, 120],
|
||||
round_positions: [3, 3, 3, 0, 3, 3],
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -129,6 +132,22 @@ describe('ChampionshipPage', () => {
|
||||
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the rivalry view with the top two drivers preselected', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||
// Default pair is the top two in the standings: VER vs NOR.
|
||||
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||
// VER beats NOR in rounds 1, 3, 5, 6 → 4–2.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('4–2')
|
||||
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the simulator view and projects standings', async () => {
|
||||
renderPage()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 0,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
|
||||
@@ -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(
|
||||
<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', () => {
|
||||
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
||||
renderStrip({ scrubTime: scrub })
|
||||
@@ -84,4 +103,26 @@ describe('ChapterStrip', () => {
|
||||
expect(index).toBe(1)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +109,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
round_positions: [1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
|
||||
@@ -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<RaceHub> = {}) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RaceStoryCanvas data={raceHub} />
|
||||
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
@@ -122,14 +148,144 @@ 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('uses lap timing rather than pre-race position samples for the chart scale', () => {
|
||||
const laps = Array.from({ length: 20 }, (_, index) => ({
|
||||
session_key: 99,
|
||||
driver_number: 1,
|
||||
meeting_key: 1,
|
||||
lap_number: index + 1,
|
||||
date_start: `2025-05-25T13:${String(index).padStart(2, '0')}:00Z`,
|
||||
lap_duration: 60,
|
||||
is_pit_out_lap: false,
|
||||
}))
|
||||
|
||||
renderCanvas({
|
||||
results: [{ ...raceHub.results[0], number_of_laps: 20 }],
|
||||
laps,
|
||||
positions: [
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T12:00:00Z', position: 1 },
|
||||
{ session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:20:00Z', position: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
// L10 is the end of the tenth 60s lap, exactly halfway through the race.
|
||||
const lapTenLabel = screen.getByText('L10')
|
||||
expect(lapTenLabel.previousElementSibling).toHaveAttribute('x1', '316')
|
||||
// The pre-race position is retained as the starting grid at the left edge.
|
||||
expect(document.querySelector('.rs-driver-line')).toHaveAttribute('points', '40,8 592,8 592,8')
|
||||
})
|
||||
|
||||
it('maps cursor positions from rendered pixels to the SVG viewBox', () => {
|
||||
renderCanvas()
|
||||
|
||||
const svg = document.querySelector('.rs-position-chart-svg')!
|
||||
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1280,
|
||||
height: 360,
|
||||
top: 0,
|
||||
right: 1280,
|
||||
bottom: 360,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
const pointerMove = new Event('pointermove', { bubbles: true })
|
||||
Object.defineProperty(pointerMove, 'clientX', { value: 640 })
|
||||
fireEvent(screen.getByTestId('position-chart-interaction'), pointerMove)
|
||||
|
||||
// 640px is the middle of a 1280px rendered chart, which is x=320 in its 640-unit viewBox.
|
||||
expect(document.querySelector('.rs-playhead')).toHaveAttribute('x1', '320')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<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', () => {
|
||||
|
||||
114
frontend/src/test/RivalryCompare.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { RivalryCompare } from '../components/RivalryCompare'
|
||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||
|
||||
function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
return {
|
||||
driver_number: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull',
|
||||
team_colour: '3671c6',
|
||||
points: 100,
|
||||
position: 1,
|
||||
wins: 2,
|
||||
podiums: 3,
|
||||
poles: 1,
|
||||
form: [25, 18, 25],
|
||||
cumulative: [25, 43, 68],
|
||||
round_positions: [1, 2, 1],
|
||||
teammate_wins: 3,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function makeHub(drivers: ChampHubDriver[]): ChampionshipHub {
|
||||
return {
|
||||
season: 2025,
|
||||
round: 3,
|
||||
total_rounds: 10,
|
||||
rounds_left: 7,
|
||||
last_race: 'Japan GP',
|
||||
round_labels: ['R1', 'R2', 'R3'],
|
||||
drivers,
|
||||
teams: [],
|
||||
}
|
||||
}
|
||||
|
||||
const hub = makeHub([
|
||||
driver({}),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
full_name: 'Lando Norris',
|
||||
team_name: 'McLaren',
|
||||
team_colour: 'ff8000',
|
||||
points: 90,
|
||||
position: 2,
|
||||
cumulative: [18, 43, 61],
|
||||
round_positions: [2, 1, 2],
|
||||
}),
|
||||
driver({
|
||||
driver_number: 16,
|
||||
name_acronym: 'LEC',
|
||||
full_name: 'Charles Leclerc',
|
||||
team_name: 'Ferrari',
|
||||
team_colour: 'e8002d',
|
||||
points: 50,
|
||||
position: 3,
|
||||
cumulative: [15, 28, 40],
|
||||
round_positions: [3, 0, 3],
|
||||
}),
|
||||
])
|
||||
|
||||
describe('RivalryCompare', () => {
|
||||
it('defaults to the top two drivers and shows the H2H score', () => {
|
||||
render(<RivalryCompare hub={hub} />)
|
||||
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||
// VER wins R1 and R3, NOR wins R2.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–1')
|
||||
expect(screen.getByText('3 rounds counted', { exact: false })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||
// Last gap: 68 − 61 = +7 → VER ahead.
|
||||
expect(screen.getByText('VER leads by 7 pts', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recomputes when a different driver is picked and skips missing rounds', () => {
|
||||
render(<RivalryCompare hub={hub} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('rivalry-pick-b'), { target: { value: '16' } })
|
||||
|
||||
// VER vs LEC: R2 skipped (LEC has no position), VER wins R1 and R3.
|
||||
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('2–0')
|
||||
expect(screen.getByText('2 rounds counted · 1 skipped', { exact: false })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state with fewer than two drivers', () => {
|
||||
render(<RivalryCompare hub={makeHub([driver({})])} />)
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('at least two drivers')
|
||||
})
|
||||
|
||||
it('shows an empty message when no rounds are completed', () => {
|
||||
const empty = makeHub([
|
||||
driver({ cumulative: [], round_positions: [], form: [] }),
|
||||
driver({
|
||||
driver_number: 4,
|
||||
name_acronym: 'NOR',
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
form: [],
|
||||
}),
|
||||
])
|
||||
empty.round = 0
|
||||
empty.round_labels = []
|
||||
|
||||
render(<RivalryCompare hub={empty} />)
|
||||
expect(screen.getByTestId('champ-view-rivalry')).toHaveTextContent('No completed rounds yet')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -57,7 +60,65 @@ describe('chapters lib', () => {
|
||||
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
||||
})
|
||||
|
||||
it('activates chapters whose timestamps clamp outside the position window', () => {
|
||||
// Position samples only cover 13:05–13: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', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@ const driver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
cumulative: [25, 43, 68, 100],
|
||||
teammate_wins: 4,
|
||||
teammate_losses: 1,
|
||||
round_positions: [],
|
||||
...over,
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
cumulative: [],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
round_positions: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ const hubDriver = (over: Partial<ChampHubDriver>): ChampHubDriver => ({
|
||||
poles: 4,
|
||||
form: [25],
|
||||
cumulative: [200],
|
||||
round_positions: [1],
|
||||
teammate_wins: 9,
|
||||
teammate_losses: 1,
|
||||
...over,
|
||||
|
||||
33
frontend/src/test/replayMap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
71
frontend/src/test/rivalry.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||
|
||||
describe('gapSeries', () => {
|
||||
it('computes per-round a − b', () => {
|
||||
expect(gapSeries([25, 43, 68], [18, 36, 54])).toEqual([7, 7, 14])
|
||||
})
|
||||
|
||||
it('handles negative gaps (b ahead)', () => {
|
||||
expect(gapSeries([10, 20], [18, 36])).toEqual([-8, -16])
|
||||
})
|
||||
|
||||
it('truncates to the shorter series', () => {
|
||||
expect(gapSeries([25, 43, 68], [18])).toEqual([7])
|
||||
expect(gapSeries([], [18, 36])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('h2hTally', () => {
|
||||
it('tallies lower-position wins per round', () => {
|
||||
const t = h2hTally([1, 2, 1], [2, 1, 3], ['R1', 'R2', 'R3'])
|
||||
expect(t.a).toBe(2)
|
||||
expect(t.b).toBe(1)
|
||||
expect(t.skipped).toBe(0)
|
||||
expect(t.rounds.map((r) => r.winner)).toEqual(['a', 'b', 'a'])
|
||||
expect(t.rounds[0]).toEqual({ round: 1, label: 'R1', posA: 1, posB: 2, winner: 'a' })
|
||||
})
|
||||
|
||||
it('skips rounds where either driver has no position', () => {
|
||||
// R2: a missing (0). R3: b missing (0). Only R1 and R4 count.
|
||||
const t = h2hTally([1, 0, 5, 3], [4, 2, 0, 1], ['R1', 'R2', 'R3', 'R4'])
|
||||
expect(t.a).toBe(1)
|
||||
expect(t.b).toBe(1)
|
||||
expect(t.skipped).toBe(2)
|
||||
expect(t.rounds.map((r) => r.round)).toEqual([1, 4])
|
||||
})
|
||||
|
||||
it('handles arrays of different lengths (missing tail = skipped)', () => {
|
||||
const t = h2hTally([1, 2, 3], [2], ['R1', 'R2', 'R3'])
|
||||
expect(t.a).toBe(1)
|
||||
expect(t.b).toBe(0)
|
||||
expect(t.skipped).toBe(2)
|
||||
})
|
||||
|
||||
it('skips equal positions defensively and falls back on labels', () => {
|
||||
const t = h2hTally([2, 1], [2, 3], ['R1'])
|
||||
expect(t.a).toBe(1)
|
||||
expect(t.b).toBe(0)
|
||||
expect(t.skipped).toBe(1)
|
||||
expect(t.rounds[0].label).toBe('R2')
|
||||
})
|
||||
|
||||
it('returns an empty tally for empty inputs', () => {
|
||||
const t = h2hTally([], [], [])
|
||||
expect(t).toEqual({ a: 0, b: 0, rounds: [], skipped: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastRounds', () => {
|
||||
it('returns the last n counted rounds in order', () => {
|
||||
const t = h2hTally([1, 1, 1, 2, 1, 1, 2], [2, 2, 2, 1, 2, 2, 1], [])
|
||||
const last = lastRounds(t, 5)
|
||||
expect(last).toHaveLength(5)
|
||||
expect(last.map((r) => r.round)).toEqual([3, 4, 5, 6, 7])
|
||||
})
|
||||
|
||||
it('returns fewer when the tally has fewer counted rounds', () => {
|
||||
const t = h2hTally([1, 0], [2, 0], [])
|
||||
expect(lastRounds(t, 5)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
||||
poles: 0,
|
||||
form: [],
|
||||
cumulative: [],
|
||||
round_positions: [],
|
||||
teammate_wins: 0,
|
||||
teammate_losses: 0,
|
||||
...over,
|
||||
|
||||
@@ -391,6 +391,7 @@ export interface ChampHubDriver {
|
||||
poles: number
|
||||
form: number[]
|
||||
cumulative: number[]
|
||||
round_positions: number[]
|
||||
teammate_wins: number
|
||||
teammate_losses: number
|
||||
}
|
||||
|
||||
@@ -616,6 +616,7 @@ type champHubDriver struct {
|
||||
Poles int `json:"poles"`
|
||||
Form []float64 `json:"form"` // last 5 races' points
|
||||
Cumulative []float64 `json:"cumulative"` // running total per completed round
|
||||
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
|
||||
TeammateWins int `json:"teammate_wins"`
|
||||
TeammateLosses int `json:"teammate_losses"`
|
||||
}
|
||||
@@ -995,6 +996,10 @@ func aggregateChampionshipHub(
|
||||
if len(form) > 5 {
|
||||
form = form[len(form)-5:]
|
||||
}
|
||||
roundPositions := make([]int, completed)
|
||||
for round := 1; round <= completed; round++ {
|
||||
roundPositions[round-1] = a.finishByRound[round]
|
||||
}
|
||||
info := driverInfo[c.DriverNumber]
|
||||
drivers = append(drivers, champHubDriver{
|
||||
DriverNumber: c.DriverNumber,
|
||||
@@ -1009,6 +1014,7 @@ func aggregateChampionshipHub(
|
||||
Poles: a.poles,
|
||||
Form: form,
|
||||
Cumulative: cumulative[c.DriverNumber],
|
||||
RoundPositions: roundPositions,
|
||||
TeammateWins: twins[c.DriverNumber],
|
||||
TeammateLosses: tloss[c.DriverNumber],
|
||||
})
|
||||
|
||||
@@ -17,20 +17,22 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||
2: {DriverNumber: 2, NameAcronym: "PER", FullName: "Sergio Perez", TeamName: "Red Bull", TeamColour: "3671c6"},
|
||||
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
||||
4: {DriverNumber: 4, NameAcronym: "NOR", FullName: "Lando Norris", TeamName: "McLaren", TeamColour: "ff8000"},
|
||||
}
|
||||
|
||||
champ := []models.ChampionshipDriver{
|
||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||
{DriverNumber: 2, PointsCurrent: 30, PositionCurrent: 3, SessionKey: 99},
|
||||
{DriverNumber: 4, PointsCurrent: 12, PositionCurrent: 4, SessionKey: 99},
|
||||
}
|
||||
teams := []models.ChampionshipTeam{
|
||||
{TeamName: "Red Bull", PointsCurrent: 80, PositionCurrent: 1},
|
||||
{TeamName: "Mercedes", PointsCurrent: 33, PositionCurrent: 2},
|
||||
}
|
||||
|
||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER.
|
||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15). Pole: HAM.
|
||||
// Round 1: VER P1(25), HAM P2(18), PER P3(15). Pole: VER. NOR absent.
|
||||
// Round 2: VER P1(25), PER P2(18), HAM P3(15), NOR P4(12). Pole: HAM.
|
||||
races := []meetingRace{
|
||||
{
|
||||
Meeting: models.Meeting{MeetingName: "Bahrain GP"},
|
||||
@@ -39,7 +41,7 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Meeting: models.Meeting{MeetingName: "Saudi GP"},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15)},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(2, 2, 18), raceResult(3, 3, 15), raceResult(4, 4, 12)},
|
||||
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}},
|
||||
},
|
||||
// Round 3: not yet run (no results) — should not count as completed.
|
||||
@@ -67,9 +69,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
||||
}
|
||||
|
||||
// Drivers are sorted by official position: VER, HAM, PER.
|
||||
if len(resp.Drivers) != 3 {
|
||||
t.Fatalf("drivers = %d, want 3", len(resp.Drivers))
|
||||
// Drivers are sorted by official position: VER, HAM, PER, NOR.
|
||||
if len(resp.Drivers) != 4 {
|
||||
t.Fatalf("drivers = %d, want 4", len(resp.Drivers))
|
||||
}
|
||||
ver := resp.Drivers[0]
|
||||
if ver.NameAcronym != "VER" || ver.Position != 1 {
|
||||
@@ -91,6 +93,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
if len(ver.Cumulative) != 2 || ver.Cumulative[0] != 25 || ver.Cumulative[1] != 50 {
|
||||
t.Errorf("VER cumulative = %v, want [25 50]", ver.Cumulative)
|
||||
}
|
||||
if len(ver.RoundPositions) != 2 || ver.RoundPositions[0] != 1 || ver.RoundPositions[1] != 1 {
|
||||
t.Errorf("VER round positions = %v, want [1 1]", ver.RoundPositions)
|
||||
}
|
||||
// VER beat teammate PER in both rounds.
|
||||
if ver.TeammateWins != 2 || ver.TeammateLosses != 0 {
|
||||
t.Errorf("VER h2h = %d-%d, want 2-0", ver.TeammateWins, ver.TeammateLosses)
|
||||
@@ -109,6 +114,9 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
if per.Poles != 0 {
|
||||
t.Errorf("PER poles = %d, want 0", per.Poles)
|
||||
}
|
||||
if len(per.RoundPositions) != 2 || per.RoundPositions[0] != 3 || per.RoundPositions[1] != 2 {
|
||||
t.Errorf("PER round positions = %v, want [3 2]", per.RoundPositions)
|
||||
}
|
||||
|
||||
// HAM has no teammate in the data — no h2h recorded.
|
||||
var ham champHubDriver
|
||||
@@ -124,6 +132,17 @@ func TestAggregateChampionshipHub(t *testing.T) {
|
||||
t.Errorf("HAM poles = %d, want 1", ham.Poles)
|
||||
}
|
||||
|
||||
// NOR missed round 1 — position 0 marks the absent round.
|
||||
var nor champHubDriver
|
||||
for _, d := range resp.Drivers {
|
||||
if d.NameAcronym == "NOR" {
|
||||
nor = d
|
||||
}
|
||||
}
|
||||
if len(nor.RoundPositions) != 2 || nor.RoundPositions[0] != 0 || nor.RoundPositions[1] != 4 {
|
||||
t.Errorf("NOR round positions = %v, want [0 4]", nor.RoundPositions)
|
||||
}
|
||||
|
||||
// Teams sorted by position; Red Bull wins = VER(2) + PER(0) = 2.
|
||||
if len(resp.Teams) != 2 {
|
||||
t.Fatalf("teams = %d, want 2", len(resp.Teams))
|
||||
|
||||
@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'Position evolution chart' }),
|
||||
).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 }) => {
|
||||
@@ -58,13 +68,15 @@ test.describe('Race Hub Weekend Workspace', () => {
|
||||
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,
|
||||
}) => {
|
||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
BIN
tests/visual/__snapshots__/desktop/race-story.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
tests/visual/__snapshots__/mobile/race-story.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
tests/visual/__snapshots__/tablet/race-story.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -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<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> {
|
||||
await page.goto('/admin')
|
||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||
|
||||
9
tests/visual/race-story.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||