mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
1 Commits
v0.03.1
...
review-pr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910301d2ce |
17
docs/reviews/pr-69/index.html
Normal file
17
docs/reviews/pr-69/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PR #69 browser QA</title>
|
||||||
|
<style>body{margin:2rem;background:#111;color:#eee;font-family:system-ui,sans-serif}img{display:block;max-width:100%;margin:1rem 0 3rem;border:1px solid #444}h1{margin-bottom:.25rem}p{color:#bbb}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>PR #69 browser QA</h1>
|
||||||
|
<p>Race Story — hermetic seeded preview</p>
|
||||||
|
<h2>Desktop</h2>
|
||||||
|
<img src="screenshots/race-story-desktop.png" alt="Race Story desktop QA">
|
||||||
|
<h2>Mobile</h2>
|
||||||
|
<img src="screenshots/race-story-mobile.png" alt="Race Story mobile QA">
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
docs/reviews/pr-69/screenshots/race-story-desktop.png
Normal file
BIN
docs/reviews/pr-69/screenshots/race-story-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/reviews/pr-69/screenshots/race-story-mobile.png
Normal file
BIN
docs/reviews/pr-69/screenshots/race-story-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
42
docs/reviews/pr-69/summary.md
Normal file
42
docs/reviews/pr-69/summary.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# PR #69 — Race Story polish review
|
||||||
|
|
||||||
|
Result: **NEEDS CHANGES**
|
||||||
|
|
||||||
|
- Reviewer harness: Codex, including browser QA against the hermetic seeded preview
|
||||||
|
- Implementer harness: Cursor
|
||||||
|
- Scope: issue #68 — Race Story UX polish
|
||||||
|
|
||||||
|
## Blocking findings
|
||||||
|
|
||||||
|
1. **Existing E2E coverage fails.** `npm run test:e2e` has one failure in `tests/race-hub.spec.ts`: it still expects the existing missing-positions notice, but the PR replaces it with new empty-state copy without updating the E2E assertion. The issue's definition of done requires existing suites to stay green.
|
||||||
|
2. **Chapter selection is not visibly synchronized for data with partial position coverage.** Browser QA on the seeded `session_key=9472` route showed that clicking either chapter leaves both cards unhighlighted. `chapterStartScrub` clamps a chapter outside the position chart's time window to 0 or 1, while `activeChapterIndex` checks the unclamped timestamps. The scrubber and active card therefore disagree. This is a direct miss on the active-chapter highlight/jump acceptance criterion.
|
||||||
|
|
||||||
|
## Gates
|
||||||
|
|
||||||
|
- `go test ./...` — pass
|
||||||
|
- `npm run test` — pass, 393/393 across 51 files
|
||||||
|
- `npm run build` — pass (`tsc --noEmit` + Vite build)
|
||||||
|
- `npm run test:e2e` — **fail**, 27/28 (missing-positions assertion)
|
||||||
|
- `npm run test:visual` — pass, 15/15 including new Race Story snapshots
|
||||||
|
- `git diff --check origin/main...HEAD` — pass
|
||||||
|
- Browser QA — desktop and mobile rendered without console warnings/errors; the map control was correctly absent when seeded replay data was unavailable
|
||||||
|
|
||||||
|
## Visual artifacts
|
||||||
|
|
||||||
|
- `screenshots/race-story-desktop.png`
|
||||||
|
- `screenshots/race-story-mobile.png`
|
||||||
|
|
||||||
|
## Acceptance-criteria assessment
|
||||||
|
|
||||||
|
- Chapter-strip styling, scroll affordances, cards, and responsive layout: visually verified
|
||||||
|
- Segmented playback controls: visually verified
|
||||||
|
- Map unavailable state: verified; no dead panel or map toggle in seeded data
|
||||||
|
- Graph full-width fallback and responsive rendering: visually verified
|
||||||
|
- Chapter band/axis/label polish: present in the rendered chart
|
||||||
|
- Empty-state card pattern: present
|
||||||
|
- Active chapter highlight synchronization: **not met in browser QA**
|
||||||
|
- Existing test suites stay green: **not met** (one E2E failure)
|
||||||
|
|
||||||
|
## Non-blocking note
|
||||||
|
|
||||||
|
The PR adds shared empty-state styles to `frontend/src/styles/app.css`, although the issue explicitly says not to edit that file. Keep the fix scoped to the Race Story style files if practical.
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { BookOpen } from 'lucide-react'
|
|
||||||
import type { Chapter } from '../types'
|
import type { Chapter } from '../types'
|
||||||
import {
|
import {
|
||||||
activeChapterIndex,
|
activeChapterIndex,
|
||||||
@@ -7,7 +5,6 @@ import {
|
|||||||
chapterLapRange,
|
chapterLapRange,
|
||||||
chapterStartScrub,
|
chapterStartScrub,
|
||||||
} from '../lib/chapters'
|
} from '../lib/chapters'
|
||||||
import { EmptyStateCard } from './EmptyStateCard'
|
|
||||||
import '../styles/chapters.css'
|
import '../styles/chapters.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,8 +14,6 @@ interface Props {
|
|||||||
tRange: number
|
tRange: number
|
||||||
tourActive: boolean
|
tourActive: boolean
|
||||||
tourChapterIndex: number | null
|
tourChapterIndex: number | null
|
||||||
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
|
||||||
selectedChapterIndex?: number | null
|
|
||||||
onChapterClick: (index: number, scrub: number) => void
|
onChapterClick: (index: number, scrub: number) => void
|
||||||
onTourToggle: () => void
|
onTourToggle: () => void
|
||||||
}
|
}
|
||||||
@@ -30,38 +25,19 @@ export function ChapterStrip({
|
|||||||
tRange,
|
tRange,
|
||||||
tourActive,
|
tourActive,
|
||||||
tourChapterIndex,
|
tourChapterIndex,
|
||||||
selectedChapterIndex = null,
|
|
||||||
onChapterClick,
|
onChapterClick,
|
||||||
onTourToggle,
|
onTourToggle,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
|
||||||
const highlightedIndex = tourActive
|
|
||||||
? tourChapterIndex
|
|
||||||
: (selectedChapterIndex ?? activeIndex)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (highlightedIndex === null || !scrollRef.current) return
|
|
||||||
const card = scrollRef.current.querySelector<HTMLElement>(
|
|
||||||
`[data-testid="chapter-card-${highlightedIndex}"]`,
|
|
||||||
)
|
|
||||||
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
|
||||||
}, [highlightedIndex])
|
|
||||||
|
|
||||||
if (chapters.length === 0) {
|
if (chapters.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="chapter-strip" data-testid="chapter-strip">
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
<EmptyStateCard
|
<p className="chapter-strip-empty">No story chapters for this session.</p>
|
||||||
icon={BookOpen}
|
|
||||||
title="No story chapters"
|
|
||||||
hint="This session does not have enough race-control or position data to build narrative chapters."
|
|
||||||
testId="chapter-strip-empty"
|
|
||||||
className="chapter-strip-empty-card"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chapter-strip" data-testid="chapter-strip">
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
<div className="chapter-strip-header">
|
<div className="chapter-strip-header">
|
||||||
@@ -77,16 +53,12 @@ export function ChapterStrip({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chapter-strip-scroll-wrap">
|
<div className="chapter-strip-scroll" role="list" aria-label="Race story chapters">
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="chapter-strip-scroll"
|
|
||||||
role="list"
|
|
||||||
aria-label="Race story chapters"
|
|
||||||
>
|
|
||||||
{chapters.map((chapter, index) => {
|
{chapters.map((chapter, index) => {
|
||||||
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
||||||
const isActive = highlightedIndex === index
|
const isActive = tourActive
|
||||||
|
? tourChapterIndex === index
|
||||||
|
: activeIndex === index
|
||||||
const headline = chapter.headline || chapter.title
|
const headline = chapter.headline || chapter.title
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,6 +83,5 @@ export function ChapterStrip({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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,36 +1,13 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { LineChart } from 'lucide-react'
|
|
||||||
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
||||||
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||||
import { ReplayTrackMap } from './ReplayTrackMap'
|
import { ReplayTrackMap } from './ReplayTrackMap'
|
||||||
import { ChapterStrip } from './ChapterStrip'
|
import { ChapterStrip } from './ChapterStrip'
|
||||||
import { EmptyStateCard } from './EmptyStateCard'
|
|
||||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
import {
|
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
|
||||||
chapterBandFill,
|
|
||||||
chapterEndScrub,
|
|
||||||
chapterStartScrub,
|
|
||||||
chapterTourDurations,
|
|
||||||
deCollideYPositions,
|
|
||||||
decimatedPositionLabels,
|
|
||||||
} from '../lib/chapters'
|
|
||||||
import { isReplayMapAvailable } from '../lib/replayMap'
|
|
||||||
import '../styles/race-story.css'
|
|
||||||
|
|
||||||
const CHAPTER_TOUR_MS = 90_000
|
const CHAPTER_TOUR_MS = 90_000
|
||||||
const CHART_W = 640
|
|
||||||
const CHART_H = 180
|
|
||||||
const CHART_PL = 40
|
|
||||||
const CHART_PR = 48
|
|
||||||
const CHART_PT = 8
|
|
||||||
const CHART_PB = 20
|
|
||||||
|
|
||||||
interface ChartTiming {
|
|
||||||
tMin: number
|
|
||||||
tMax: number
|
|
||||||
tRange: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -48,48 +25,6 @@ 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) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const {
|
const {
|
||||||
results,
|
results,
|
||||||
@@ -113,46 +48,32 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||||
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
||||||
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||||
|
|
||||||
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
// Position Evolution Chart Logic
|
||||||
const hasChartData = hasPositions && chartTiming !== null
|
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 circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||||
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
|
||||||
|
|
||||||
const replayQuery = useQuery({
|
const replayQuery = useQuery({
|
||||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||||
enabled: canProbeMap,
|
enabled: mapOpen && Boolean(session?.session_key),
|
||||||
})
|
})
|
||||||
const outlineQuery = useQuery({
|
const outlineQuery = useQuery({
|
||||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||||
enabled: canProbeMap,
|
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
|
|
||||||
const mapAvailable = useMemo(
|
|
||||||
() =>
|
|
||||||
canProbeMap &&
|
|
||||||
isReplayMapAvailable(
|
|
||||||
replayQuery.data,
|
|
||||||
outlineQuery.data,
|
|
||||||
replayQuery.isError || outlineQuery.isError,
|
|
||||||
),
|
|
||||||
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
|
|
||||||
)
|
|
||||||
const showMapPanel = mapOpen && mapAvailable
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mapAvailable && mapOpen) {
|
|
||||||
setMapOpen(false)
|
|
||||||
}
|
|
||||||
}, [mapAvailable, mapOpen])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying || !chartTiming) return
|
if (!isPlaying || !chartTiming) return
|
||||||
|
|
||||||
@@ -183,14 +104,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const jumpToChapter = (index: number, scrub: number) => {
|
const jumpToChapter = (index: number, scrub: number) => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
setSelectedChapterIndex(index)
|
|
||||||
setScrubTime(scrub)
|
setScrubTime(scrub)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearChapterSelection = () => {
|
|
||||||
setSelectedChapterIndex(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleChapterTour = () => {
|
const toggleChapterTour = () => {
|
||||||
if (chapterTourActive) {
|
if (chapterTourActive) {
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
@@ -198,7 +114,6 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
if (!chartTiming || chapters.length === 0) return
|
if (!chartTiming || chapters.length === 0) return
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
setSelectedChapterIndex(null)
|
|
||||||
setChapterTourActive(true)
|
setChapterTourActive(true)
|
||||||
setTourChapterIndex(0)
|
setTourChapterIndex(0)
|
||||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||||
@@ -279,19 +194,16 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
|
|
||||||
if (hasChartData && chartTiming) {
|
if (hasChartData && chartTiming) {
|
||||||
const { tMin, tRange } = 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 }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
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, [])
|
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||||
byDriver.get(p.driver_number)!.push({
|
byDriver.get(p.driver_number)!.push({
|
||||||
t: normaliseTime(time),
|
t: (new Date(p.date).getTime() - tMin) / tRange,
|
||||||
pos: p.position,
|
pos: p.position,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
|
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
|
||||||
for (const [dNum, samples] of byDriver.entries()) {
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
samples.sort((a, b) => a.t - b.t)
|
samples.sort((a, b) => a.t - b.t)
|
||||||
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
||||||
@@ -299,7 +211,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
|
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
|
||||||
if (!samples || samples.length === 0) return null
|
if (!samples || samples.length === 0) return null
|
||||||
if (t <= samples[0].t) return samples[0].pos
|
if (t <= samples[0].t) return samples[0].pos
|
||||||
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||||
@@ -332,37 +244,34 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
||||||
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
|
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
|
||||||
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
||||||
const positionLabels = decimatedPositionLabels(maxPos)
|
|
||||||
|
|
||||||
const W = CHART_W
|
const W = 640
|
||||||
const H = CHART_H
|
const H = 180
|
||||||
const PL = CHART_PL
|
const PL = 40
|
||||||
const PR = CHART_PR
|
const PR = 48
|
||||||
const PT = CHART_PT
|
const PT = 8
|
||||||
const PB = CHART_PB
|
const PB = 20
|
||||||
const plotW = W - PL - PR
|
const plotW = W - PL - PR
|
||||||
const plotH = H - PT - PB
|
const plotH = H - PT - PB
|
||||||
|
|
||||||
const toX = (t: number) => PL + t * plotW
|
const toX = (t: number) => PL + t * plotW
|
||||||
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
||||||
|
|
||||||
const winner = results.find((r) => r.position === 1)
|
const winner = results.find(r => r.position === 1)
|
||||||
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
|
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
|
||||||
const lapTicks: { lap: number; t: number }[] = []
|
const lapTicks: { lap: number, t: number }[] = []
|
||||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||||
|
|
||||||
for (let i = 0; i < winnerLaps.length; i++) {
|
for (const lap of winnerLaps) {
|
||||||
const lap = winnerLaps[i]
|
|
||||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||||
const lapStart = new Date(lap.date_start).getTime()
|
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
||||||
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
if (t >= 0 && t <= 1) {
|
||||||
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
lapTicks.push({ lap: lap.lap_number, t })
|
||||||
? 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' }[] = []
|
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||||
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
||||||
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
@@ -387,73 +296,25 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapterBands = chapters
|
|
||||||
.map((chapter, index) => {
|
|
||||||
const startT = chapterStartScrub(chapter, tMin, tRange)
|
|
||||||
const endT = chapterEndScrub(chapter, tMin, tRange)
|
|
||||||
if (startT === null) return null
|
|
||||||
const end = endT ?? startT
|
|
||||||
return {
|
|
||||||
key: `${chapter.kind}-${index}`,
|
|
||||||
start: Math.min(startT, end),
|
|
||||||
end: Math.max(startT, end),
|
|
||||||
fill: chapterBandFill(chapter.kind),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((band): band is NonNullable<typeof band> => band !== null)
|
|
||||||
|
|
||||||
const labelCandidates = Array.from(byDriver.entries())
|
|
||||||
.map(([dNum, samples]) => {
|
|
||||||
const last = samples[samples.length - 1]
|
|
||||||
if (!last) return null
|
|
||||||
return { key: dNum, y: toY(last.pos) }
|
|
||||||
})
|
|
||||||
.filter((item): item is { key: number; y: number } => item !== null)
|
|
||||||
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
|
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
clearChapterSelection()
|
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
if (rect.width <= 0) return
|
const x = e.clientX - rect.left
|
||||||
// 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))
|
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
setScrubTime(t)
|
setScrubTime(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
chartContent = (
|
chartContent = (
|
||||||
<div
|
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
||||||
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
|
|
||||||
data-testid="position-chart"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
className="rs-position-chart-svg"
|
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Position evolution chart"
|
aria-label="Position evolution chart"
|
||||||
>
|
>
|
||||||
{chapterBands.map((band) => {
|
|
||||||
const x1 = toX(Math.max(0, band.start))
|
|
||||||
const x2 = toX(Math.min(1, band.end))
|
|
||||||
if (x2 <= PL || x1 >= W - PR) return null
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={band.key}
|
|
||||||
x={x1}
|
|
||||||
y={PT}
|
|
||||||
width={Math.max(0, x2 - x1)}
|
|
||||||
height={plotH}
|
|
||||||
fill={band.fill}
|
|
||||||
data-testid="chapter-band"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{scPeriods.map((sc, i) => {
|
{scPeriods.map((sc, i) => {
|
||||||
const startT = (sc.start - tMin) / tRange
|
const startT = (sc.start - tMin) / tRange
|
||||||
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||||
@@ -472,41 +333,33 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{positionLabels.map((pos) => (
|
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
||||||
<g key={pos}>
|
<g key={pos}>
|
||||||
<line
|
<line
|
||||||
x1={PL}
|
x1={PL}
|
||||||
x2={W - PR}
|
x2={W - PR}
|
||||||
y1={toY(pos)}
|
y1={toY(pos)}
|
||||||
y2={toY(pos)}
|
y2={toY(pos)}
|
||||||
className="rs-chart-grid-line"
|
stroke="var(--border)"
|
||||||
|
strokeWidth={0.5}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={PL - 4}
|
x={PL - 4}
|
||||||
y={toY(pos) + 4}
|
y={toY(pos) + 4}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
className="rs-chart-axis-label"
|
fill="var(--text-3)"
|
||||||
|
fontSize={8}
|
||||||
|
fontFamily="var(--f-mono)"
|
||||||
>
|
>
|
||||||
P{pos}
|
P{pos}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{lapTicks.map((tick) => (
|
{lapTicks.map(tick => (
|
||||||
<g key={`lap-${tick.lap}`}>
|
<g key={`lap-${tick.lap}`}>
|
||||||
<line
|
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
|
||||||
x1={toX(tick.t)}
|
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
||||||
x2={toX(tick.t)}
|
|
||||||
y1={H - PB}
|
|
||||||
y2={H - PB + 4}
|
|
||||||
className="rs-chart-lap-tick"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={toX(tick.t)}
|
|
||||||
y={H - PB + 14}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="rs-chart-axis-label"
|
|
||||||
>
|
|
||||||
L{tick.lap}
|
L{tick.lap}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -519,9 +372,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const last = samples[samples.length - 1]
|
const last = samples[samples.length - 1]
|
||||||
const isHovered = hoverDriver === dNum
|
const isHovered = hoverDriver === dNum
|
||||||
const isFaded = hoverDriver !== null && !isHovered
|
const isFaded = hoverDriver !== null && !isHovered
|
||||||
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
|
|
||||||
|
|
||||||
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
|
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -542,9 +394,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{driverPits.map((p, i) => {
|
{driverPits.map((p, i) => {
|
||||||
const time = new Date(p.date).getTime()
|
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||||
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
if (t < 0 || t > 1) return null
|
||||||
const t = normaliseTime(time)
|
|
||||||
const pos = getInterpPos(samples, t)
|
const pos = getInterpPos(samples, t)
|
||||||
if (pos === null) return null
|
if (pos === null) return null
|
||||||
return (
|
return (
|
||||||
@@ -564,7 +415,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
{last && (
|
{last && (
|
||||||
<text
|
<text
|
||||||
x={toX(last.t) + 6}
|
x={toX(last.t) + 6}
|
||||||
y={labelY + 4}
|
y={toY(last.pos) + 4}
|
||||||
fill={color}
|
fill={color}
|
||||||
fontSize={isHovered ? 11 : 9}
|
fontSize={isHovered ? 11 : 9}
|
||||||
fontFamily="var(--f-mono)"
|
fontFamily="var(--f-mono)"
|
||||||
@@ -598,57 +449,44 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
width={plotW}
|
width={plotW}
|
||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
data-testid="position-chart-interaction"
|
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => {
|
onPointerLeave={() => {
|
||||||
if (!isPlaying) {
|
if (!isPlaying) setScrubTime(null)
|
||||||
clearChapterSelection()
|
|
||||||
setScrubTime(null)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
|
<div className="rs-replay-tools" aria-label="Race replay controls">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setScrubTime((current) => current ?? 0)
|
setScrubTime((current) => current ?? 0)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
clearChapterSelection()
|
|
||||||
setIsPlaying((current) => !current)
|
setIsPlaying((current) => !current)
|
||||||
}}
|
}}
|
||||||
aria-pressed={isPlaying}
|
|
||||||
>
|
>
|
||||||
{isPlaying ? 'Pause' : 'Play'}
|
{isPlaying ? 'Pause' : 'Play'}
|
||||||
</button>
|
</button>
|
||||||
<span className="rs-segment-divider" aria-hidden />
|
<div className="rs-speed-group" aria-label="Playback speed">
|
||||||
{[1, 10, 30].map((speed) => (
|
{[1, 10, 30].map((speed) => (
|
||||||
<button
|
<button
|
||||||
key={speed}
|
key={speed}
|
||||||
type="button"
|
type="button"
|
||||||
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
||||||
onClick={() => setPlaybackSpeed(speed)}
|
onClick={() => setPlaybackSpeed(speed)}
|
||||||
aria-pressed={playbackSpeed === speed}
|
|
||||||
>
|
>
|
||||||
{speed}x
|
{speed}x
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{mapProbeSettled && mapAvailable && (
|
</div>
|
||||||
<>
|
|
||||||
<span className="rs-segment-divider" aria-hidden />
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
||||||
onClick={() => setMapOpen((current) => !current)}
|
onClick={() => setMapOpen((current) => !current)}
|
||||||
aria-pressed={mapOpen}
|
|
||||||
data-testid="replay-map-toggle"
|
|
||||||
>
|
>
|
||||||
Map
|
Map
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -664,32 +502,24 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
tRange={chartTiming.tRange}
|
tRange={chartTiming.tRange}
|
||||||
tourActive={chapterTourActive}
|
tourActive={chapterTourActive}
|
||||||
tourChapterIndex={tourChapterIndex}
|
tourChapterIndex={tourChapterIndex}
|
||||||
selectedChapterIndex={selectedChapterIndex}
|
|
||||||
onChapterClick={jumpToChapter}
|
onChapterClick={jumpToChapter}
|
||||||
onTourToggle={toggleChapterTour}
|
onTourToggle={toggleChapterTour}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
<div className="rs-replay-shell">
|
||||||
<div className="rs-replay-main">
|
<div className="rs-replay-main">
|
||||||
{hasChartData ? (
|
{hasChartData ? (
|
||||||
chartContent
|
chartContent
|
||||||
) : (
|
) : (
|
||||||
<EmptyStateCard
|
<div className="analysis-notice">
|
||||||
icon={LineChart}
|
<strong>Lap-by-lap positions not available.</strong> This session does not
|
||||||
title="Lap-by-lap positions not available"
|
have ingested position samples in <code>/api/v1/race-hub</code>.
|
||||||
hint={
|
</div>
|
||||||
<>
|
|
||||||
This session does not have ingested position samples in{' '}
|
|
||||||
<code>/api/v1/race-hub</code>.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
testId="race-story-no-positions"
|
|
||||||
className="race-story-empty-card"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showMapPanel && (
|
{mapOpen && (
|
||||||
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
<div className="rs-replay-map-slot">
|
||||||
|
{circuitKey > 0 && outlineYear > 0 ? (
|
||||||
<ReplayTrackMap
|
<ReplayTrackMap
|
||||||
outline={outlineQuery.data}
|
outline={outlineQuery.data}
|
||||||
replay={replayQuery.data}
|
replay={replayQuery.data}
|
||||||
@@ -699,6 +529,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||||
error={outlineQuery.isError || replayQuery.isError}
|
error={outlineQuery.isError || replayQuery.isError}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rs-replay-map-placeholder">track identity unavailable for replay map</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -709,8 +542,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
const isWinner = i === 0 && r.position === 1
|
const isWinner = i === 0 && r.position === 1
|
||||||
const pClass =
|
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
||||||
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
|
||||||
|
|
||||||
let currentPoints: number | string = r.points
|
let currentPoints: number | string = r.points
|
||||||
if (scrubTime !== null) {
|
if (scrubTime !== null) {
|
||||||
@@ -727,7 +559,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
onMouseLeave={() => setHoverDriver(null)}
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
>
|
>
|
||||||
<div className="rs-driver-left">
|
<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-cell">
|
||||||
<div
|
<div
|
||||||
className="rs-driver-color"
|
className="rs-driver-color"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Loader2, MapPin, Satellite } from 'lucide-react'
|
|
||||||
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
import { buildOutlinePath } from '../lib/trackmap'
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||||
import { EmptyStateCard } from './EmptyStateCard'
|
|
||||||
import '../styles/replay-map.css'
|
import '../styles/replay-map.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -62,13 +60,7 @@ export function ReplayTrackMap({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<EmptyStateCard
|
<div className="replay-map-empty">loading replay GPS...</div>
|
||||||
icon={Loader2}
|
|
||||||
title="Loading replay map"
|
|
||||||
hint="Fetching track outline and historical GPS frames."
|
|
||||||
testId="replay-map-loading"
|
|
||||||
className="replay-map-empty-card"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,13 +68,7 @@ export function ReplayTrackMap({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<EmptyStateCard
|
<div className="replay-map-empty">replay GPS unavailable for this session</div>
|
||||||
icon={Satellite}
|
|
||||||
title="Replay GPS unavailable"
|
|
||||||
hint="This session does not have ingested location samples for the replay map."
|
|
||||||
testId="replay-map-error"
|
|
||||||
className="replay-map-empty-card"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -90,13 +76,7 @@ export function ReplayTrackMap({
|
|||||||
if (!outline || !outlinePath) {
|
if (!outline || !outlinePath) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<EmptyStateCard
|
<div className="replay-map-empty">track outline unavailable for this session</div>
|
||||||
icon={MapPin}
|
|
||||||
title="Track outline unavailable"
|
|
||||||
hint="Circuit GPS outline data is missing for this session."
|
|
||||||
testId="replay-map-no-outline"
|
|
||||||
className="replay-map-empty-card"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,13 +84,7 @@ export function ReplayTrackMap({
|
|||||||
if (!replay?.frames?.length || cars.length === 0) {
|
if (!replay?.frames?.length || cars.length === 0) {
|
||||||
return (
|
return (
|
||||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
<EmptyStateCard
|
<div className="replay-map-empty">historical GPS unavailable for this session</div>
|
||||||
icon={Satellite}
|
|
||||||
title="Historical GPS unavailable"
|
|
||||||
hint="Fewer than two replay frames were returned for this session."
|
|
||||||
testId="replay-map-no-frames"
|
|
||||||
className="replay-map-empty-card"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ export function chapterEndScrub(
|
|||||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the chapter containing the current scrub position, if any.
|
/** Index of the chapter containing the current scrub position, if any. */
|
||||||
* Uses the same 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(
|
export function activeChapterIndex(
|
||||||
chapters: Chapter[],
|
chapters: Chapter[],
|
||||||
scrubTime: number | null,
|
scrubTime: number | null,
|
||||||
@@ -64,13 +61,15 @@ export function activeChapterIndex(
|
|||||||
tRange: number,
|
tRange: number,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||||
|
const chartMs = tMin + scrubTime * tRange
|
||||||
for (let i = 0; i < chapters.length; i++) {
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
const start = chapterStartScrub(chapters[i], tMin, tRange)
|
const ch = chapters[i]
|
||||||
const end = chapterEndScrub(chapters[i], tMin, tRange)
|
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
|
||||||
if (start === null || end === null) continue
|
const endRaw = ch.end_time ?? ch.start_time
|
||||||
const lo = Math.min(start, end)
|
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
|
||||||
const hi = Math.max(start, end)
|
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
|
||||||
if (scrubTime >= lo && scrubTime <= hi) return i
|
return i
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -81,52 +80,3 @@ export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): num
|
|||||||
const perChapter = totalMs / chapters.length
|
const perChapter = totalMs / chapters.length
|
||||||
return chapters.map(() => perChapter)
|
return chapters.map(() => perChapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
|
|
||||||
export function decimatedPositionLabels(maxPos: number): number[] {
|
|
||||||
if (maxPos <= 1) return [1]
|
|
||||||
const labels = new Set<number>([1])
|
|
||||||
for (let pos = 5; pos < maxPos; pos += 5) {
|
|
||||||
labels.add(pos)
|
|
||||||
}
|
|
||||||
labels.add(maxPos)
|
|
||||||
return [...labels].sort((a, b) => a - b)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subtle background fill for chapter time-bands on the position graph. */
|
|
||||||
export function chapterBandFill(kind: string): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'safety_car':
|
|
||||||
return 'rgba(255, 153, 0, 0.14)'
|
|
||||||
case 'virtual_safety_car':
|
|
||||||
return 'rgba(255, 204, 0, 0.12)'
|
|
||||||
case 'red_flag':
|
|
||||||
return 'rgba(230, 36, 41, 0.12)'
|
|
||||||
case 'pit_phase':
|
|
||||||
return 'rgba(96, 165, 250, 0.08)'
|
|
||||||
case 'decisive_swing':
|
|
||||||
return 'rgba(34, 197, 94, 0.08)'
|
|
||||||
case 'finish':
|
|
||||||
return 'rgba(255, 204, 0, 0.06)'
|
|
||||||
default:
|
|
||||||
return 'rgba(255, 255, 255, 0.03)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimum vertical spacing between colliding labels (SVG units). */
|
|
||||||
export function deCollideYPositions(
|
|
||||||
items: ReadonlyArray<{ key: string | number; y: number }>,
|
|
||||||
minGap = 12,
|
|
||||||
): Map<string | number, number> {
|
|
||||||
if (items.length === 0) return new Map()
|
|
||||||
const sorted = [...items].sort((a, b) => a.y - b.y)
|
|
||||||
const adjusted = sorted.map((item) => ({ ...item }))
|
|
||||||
for (let i = 1; i < adjusted.length; i++) {
|
|
||||||
const prev = adjusted[i - 1]
|
|
||||||
const curr = adjusted[i]
|
|
||||||
if (curr.y - prev.y < minGap) {
|
|
||||||
curr.y = prev.y + minGap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Map(adjusted.map((item) => [item.key, item.y]))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-strip-title {
|
.chapter-strip-title {
|
||||||
@@ -45,31 +44,6 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-strip-scroll-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll-wrap::before,
|
|
||||||
.chapter-strip-scroll-wrap::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: var(--s2);
|
|
||||||
width: 28px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll-wrap::before {
|
|
||||||
left: 0;
|
|
||||||
background: linear-gradient(to right, var(--bg), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll-wrap::after {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(to left, var(--bg), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll {
|
.chapter-strip-scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
@@ -77,26 +51,10 @@
|
|||||||
padding-bottom: var(--s2);
|
padding-bottom: var(--s2);
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-2) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll::-webkit-scrollbar {
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-scroll::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border-2);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-card {
|
.chapter-card {
|
||||||
flex: 0 0 min(240px, 72vw);
|
flex: 0 0 min(240px, 72vw);
|
||||||
min-height: 88px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--s2);
|
gap: var(--s2);
|
||||||
@@ -106,7 +64,7 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
scroll-snap-align: center;
|
scroll-snap-align: start;
|
||||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,22 +145,10 @@
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-strip-empty-card {
|
.chapter-strip-empty {
|
||||||
padding: var(--s6) var(--s5);
|
font-size: 12px;
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chapter-strip-empty-card .empty-state-icon {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto var(--s3);
|
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
padding: var(--s3) 0;
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.chapter-strip-header {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
.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,9 +1,76 @@
|
|||||||
|
.rs-replay-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-top: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tool-btn,
|
||||||
|
.rs-speed-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tool-btn:hover,
|
||||||
|
.rs-speed-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-tool-btn.active,
|
||||||
|
.rs-speed-btn.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--red);
|
||||||
|
background: rgba(230, 36, 41, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-speed-group {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-map-slot {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-map-placeholder {
|
||||||
|
min-height: 244px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--s5);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
.replay-map-panel {
|
.replay-map-panel {
|
||||||
min-height: 244px;
|
min-height: 244px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: var(--s2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-map-stage {
|
.replay-map-stage {
|
||||||
@@ -76,16 +143,19 @@
|
|||||||
stroke-width: 1.2;
|
stroke-width: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-map-empty-card {
|
.replay-map-empty {
|
||||||
min-height: 244px;
|
min-height: 244px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-items: center;
|
||||||
padding: var(--s6);
|
padding: var(--s5);
|
||||||
margin: 0;
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-map-empty-card .empty-state-icon {
|
@media (max-width: 860px) {
|
||||||
display: block;
|
.rs-replay-shell {
|
||||||
margin: 0 auto var(--s3);
|
grid-template-columns: 1fr;
|
||||||
color: var(--text-3);
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,25 +68,6 @@ describe('ChapterStrip', () => {
|
|||||||
expect(screen.getByText('L12–L15')).toBeInTheDocument()
|
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', () => {
|
it('highlights the active chapter from scrub time', () => {
|
||||||
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange
|
||||||
renderStrip({ scrubTime: scrub })
|
renderStrip({ scrubTime: scrub })
|
||||||
@@ -103,26 +84,4 @@ describe('ChapterStrip', () => {
|
|||||||
expect(index).toBe(1)
|
expect(index).toBe(1)
|
||||||
expect(scrub).toBeCloseTo(0.6, 2)
|
expect(scrub).toBeCloseTo(0.6, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => {
|
|
||||||
// Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins.
|
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ChapterStrip
|
|
||||||
chapters={chapters}
|
|
||||||
scrubTime={0}
|
|
||||||
tMin={tMin}
|
|
||||||
tRange={tRange}
|
|
||||||
tourActive={false}
|
|
||||||
tourChapterIndex={null}
|
|
||||||
selectedChapterIndex={1}
|
|
||||||
onChapterClick={vi.fn()}
|
|
||||||
onTourToggle={vi.fn()}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
)
|
|
||||||
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
|
|
||||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
import type { Chapter, RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
import type { RaceHub, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchReplayFrames: vi.fn(),
|
fetchReplayFrames: vi.fn(),
|
||||||
@@ -28,35 +28,9 @@ const replay: ReplayFramesResponse = {
|
|||||||
session_key: 99,
|
session_key: 99,
|
||||||
interval_ms: 5000,
|
interval_ms: 5000,
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
start_time: '2025-05-25T13:00:00Z',
|
||||||
frames: [
|
frames: [{ t: 0, cars: { '1': { x: 10, y: 20 } } }],
|
||||||
{ t: 0, cars: { '1': { x: 10, y: 20 } } },
|
|
||||||
{ t: 5000, cars: { '1': { x: 50, y: 50 } } },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const chapters: Chapter[] = [
|
|
||||||
{
|
|
||||||
kind: 'start',
|
|
||||||
title: 'Start',
|
|
||||||
headline: 'Lights out',
|
|
||||||
start_lap: 1,
|
|
||||||
end_lap: 1,
|
|
||||||
start_time: '2025-05-25T13:00:00Z',
|
|
||||||
end_time: '2025-05-25T13:01:00Z',
|
|
||||||
driver_numbers: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: 'safety_car',
|
|
||||||
title: 'Safety Car',
|
|
||||||
headline: 'Incident brings out the Safety Car',
|
|
||||||
start_lap: 2,
|
|
||||||
end_lap: 2,
|
|
||||||
start_time: '2025-05-25T13:05:00Z',
|
|
||||||
end_time: '2025-05-25T13:05:00Z',
|
|
||||||
driver_numbers: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const raceHub: RaceHub = {
|
const raceHub: RaceHub = {
|
||||||
source: 'local',
|
source: 'local',
|
||||||
session_key: 99,
|
session_key: 99,
|
||||||
@@ -129,14 +103,14 @@ const raceHub: RaceHub = {
|
|||||||
race_control: [],
|
race_control: [],
|
||||||
weather: [],
|
weather: [],
|
||||||
laps: [],
|
laps: [],
|
||||||
chapters,
|
chapters: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCanvas(overrides: Partial<RaceHub> = {}) {
|
function renderCanvas() {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RaceStoryCanvas data={{ ...raceHub, ...overrides }} />
|
<RaceStoryCanvas data={raceHub} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -148,144 +122,14 @@ describe('RaceStoryCanvas replay map', () => {
|
|||||||
mockFetchTrackOutline.mockResolvedValue(outline)
|
mockFetchTrackOutline.mockResolvedValue(outline)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('probes replay frames on mount and opens the map when data is available', async () => {
|
it('fetches replay frames lazily when the map panel opens', async () => {
|
||||||
renderCanvas()
|
renderCanvas()
|
||||||
|
|
||||||
|
expect(mockFetchReplayFrames).not.toHaveBeenCalled()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Map' }))
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalledWith(99, 5000))
|
||||||
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
expect(mockFetchTrackOutline).toHaveBeenCalledWith(1, 2025)
|
||||||
|
|
||||||
const mapToggle = await screen.findByTestId('replay-map-toggle')
|
|
||||||
fireEvent.click(mapToggle)
|
|
||||||
|
|
||||||
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
expect(await screen.findByTestId('replay-track-map')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('replay-map-slot')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides the map toggle when replay frames are unavailable', async () => {
|
|
||||||
mockFetchReplayFrames.mockResolvedValue({ ...replay, frames: [] })
|
|
||||||
renderCanvas()
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
|
||||||
await waitFor(() => expect(screen.queryByTestId('replay-map-toggle')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses full-width chart layout when the map is closed', async () => {
|
|
||||||
renderCanvas()
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockFetchReplayFrames).toHaveBeenCalled())
|
|
||||||
const chart = screen.getByTestId('position-chart')
|
|
||||||
expect(chart).toHaveClass('rs-chart-container--full')
|
|
||||||
expect(screen.queryByTestId('replay-map-slot')).not.toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.rs-replay-shell--split')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('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,10 +44,9 @@ const results: EnrichedResult[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
describe('ReplayTrackMap', () => {
|
describe('ReplayTrackMap', () => {
|
||||||
it('renders an empty-state card when replay frames are missing', () => {
|
it('renders an empty state when replay frames are missing', () => {
|
||||||
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
render(<ReplayTrackMap outline={outline} replay={{ ...replay, frames: [] }} tMs={0} drivers={[]} results={results} />)
|
||||||
expect(screen.getByTestId('replay-map-no-frames')).toBeInTheDocument()
|
expect(screen.getByTestId('replay-track-map')).toHaveTextContent(/historical GPS unavailable/i)
|
||||||
expect(screen.getByText('Historical GPS unavailable')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders car labels from result metadata', () => {
|
it('renders car labels from result metadata', () => {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
activeChapterIndex,
|
activeChapterIndex,
|
||||||
chapterBandFill,
|
|
||||||
chapterKindLabel,
|
chapterKindLabel,
|
||||||
chapterLapRange,
|
chapterLapRange,
|
||||||
chapterStartScrub,
|
chapterStartScrub,
|
||||||
chapterTourDurations,
|
chapterTourDurations,
|
||||||
deCollideYPositions,
|
|
||||||
decimatedPositionLabels,
|
|
||||||
} from '../lib/chapters'
|
} from '../lib/chapters'
|
||||||
import type { Chapter } from '../types'
|
import type { Chapter } from '../types'
|
||||||
|
|
||||||
@@ -60,65 +57,7 @@ describe('chapters lib', () => {
|
|||||||
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('activates chapters whose timestamps clamp outside the position window', () => {
|
|
||||||
// Position samples only cover 13: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', () => {
|
it('splits 90s evenly across chapters', () => {
|
||||||
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('decimates position axis labels', () => {
|
|
||||||
expect(decimatedPositionLabels(22)).toEqual([1, 5, 10, 15, 20, 22])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns chapter band fills by kind', () => {
|
|
||||||
expect(chapterBandFill('safety_car')).toContain('rgba')
|
|
||||||
expect(chapterBandFill('virtual_safety_car')).toContain('rgba')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('de-collides overlapping label y positions', () => {
|
|
||||||
const adjusted = deCollideYPositions(
|
|
||||||
[
|
|
||||||
{ key: 'a', y: 10 },
|
|
||||||
{ key: 'b', y: 12 },
|
|
||||||
{ key: 'c', y: 30 },
|
|
||||||
],
|
|
||||||
12,
|
|
||||||
)
|
|
||||||
expect(adjusted.get('a')).toBe(10)
|
|
||||||
expect(adjusted.get('b')).toBe(22)
|
|
||||||
expect(adjusted.get('c')).toBe(34)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -39,17 +39,7 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('img', { name: 'Position evolution chart' }),
|
page.getByRole('img', { name: 'Position evolution chart' }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible()
|
await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible()
|
||||||
})
|
|
||||||
|
|
||||||
test('Race Story highlights a chapter card when clicked', async ({ page }) => {
|
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
|
||||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
|
||||||
|
|
||||||
const firstCard = page.getByTestId('chapter-card-0')
|
|
||||||
await expect(firstCard).toBeVisible()
|
|
||||||
await firstCard.click()
|
|
||||||
await expect(firstCard).toHaveClass(/active/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
|
test('strategy tab renders stint chart when stints are available', async ({ page }) => {
|
||||||
@@ -68,15 +58,13 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
|
await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Race Story shows empty-state card when positions are unavailable', async ({
|
test('Race Story shows missing notice when positions are unavailable', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
await page.getByRole('tab', { name: 'Race Story' }).click()
|
||||||
|
|
||||||
const empty = page.getByTestId('race-story-no-positions')
|
await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible()
|
||||||
await expect(empty).toBeVisible()
|
|
||||||
await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible()
|
|
||||||
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
@@ -36,14 +36,6 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
|
|||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
|
||||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
|
||||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
|
||||||
await expect(page.getByTestId('position-chart')).toBeVisible()
|
|
||||||
await waitForScreenshotReady(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
||||||
await page.goto('/admin')
|
await page.goto('/admin')
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user