mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
merge phase-2 — keep both DriversView props (drivers for H2H, season for profile links)
This commit is contained in:
87
frontend/src/components/ChapterStrip.tsx
Normal file
87
frontend/src/components/ChapterStrip.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Chapter } from '../types'
|
||||
import {
|
||||
activeChapterIndex,
|
||||
chapterKindLabel,
|
||||
chapterLapRange,
|
||||
chapterStartScrub,
|
||||
} from '../lib/chapters'
|
||||
import '../styles/chapters.css'
|
||||
|
||||
interface Props {
|
||||
chapters: Chapter[]
|
||||
scrubTime: number | null
|
||||
tMin: number
|
||||
tRange: number
|
||||
tourActive: boolean
|
||||
tourChapterIndex: number | null
|
||||
onChapterClick: (index: number, scrub: number) => void
|
||||
onTourToggle: () => void
|
||||
}
|
||||
|
||||
export function ChapterStrip({
|
||||
chapters,
|
||||
scrubTime,
|
||||
tMin,
|
||||
tRange,
|
||||
tourActive,
|
||||
tourChapterIndex,
|
||||
onChapterClick,
|
||||
onTourToggle,
|
||||
}: Props) {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||
|
||||
return (
|
||||
<div className="chapter-strip" data-testid="chapter-strip">
|
||||
<div className="chapter-strip-header">
|
||||
<span className="chapter-strip-title">Race chapters</span>
|
||||
<div className="chapter-strip-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
|
||||
onClick={onTourToggle}
|
||||
aria-pressed={tourActive}
|
||||
>
|
||||
{tourActive ? 'Exit 90s' : '90s tour'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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 headline = chapter.headline || chapter.title
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
|
||||
onClick={() => onChapterClick(index, scrub)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
data-testid={`chapter-card-${index}`}
|
||||
>
|
||||
<div className="chapter-card-top">
|
||||
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
|
||||
{chapterKindLabel(chapter.kind)}
|
||||
</span>
|
||||
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
|
||||
</div>
|
||||
<span className="chapter-headline">{headline}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export function Nav() {
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||
Preview
|
||||
</Link>
|
||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||
Championship
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchNews } from '../api'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchNews,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
} from '../api'
|
||||
import {
|
||||
activeDigestWindow,
|
||||
gpWindows,
|
||||
itemsForWindow,
|
||||
sinceLastLabel,
|
||||
tagColour,
|
||||
tagItems,
|
||||
topTags,
|
||||
} from '../lib/digest'
|
||||
import { timeAgo } from '../utils'
|
||||
import '../styles/digest.css'
|
||||
|
||||
const SOURCE_DISPLAY: Record<string, string> = {
|
||||
'fia': 'FIA',
|
||||
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function PaddockBriefing() {
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const { data: news, isLoading, isError } = useQuery({
|
||||
queryKey: ['news'],
|
||||
queryFn: () => fetchNews(100),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
})
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', latestSeason],
|
||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const hubQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
|
||||
const meetings = meetingsQuery.data ?? []
|
||||
const hub = hubQuery.data
|
||||
const tagged = useMemo(
|
||||
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
||||
[news, hub],
|
||||
)
|
||||
|
||||
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||
const activeWindow = useMemo(
|
||||
() => activeDigestWindow(windows, meetings, now),
|
||||
[windows, meetings, now],
|
||||
)
|
||||
const sinceItems = useMemo(
|
||||
() => itemsForWindow(tagged, activeWindow),
|
||||
[tagged, activeWindow],
|
||||
)
|
||||
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
||||
const sinceLabel = sinceLastLabel(meetings, now)
|
||||
|
||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||
const preview = news?.slice(0, 5) ?? []
|
||||
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||
|
||||
return (
|
||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{meetings.length > 0 && (
|
||||
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
||||
<span>Since {sinceLabel}</span>
|
||||
<span>·</span>
|
||||
<span>{sinceItems.length} items</span>
|
||||
{sinceTags.length > 0 && (
|
||||
<div className="cc-brief-digest-tags">
|
||||
{sinceTags.map((tag) => (
|
||||
<span
|
||||
key={tag.key}
|
||||
className="cc-brief-tag"
|
||||
style={{ borderColor: tagColour(tag.colour) }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
|
||||
|
||||
const CHAPTER_TOUR_MS = 90_000
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -12,28 +19,181 @@ interface Props {
|
||||
race_control?: any[]
|
||||
pit_stops?: any[]
|
||||
session?: Session
|
||||
meeting?: Meeting
|
||||
drivers?: Driver[]
|
||||
chapters?: Chapter[]
|
||||
}
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
||||
const {
|
||||
results,
|
||||
starting_grid: grid,
|
||||
positions,
|
||||
datasets,
|
||||
race_control = [],
|
||||
pit_stops = [],
|
||||
laps = [],
|
||||
session,
|
||||
meeting,
|
||||
drivers = [],
|
||||
chapters = [],
|
||||
} = data
|
||||
const hasPositions = datasets['positions']?.status === 'available'
|
||||
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||
const [mapOpen, setMapOpen] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||
const [tourChapterIndex, setTourChapterIndex] = 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 circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||
|
||||
const replayQuery = useQuery({
|
||||
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||
enabled: mapOpen && Boolean(session?.session_key),
|
||||
})
|
||||
const outlineQuery = useQuery({
|
||||
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||
enabled: mapOpen && circuitKey > 0 && outlineYear > 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !chartTiming) return
|
||||
|
||||
let frame = 0
|
||||
let last = performance.now()
|
||||
const tick = (now: number) => {
|
||||
const delta = now - last
|
||||
last = now
|
||||
setScrubTime((current) => {
|
||||
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
|
||||
if (next >= 1) {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [chartTiming, isPlaying, playbackSpeed])
|
||||
|
||||
const stopChapterTour = () => {
|
||||
setChapterTourActive(false)
|
||||
setTourChapterIndex(null)
|
||||
}
|
||||
|
||||
const jumpToChapter = (index: number, scrub: number) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
setScrubTime(scrub)
|
||||
}
|
||||
|
||||
const toggleChapterTour = () => {
|
||||
if (chapterTourActive) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
if (!chartTiming || chapters.length === 0) return
|
||||
setIsPlaying(false)
|
||||
setChapterTourActive(true)
|
||||
setTourChapterIndex(0)
|
||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
setScrubTime(startScrub)
|
||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||
tourRef.current = {
|
||||
chapterIndex: 0,
|
||||
startedAt: performance.now(),
|
||||
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||
startScrub,
|
||||
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
|
||||
|
||||
let frame = 0
|
||||
const tick = (now: number) => {
|
||||
const state = tourRef.current
|
||||
const elapsed = now - state.startedAt
|
||||
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
|
||||
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
|
||||
setScrubTime(scrub)
|
||||
setTourChapterIndex(state.chapterIndex)
|
||||
|
||||
if (progress >= 1) {
|
||||
const nextIndex = state.chapterIndex + 1
|
||||
if (nextIndex >= chapters.length) {
|
||||
stopChapterTour()
|
||||
return
|
||||
}
|
||||
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
|
||||
tourRef.current = {
|
||||
chapterIndex: nextIndex,
|
||||
startedAt: now,
|
||||
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||
startScrub,
|
||||
endScrub,
|
||||
}
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [chapterTourActive, chartTiming, chapters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterTourActive) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
stopChapterTour()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [chapterTourActive])
|
||||
|
||||
const replayTMs = useMemo(() => {
|
||||
const replay = replayQuery.data
|
||||
const frames = replay?.frames ?? []
|
||||
if (frames.length === 0) return 0
|
||||
const lastFrameT = frames[frames.length - 1].t
|
||||
const progress = scrubTime ?? 0
|
||||
if (!chartTiming || !replay?.start_time) {
|
||||
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
|
||||
}
|
||||
const replayStart = new Date(replay.start_time).getTime()
|
||||
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
|
||||
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
|
||||
}, [chartTiming, replayQuery.data, scrubTime])
|
||||
|
||||
let chartContent = null
|
||||
let displayResults = results
|
||||
|
||||
if (hasChartData) {
|
||||
const tMin = new Date(allTimes[0]).getTime()
|
||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||
const tRange = Math.max(tMax - tMin, 1)
|
||||
if (hasChartData && chartTiming) {
|
||||
const { tMin, tRange } = chartTiming
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
@@ -137,6 +297,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||
setIsPlaying(false)
|
||||
stopChapterTour()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
@@ -288,24 +450,91 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => setScrubTime(null)}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) setScrubTime(null)
|
||||
}}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="rs-replay-tools" aria-label="Race replay controls">
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setScrubTime((current) => current ?? 0)
|
||||
stopChapterTour()
|
||||
setIsPlaying((current) => !current)
|
||||
}}
|
||||
>
|
||||
{isPlaying ? 'Pause' : 'Play'}
|
||||
</button>
|
||||
<div className="rs-speed-group" aria-label="Playback speed">
|
||||
{[1, 10, 30].map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
className={`rs-speed-btn ${playbackSpeed === speed ? 'active' : ''}`}
|
||||
onClick={() => setPlaybackSpeed(speed)}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rs-tool-btn ${mapOpen ? 'active' : ''}`}
|
||||
onClick={() => setMapOpen((current) => !current)}
|
||||
>
|
||||
Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="race-story-canvas">
|
||||
{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>
|
||||
{hasChartData && chartTiming && chapters.length > 0 && (
|
||||
<ChapterStrip
|
||||
chapters={chapters}
|
||||
scrubTime={scrubTime}
|
||||
tMin={chartTiming.tMin}
|
||||
tRange={chartTiming.tRange}
|
||||
tourActive={chapterTourActive}
|
||||
tourChapterIndex={tourChapterIndex}
|
||||
onChapterClick={jumpToChapter}
|
||||
onTourToggle={toggleChapterTour}
|
||||
/>
|
||||
)}
|
||||
<div className="rs-replay-shell">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{mapOpen && (
|
||||
<div className="rs-replay-map-slot">
|
||||
{circuitKey > 0 && outlineYear > 0 ? (
|
||||
<ReplayTrackMap
|
||||
outline={outlineQuery.data}
|
||||
replay={replayQuery.data}
|
||||
tMs={replayTMs}
|
||||
drivers={drivers}
|
||||
results={results}
|
||||
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>
|
||||
|
||||
{displayResults.length > 0 && (
|
||||
<div className="rs-field-list">
|
||||
|
||||
133
frontend/src/components/ReplayTrackMap.tsx
Normal file
133
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||
import { buildOutlinePath } from '../lib/trackmap'
|
||||
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||
import '../styles/replay-map.css'
|
||||
|
||||
interface Props {
|
||||
outline?: TrackOutline | null
|
||||
replay?: ReplayFramesResponse | null
|
||||
tMs: number
|
||||
drivers: Driver[]
|
||||
results: EnrichedResult[]
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export function ReplayTrackMap({
|
||||
outline,
|
||||
replay,
|
||||
tMs,
|
||||
drivers,
|
||||
results,
|
||||
loading = false,
|
||||
error = false,
|
||||
}: Props) {
|
||||
const [pinned, setPinned] = useState<string | null>(null)
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
const driverInfo = useMemo(() => {
|
||||
const info = new Map<string, { label: string; color: string }>()
|
||||
for (const driver of drivers) {
|
||||
info.set(String(driver.driver_number), {
|
||||
label: driver.name_acronym || String(driver.driver_number),
|
||||
color: normalizeColor(driver.team_colour),
|
||||
})
|
||||
}
|
||||
for (const result of results) {
|
||||
const key = String(result.driver_number)
|
||||
if (!info.has(key)) {
|
||||
info.set(key, {
|
||||
label: result.name_acronym || key,
|
||||
color: normalizeColor(result.team_colour),
|
||||
})
|
||||
}
|
||||
}
|
||||
return info
|
||||
}, [drivers, results])
|
||||
|
||||
const cars = useMemo(() => {
|
||||
if (!outline?.bounds || !replay?.frames?.length) return []
|
||||
const positions = interpolateReplayCars(replay.frames, tMs)
|
||||
return Object.entries(positions)
|
||||
.map(([number, car]) => ({
|
||||
number,
|
||||
svg: replayCarToSvg(car, outline.bounds),
|
||||
info: driverInfo.get(number),
|
||||
}))
|
||||
.sort((a, b) => Number(a.number) - Number(b.number))
|
||||
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<div className="replay-map-empty">loading replay GPS...</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||
<div className="replay-map-stage">
|
||||
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
|
||||
<path className="replay-map-outline-shadow" d={outlinePath} />
|
||||
<path className="replay-map-outline" d={outlinePath} />
|
||||
{cars.map((car) => {
|
||||
const label = car.info?.label ?? car.number
|
||||
const selected = pinned === car.number
|
||||
return (
|
||||
<g
|
||||
key={car.number}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${label} replay position`}
|
||||
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
|
||||
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
|
||||
onClick={() => setPinned(selected ? null : car.number)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setPinned(selected ? null : car.number)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{label}</title>
|
||||
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
|
||||
<text y="0.85">{label}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeColor(color: string | undefined): string {
|
||||
const raw = color?.trim()
|
||||
if (!raw) return '#777777'
|
||||
return raw.startsWith('#') ? raw : `#${raw}`
|
||||
}
|
||||
Reference in New Issue
Block a user