import { useState, useMemo, Fragment } from 'react' import { teamColor } from '../../utils' import { useAutoAnimate } from '@formkit/auto-animate/react' import type { LiveSessionMeta, LiveStintData } from '../../types' import type { LiveTimingRow } from '../../lib/live' import { bestLapGaps, driverCode, liveSessionDisplay, positionDelta, positionDeltaClass, tyreClass, tyreLabel, } from '../../lib/live' import type { GapHistoryMap } from '../../lib/gapHistory' import { parseIntervalSeconds } from '../../lib/gapHistory' import { intervalMeaning } from '../../lib/meaning' import { Meaning } from '../Meaning' import { GapSparkline } from './GapSparkline' import { StintHistory } from './StintHistory' import { Pin } from 'lucide-react' interface Props { rows: LiveTimingRow[] stints?: Record history?: GapHistoryMap battleNumbers?: Set pinned?: string[] onTogglePin?: (racingNumber: string) => void session?: LiveSessionMeta } function posClass(pos: number): string { if (pos === 1) return 'pos-p1' if (pos === 2) return 'pos-p2' if (pos === 3) return 'pos-p3' return 'pos-n' } export function TimingTower({ rows, stints, history, battleNumbers, pinned, onTogglePin, session, }: Props) { const [expandedRow, setExpandedRow] = useState(null) const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval') const [listRef] = useAutoAnimate({ duration: 300, easing: 'ease-out' }) if (rows.length === 0) { return (
Live timing is connected, but no driver timing rows have arrived yet.
) } const sessionDisplay = liveSessionDisplay(session, rows) const isRace = sessionDisplay.isRace const isQuali = sessionDisplay.isQualifying || !isRace const columnCount = 7 + (isRace ? 3 : 0) + (isQuali ? 3 : 0) // Practice/qualifying: derive a display-only gap to P1 from valid best laps // when the upstream feed omits GapToLeader. Empty for races (feed is truth). const practiceGaps = useMemo(() => bestLapGaps(isRace ? [] : rows), [isRace, rows]) return (
{isRace && } {isRace && } {isQuali && } {isQuali && } {isQuali && } {isRace && } {rows.map((row) => { const driver = row.Driver const delta = positionDelta(driver) const deltaClass = positionDeltaClass(driver) const isPinned = pinned?.includes(row.RacingNumber) ?? false const inBattle = battleNumbers?.has(row.RacingNumber) ?? false const isExpanded = expandedRow === row.RacingNumber const isAtRisk = Boolean(sessionDisplay.cutoffPosition && row.Position > sessionDisplay.cutoffPosition) || driver.Cutoff const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition let gapText: string if (isRace) { gapText = gapMode === 'interval' ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader } else if (driver.GapToLeader) { gapText = driver.GapToLeader } else { const computed = practiceGaps[row.RacingNumber] gapText = computed ? (computed.isLeader ? '—' : computed.gap) : '' } const intervalAnnotation = gapMode === 'interval' && isRace && row.Position > 1 ? intervalMeaning(parseIntervalSeconds(gapText)) : null const renderSector = (idx: number) => { const sec = driver.Sectors?.[idx] if (!sec) return '-' let sClass = 'sector-time mono' if (sec.OverallFastest) sClass += ' sector-overall txt-purple' else if (sec.PersonalFastest) sClass += ' sector-personal txt-green' else if (sec.Value) sClass += ' sector-active txt-yellow' return {sec.Value || '-'} } return ( setExpandedRow(isExpanded ? null : row.RacingNumber)} style={{ cursor: 'pointer' }} > {isRace && } {isRace && ( )} {isQuali && } {isQuali && } {isQuali && } {isRace && } {isExpanded && ( )} {showCutoffAfter && ( )} ) })}
PosΔDriver Tyre Last Lap setGapMode(g => g === 'interval' ? 'leader' : 'interval')} title="Click to toggle Gap vs Interval" style={{ cursor: 'pointer', textDecoration: 'underline' }} > {gapMode === 'interval' && isRace ? 'Interval' : 'Gap to P1'} TrendS1S2S3BestLaps
{row.Position}{delta}
{driverCode(row)} {row.RacingNumber} {driver.InPit && PIT} {driver.PitOut && !driver.InPit && OUT} {driver.Retired && RET} {driver.KnockedOut && KO} {driver.Cutoff && !driver.KnockedOut && CUT} {!driver.Cutoff && isAtRisk && !driver.KnockedOut && RISK} {driver.OnFlyingLap && FL}
{tyreLabel(row.Tyre)} {driver.LastLapTime || '-'} {renderSector(0)}{renderSector(1)}{renderSector(2)} {driver.BestLapTime || '-'} {driver.NumberOfLaps || '-'} {onTogglePin && ( )}
STINTS
{!isRace && (
LAPS
{driver.NumberOfLaps || 0}
)} {!isRace && driver.Sectors?.some((sec) => sec?.Value) && (
SECTORS
{renderSector(0)} {renderSector(1)} {renderSector(2)}
)} {driver.SpeedTrap && (
SPEED TRAP
{driver.SpeedTrap} km/h
)}
{sessionDisplay.phaseLabel || 'Q'} cutoff P{sessionDisplay.cutoffPosition} advance {sessionDisplay.atRiskStart && sessionDisplay.atRiskEnd && ( P{sessionDisplay.atRiskStart}-P{sessionDisplay.atRiskEnd} at risk )}
) }