mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add shared Meaning primitive and pure interpretation helpers for interval, tyre age, and championship gap columns on the live tower, tyre deg panel, and championship hub. Thresholds are exported consts (undercut window tied to PIT_LOSS_SECONDS from #13). Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { teamColor } from '../../utils'
|
|
import type { LiveTimingRow } from '../../lib/live'
|
|
import { compoundClass, driverCode, tyreLabel } from '../../lib/live'
|
|
import { sparklinePoints } from '../../lib/gapHistory'
|
|
import { isRaceSession } from '../../lib/battles'
|
|
import type { StintHistoryMap } from '../../lib/tyredeg'
|
|
import {
|
|
PIT_LOSS_SECONDS,
|
|
degradationModel,
|
|
estimatePitRejoin,
|
|
formatSlope,
|
|
recordStintSamples,
|
|
stintInputFromRow,
|
|
} from '../../lib/tyredeg'
|
|
import { tyreAgeMeaning } from '../../lib/meaning'
|
|
import { Meaning } from '../Meaning'
|
|
import '../../styles/tyredeg.css'
|
|
|
|
const TOP_DRIVER_COUNT = 10
|
|
const SPARK_WIDTH = 64
|
|
const SPARK_HEIGHT = 16
|
|
|
|
interface Props {
|
|
rows: LiveTimingRow[]
|
|
sessionType: string | undefined
|
|
pinned: string[]
|
|
}
|
|
|
|
function StintSparkline({ seconds }: { seconds: number[] }) {
|
|
if (seconds.length < 2) return <span className="tyredeg-spark-empty">·</span>
|
|
const points = sparklinePoints(seconds, SPARK_WIDTH, SPARK_HEIGHT)
|
|
return (
|
|
<svg
|
|
className="tyredeg-spark"
|
|
width={SPARK_WIDTH}
|
|
height={SPARK_HEIGHT}
|
|
viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
>
|
|
<polyline
|
|
points={points}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.2"
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
|
|
|
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
|
useEffect(() => {
|
|
if (rows.length === 0) return
|
|
setStints((prev) => recordStintSamples(prev, rows.map(stintInputFromRow)))
|
|
}, [rows])
|
|
|
|
const isRace = isRaceSession(sessionType)
|
|
|
|
const visible = useMemo(
|
|
() =>
|
|
rows.filter(
|
|
(row) =>
|
|
row.Position > 0 &&
|
|
!row.Driver.Retired &&
|
|
(row.Position <= TOP_DRIVER_COUNT || pinned.includes(row.RacingNumber)),
|
|
),
|
|
[rows, pinned],
|
|
)
|
|
|
|
if (visible.length === 0) return null
|
|
|
|
return (
|
|
<section className="live-tyredeg-panel" data-testid="tyredeg-panel">
|
|
<button
|
|
type="button"
|
|
className="sec-header tyredeg-toggle"
|
|
onClick={() => setCollapsed((prev) => !prev)}
|
|
aria-expanded={!collapsed}
|
|
>
|
|
<span className="sec-title">Tyre Deg & Pit Window</span>
|
|
{isRace && <span className="sec-meta">rejoin assumes ~{PIT_LOSS_SECONDS}s pit loss</span>}
|
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
|
</button>
|
|
|
|
{!collapsed && (
|
|
<div className="tyredeg-rows">
|
|
{visible.map((row) => {
|
|
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
|
return (
|
|
<div className="tyredeg-row" key={row.RacingNumber} data-testid="tyredeg-row">
|
|
<span className="tyredeg-pos mono">P{row.Position}</span>
|
|
<span className="drv-bar" style={{ background: teamColor(row.Info?.TeamColour) }} />
|
|
<span className="drv-code">{driverCode(row)}</span>
|
|
<span className={`tyre-badge ${compoundClass(row.Tyre?.Compound)}`}>
|
|
<Meaning
|
|
value={tyreLabel(row.Tyre)}
|
|
meaning={ageAnnotation?.caption}
|
|
title={ageAnnotation?.title}
|
|
tone={ageAnnotation?.tone}
|
|
/>
|
|
</span>
|
|
{model ? (
|
|
<>
|
|
<span className={`tyredeg-trend tyredeg-trend-${model.trend}`}>
|
|
<StintSparkline seconds={model.samples.map((sample) => sample.seconds)} />
|
|
</span>
|
|
<span className={`tyredeg-slope mono tyredeg-trend-${model.trend}`}>
|
|
{formatSlope(model.slope)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="tyredeg-warmup">warming up</span>
|
|
)}
|
|
{rejoin && (
|
|
<span className="tyredeg-rejoin mono" title={`Pits now: rejoins ~P${rejoin.rejoinPosition}`}>
|
|
→ ~P{rejoin.rejoinPosition}
|
|
{rejoin.aheadCode && rejoin.behindCode && (
|
|
<span className="tyredeg-rejoin-between">
|
|
{rejoin.aheadCode} · {rejoin.behindCode}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|