mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Compare commits
2 Commits
16c035b037
...
0a42c05487
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a42c05487 | ||
|
|
08ff75b469 |
@@ -26,6 +26,12 @@ const CHART_PR = 48
|
||||
const CHART_PT = 8
|
||||
const CHART_PB = 20
|
||||
|
||||
interface ChartTiming {
|
||||
tMin: number
|
||||
tMax: number
|
||||
tRange: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
results: EnrichedResult[]
|
||||
@@ -42,6 +48,48 @@ interface Props {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position samples are recorded for the entire session, including the pre-race
|
||||
* grid period. Use the winning driver's laps to define the race window so
|
||||
* that the x-axis starts at lights-out rather than at the earliest sample.
|
||||
*/
|
||||
function raceChartTiming(
|
||||
positions: PositionSample[],
|
||||
laps: Lap[],
|
||||
results: EnrichedResult[],
|
||||
): ChartTiming | null {
|
||||
const positionTimes = positions
|
||||
.map((position) => new Date(position.date).getTime())
|
||||
.filter(Number.isFinite)
|
||||
if (positionTimes.length === 0) return null
|
||||
|
||||
const fallbackMin = Math.min(...positionTimes)
|
||||
const fallbackMax = Math.max(...positionTimes)
|
||||
const fallback = {
|
||||
tMin: fallbackMin,
|
||||
tMax: fallbackMax,
|
||||
tRange: Math.max(fallbackMax - fallbackMin, 1),
|
||||
}
|
||||
|
||||
const winner = results.find((result) => result.position === 1)
|
||||
if (!winner) return fallback
|
||||
|
||||
const winnerLaps = laps
|
||||
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
|
||||
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
|
||||
.filter((lap) => Number.isFinite(lap.start))
|
||||
.sort((a, b) => a.lap_number - b.lap_number)
|
||||
if (winnerLaps.length === 0) return fallback
|
||||
|
||||
const firstLap = winnerLaps[0]
|
||||
const lastLap = winnerLaps[winnerLaps.length - 1]
|
||||
const finalLapDuration = lastLap.lap_duration ?? 0
|
||||
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
|
||||
if (tMax <= firstLap.start) return fallback
|
||||
|
||||
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
|
||||
}
|
||||
|
||||
export function RaceStoryCanvas({ data }: Props) {
|
||||
const {
|
||||
results,
|
||||
@@ -69,14 +117,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||
|
||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
||||
const hasChartData = hasPositions && allTimes.length > 0
|
||||
const chartTiming = useMemo(() => {
|
||||
if (!hasChartData) return null
|
||||
const tMin = new Date(allTimes[0]).getTime()
|
||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
||||
return { tMin, tMax, tRange: Math.max(tMax - tMin, 1) }
|
||||
}, [allTimes, hasChartData])
|
||||
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||
const hasChartData = hasPositions && chartTiming !== null
|
||||
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
||||
@@ -237,12 +279,15 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
|
||||
if (hasChartData && chartTiming) {
|
||||
const { tMin, tRange } = chartTiming
|
||||
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||
|
||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||
for (const p of positions) {
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time)) continue
|
||||
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||
byDriver.get(p.driver_number)!.push({
|
||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
||||
t: normaliseTime(time),
|
||||
pos: p.position,
|
||||
})
|
||||
}
|
||||
@@ -306,12 +351,15 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
const lapTicks: { lap: number; t: number }[] = []
|
||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||
|
||||
for (const lap of winnerLaps) {
|
||||
for (let i = 0; i < winnerLaps.length; i++) {
|
||||
const lap = winnerLaps[i]
|
||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
||||
if (t >= 0 && t <= 1) {
|
||||
lapTicks.push({ lap: lap.lap_number, t })
|
||||
}
|
||||
const lapStart = new Date(lap.date_start).getTime()
|
||||
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
||||
? lapStart + lap.lap_duration * 1000
|
||||
: nextLapStart
|
||||
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +417,10 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
clearChapterSelection()
|
||||
if (!svgRef.current) return
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
if (rect.width <= 0) return
|
||||
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
|
||||
// before comparing with the fixed chart margins and plot width.
|
||||
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||
setScrubTime(t)
|
||||
}
|
||||
@@ -491,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
/>
|
||||
|
||||
{driverPits.map((p, i) => {
|
||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
||||
if (t < 0 || t > 1) return null
|
||||
const time = new Date(p.date).getTime()
|
||||
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||
const t = normaliseTime(time)
|
||||
const pos = getInterpPos(samples, t)
|
||||
if (pos === null) return null
|
||||
return (
|
||||
@@ -546,6 +598,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
||||
width={plotW}
|
||||
height={plotH}
|
||||
fill="transparent"
|
||||
data-testid="position-chart-interaction"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={() => {
|
||||
if (!isPlaying) {
|
||||
|
||||
@@ -179,6 +179,57 @@ describe('RaceStoryCanvas replay map', () => {
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user