mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Phase 22b: Interactive Race Story Canvas
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types'
|
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap } from '../types'
|
||||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
|
|
||||||
@@ -8,18 +9,26 @@ interface Props {
|
|||||||
positions: PositionSample[]
|
positions: PositionSample[]
|
||||||
laps: Lap[]
|
laps: Lap[]
|
||||||
datasets: Record<string, any>
|
datasets: Record<string, any>
|
||||||
|
race_control?: any[]
|
||||||
|
pit_stops?: any[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceStoryCanvas({ data }: Props) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const { results, starting_grid: grid, positions, datasets } = data
|
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [] } = data
|
||||||
const hasPositions = datasets['positions']?.status === 'available'
|
const hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
|
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
// Position Evolution Chart Logic
|
||||||
const allTimes = [...new Set(positions.map((p) => p.date))].sort()
|
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
const hasChartData = hasPositions && allTimes.length > 0
|
||||||
|
|
||||||
let chartContent = null
|
let chartContent = null
|
||||||
|
let displayResults = results
|
||||||
|
|
||||||
if (hasChartData) {
|
if (hasChartData) {
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
const tMin = new Date(allTimes[0]).getTime()
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||||
@@ -37,6 +46,36 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
samples.sort((a, b) => a.t - b.t)
|
samples.sort((a, b) => a.t - b.t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
|
||||||
|
if (!samples || samples.length === 0) return null
|
||||||
|
if (t <= samples[0].t) return samples[0].pos
|
||||||
|
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||||
|
for (let i = 0; i < samples.length - 1; i++) {
|
||||||
|
if (samples[i].t <= t && samples[i+1].t >= t) {
|
||||||
|
const dt = samples[i+1].t - samples[i].t
|
||||||
|
if (dt === 0) return samples[i].pos
|
||||||
|
const frac = (t - samples[i].t) / dt
|
||||||
|
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrubTime !== null) {
|
||||||
|
const currentPos = new Map<number, number>()
|
||||||
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
|
const pos = getInterpPos(samples, scrubTime)
|
||||||
|
if (pos !== null) {
|
||||||
|
currentPos.set(dNum, pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
displayResults = [...results].sort((a, b) => {
|
||||||
|
const posA = currentPos.get(a.driver_number) ?? 999
|
||||||
|
const posB = currentPos.get(b.driver_number) ?? 999
|
||||||
|
return posA - posB
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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]))
|
||||||
@@ -53,14 +92,66 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
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
|
||||||
|
|
||||||
|
// Safety Car / VSC periods
|
||||||
|
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||||
|
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
||||||
|
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
|
|
||||||
|
for (const msg of rc) {
|
||||||
|
const t = new Date(msg.date).getTime()
|
||||||
|
const m = msg.message?.toUpperCase() || ''
|
||||||
|
const cat = msg.category?.toUpperCase() || ''
|
||||||
|
|
||||||
|
if (m.includes('VIRTUAL SAFETY CAR DEPLOYED') || cat === 'VIRTUALSAFETYCAR') {
|
||||||
|
if (!activeSC) activeSC = { start: t, type: 'VSC' }
|
||||||
|
} else if (m.includes('SAFETY CAR DEPLOYED') || cat === 'SAFETYCAR') {
|
||||||
|
if (!activeSC) activeSC = { start: t, type: 'SC' }
|
||||||
|
} else if (m.includes('TRACK CLEAR') || m.includes('CLEAR')) {
|
||||||
|
if (activeSC) {
|
||||||
|
scPeriods.push({ start: activeSC.start, end: t, type: activeSC.type })
|
||||||
|
activeSC = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeSC) {
|
||||||
|
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
if (!svgRef.current) return
|
||||||
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
|
setScrubTime(t)
|
||||||
|
}
|
||||||
|
|
||||||
chartContent = (
|
chartContent = (
|
||||||
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
||||||
<svg
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Position evolution chart"
|
aria-label="Position evolution chart"
|
||||||
>
|
>
|
||||||
|
{scPeriods.map((sc, i) => {
|
||||||
|
const startT = (sc.start - tMin) / tRange
|
||||||
|
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||||
|
const x1 = toX(Math.max(0, startT))
|
||||||
|
const x2 = toX(Math.min(1, endT))
|
||||||
|
if (x2 <= PL || x1 >= W - PR) return null
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`sc-${i}`}
|
||||||
|
x={x1}
|
||||||
|
y={PT}
|
||||||
|
width={Math.max(0, x2 - x1)}
|
||||||
|
height={plotH}
|
||||||
|
fill={sc.type === 'SC' ? 'rgba(255, 153, 0, 0.15)' : 'rgba(255, 204, 0, 0.1)'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
||||||
<g key={pos}>
|
<g key={pos}>
|
||||||
<line
|
<line
|
||||||
@@ -89,28 +180,56 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const color = colour ? `#${colour}` : '#888'
|
const color = colour ? `#${colour}` : '#888'
|
||||||
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
|
const pts = samples.map((s) => `${toX(s.t)},${toY(s.pos)}`).join(' ')
|
||||||
const last = samples[samples.length - 1]
|
const last = samples[samples.length - 1]
|
||||||
|
const isHovered = hoverDriver === dNum
|
||||||
|
const isFaded = hoverDriver !== null && !isHovered
|
||||||
|
|
||||||
|
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={dNum}>
|
<g
|
||||||
|
key={dNum}
|
||||||
|
style={{ opacity: isFaded ? 0.2 : 1, transition: 'opacity 0.2s' }}
|
||||||
|
onMouseEnter={() => setHoverDriver(dNum)}
|
||||||
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
|
>
|
||||||
<polyline
|
<polyline
|
||||||
points={pts}
|
points={pts}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={2}
|
strokeWidth={isHovered ? 3 : 2}
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
|
className="rs-driver-line"
|
||||||
/>
|
/>
|
||||||
{samples.map((s, i) => (
|
|
||||||
<circle key={i} cx={toX(s.t)} cy={toY(s.pos)} r={3} fill={color} />
|
{driverPits.map((p, i) => {
|
||||||
))}
|
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||||
|
if (t < 0 || t > 1) return null
|
||||||
|
const pos = getInterpPos(samples, t)
|
||||||
|
if (pos === null) return null
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={`pit-${i}`}
|
||||||
|
cx={toX(t)}
|
||||||
|
cy={toY(pos)}
|
||||||
|
r={3}
|
||||||
|
fill="var(--bg)"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
className="rs-pit-dot"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{last && (
|
{last && (
|
||||||
<text
|
<text
|
||||||
x={toX(last.t) + 6}
|
x={toX(last.t) + 6}
|
||||||
y={toY(last.pos) + 4}
|
y={toY(last.pos) + 4}
|
||||||
fill={color}
|
fill={color}
|
||||||
fontSize={9}
|
fontSize={isHovered ? 11 : 9}
|
||||||
fontFamily="var(--f-mono)"
|
fontFamily="var(--f-mono)"
|
||||||
fontWeight={700}
|
fontWeight={700}
|
||||||
|
style={{ cursor: 'default' }}
|
||||||
>
|
>
|
||||||
{acronymByDriver.get(dNum) ?? dNum}
|
{acronymByDriver.get(dNum) ?? dNum}
|
||||||
</text>
|
</text>
|
||||||
@@ -118,6 +237,31 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{scrubTime !== null && (
|
||||||
|
<line
|
||||||
|
x1={toX(scrubTime)}
|
||||||
|
x2={toX(scrubTime)}
|
||||||
|
y1={PT}
|
||||||
|
y2={H - PB}
|
||||||
|
stroke="var(--text)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
className="rs-playhead"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={PL}
|
||||||
|
y={PT}
|
||||||
|
width={plotW}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerLeave={() => setScrubTime(null)}
|
||||||
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -134,17 +278,24 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{results.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div className="rs-field-list">
|
||||||
{results.map((r, i) => {
|
{displayResults.map((r, i) => {
|
||||||
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 isWinner = i === 0 && r.position === 1
|
const isWinner = i === 0 && r.position === 1
|
||||||
const pClass = r.position === 1 ? 'rs-pos-p1' : r.position === 2 ? 'rs-pos-p2' : r.position === 3 ? 'rs-pos-p3' : ''
|
const pClass = r.position === 1 ? 'rs-pos-p1' : r.position === 2 ? 'rs-pos-p2' : r.position === 3 ? 'rs-pos-p3' : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={r.driver_number} className="rs-driver-row">
|
<div
|
||||||
|
key={r.driver_number}
|
||||||
|
className={`rs-driver-row ${hoverDriver === r.driver_number ? 'rs-driver-row-hover' : ''}`}
|
||||||
|
onMouseEnter={() => setHoverDriver(r.driver_number)}
|
||||||
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
|
>
|
||||||
<div className="rs-driver-left">
|
<div className="rs-driver-left">
|
||||||
<div className={`rs-pos-col ${pClass}`}>{r.position}</div>
|
<div className={`rs-pos-col ${pClass}`}>
|
||||||
|
{scrubTime !== null ? i + 1 : r.position}
|
||||||
|
</div>
|
||||||
<div className="rs-driver-cell">
|
<div className="rs-driver-cell">
|
||||||
<div
|
<div
|
||||||
className="rs-driver-color"
|
className="rs-driver-color"
|
||||||
|
|||||||
@@ -2498,3 +2498,36 @@ a { color: inherit; text-decoration: none; }
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Interactive Race Story Canvas elements */
|
||||||
|
.rs-driver-line {
|
||||||
|
stroke-dasharray: 4000;
|
||||||
|
stroke-dashoffset: 4000;
|
||||||
|
animation: drawLine 2.5s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawLine {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-pit-dot {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.5s ease-in-out 1.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-playhead {
|
||||||
|
transition: x1 0.05s linear, x2 0.05s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-driver-row-hover {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user