mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Compare commits
1 Commits
fix/live-s
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
888378e210 |
@@ -53,9 +53,8 @@ function StintSparkline({ seconds }: { seconds: number[] }) {
|
||||
|
||||
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
const isRace = isRaceSession(sessionType)
|
||||
// In practice/qualifying deg trends are secondary — collapse by default so
|
||||
// the Timing Tower stays above the fold. Races keep it open.
|
||||
const [collapsed, setCollapsed] = useState(!isRace)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [readerChose, setReaderChose] = useState(false)
|
||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||
|
||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
||||
@@ -75,6 +74,30 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
[rows, pinned],
|
||||
)
|
||||
|
||||
// One linear fit per driver per snapshot, shared by the readiness check and
|
||||
// the rows below. degradationModel is O(laps) and this runs at feed rate.
|
||||
const models = useMemo(() => {
|
||||
const out: Record<string, ReturnType<typeof degradationModel>> = {}
|
||||
for (const row of visible) {
|
||||
out[row.RacingNumber] = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
||||
}
|
||||
return out
|
||||
}, [visible, stints])
|
||||
|
||||
// Before any stint has enough clean laps to fit, every row reads "warming
|
||||
// up" — a full-height panel of placeholders that pushed the Timing Tower off
|
||||
// the fold for the first third of a race. Stay collapsed until there is
|
||||
// something to say, then open. A reader who has toggled it keeps their choice.
|
||||
const hasSignal = useMemo(
|
||||
() => visible.some((row) => models[row.RacingNumber] != null),
|
||||
[visible, models],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (readerChose) return
|
||||
setCollapsed(!(isRace && hasSignal))
|
||||
}, [isRace, hasSignal, readerChose])
|
||||
|
||||
if (visible.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -82,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="sec-header tyredeg-toggle"
|
||||
onClick={() => setCollapsed((prev) => !prev)}
|
||||
onClick={() => {
|
||||
setReaderChose(true)
|
||||
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>}
|
||||
{!hasSignal ? (
|
||||
<span className="sec-meta">collecting clean laps</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 model = models[row.RacingNumber]
|
||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||
return (
|
||||
|
||||
@@ -289,14 +289,16 @@ export function LiveTimingPage() {
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
</div>
|
||||
{/* Rail runs most-synthesized to most-raw: a reader arriving
|
||||
mid-session wants "what did I miss" before the regulatory log. */}
|
||||
<div className="live-rc-col">
|
||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||
<TeamRadioTicker
|
||||
captures={snapshot.TeamRadio ?? []}
|
||||
driverInfo={snapshot.DriverInfo}
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.app-nav::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* The nav scrolls horizontally with its scrollbar hidden. Below the width
|
||||
where the links stop fitting, fade the trailing edge so the cut-off item
|
||||
reads as "scroll for more" instead of as a clipping bug. */
|
||||
@media (max-width: 560px) {
|
||||
.app-nav {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-family: var(--f-mono);
|
||||
font-size: 15px;
|
||||
|
||||
@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
||||
}
|
||||
|
||||
describe('TyreDegPanel', () => {
|
||||
it('shows a warming-up placeholder until enough clean laps accumulate', () => {
|
||||
it('stays collapsed while every stint is still warming up', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
|
||||
// A panel of "warming up" placeholders carries no information and used to
|
||||
// push the Timing Tower off the fold for the first third of a race.
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
expect(panel).toHaveTextContent('collecting clean laps')
|
||||
})
|
||||
|
||||
it('shows a warming-up placeholder on each row once expanded', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
const panel = screen.getByTestId('tyredeg-panel')
|
||||
expect(panel).toHaveTextContent('VER')
|
||||
expect(panel).toHaveTextContent('M +5')
|
||||
expect(panel).toHaveTextContent('fresh')
|
||||
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
|
||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||
]
|
||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -92,16 +104,44 @@ describe('TyreDegPanel', () => {
|
||||
expect(panel).not.toHaveTextContent('~P')
|
||||
})
|
||||
|
||||
it('starts expanded during a race', () => {
|
||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||
it('opens itself during a race as soon as a stint has signal', () => {
|
||||
const { rerender } = render(
|
||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||
)
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
|
||||
for (let lap = 2; lap <= 6; lap++) {
|
||||
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||
}
|
||||
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the reader\'s own collapse choice when signal arrives', () => {
|
||||
const { rerender } = render(
|
||||
<TyreDegPanel rows={snapshotRows(1, '1:30.000')} sessionType="Race" pinned={[]} />,
|
||||
)
|
||||
// Reader opens it early, then closes it again — that decision must stick
|
||||
// even once the panel would otherwise auto-open.
|
||||
const toggle = screen.getByRole('button', { name: /tyre deg/i })
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(toggle)
|
||||
|
||||
for (let lap = 2; lap <= 6; lap++) {
|
||||
const time = `1:30.${String((lap - 1) * 100).padStart(3, '0')}`
|
||||
rerender(<TyreDegPanel rows={snapshotRows(lap, time)} sessionType="Race" pinned={[]} />)
|
||||
}
|
||||
|
||||
expect(screen.queryAllByTestId('tyredeg-row')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('limits rows to the top ten plus pinned drivers', () => {
|
||||
const rows = Array.from({ length: 15 }, (_, index) =>
|
||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||
)
|
||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||
expect(screen.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||
expect(screen.queryByText('D12')).not.toBeInTheDocument()
|
||||
|
||||
@@ -472,62 +472,6 @@ func TestProcessTopicTimingAppData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The feed sends stints as sparse deltas keyed by stint index. Replacing the
|
||||
// slice on each delta collapsed pit history to one entry and pinned tyre age
|
||||
// near zero — observed live at lap 49 of a 70-lap race, where every driver
|
||||
// reported a single stint of age 0 despite having pitted.
|
||||
func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||
"Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}}
|
||||
}`))
|
||||
// Stint 0 runs to 18 laps, then the driver pits onto a new hard.
|
||||
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||
"Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}}
|
||||
}`))
|
||||
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||
"Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}}
|
||||
}`))
|
||||
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||
"Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}}
|
||||
}`))
|
||||
|
||||
snap := state.Snapshot()
|
||||
stints := snap.Stints["4"]
|
||||
if len(stints) != 2 {
|
||||
t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints)
|
||||
}
|
||||
if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 {
|
||||
t.Errorf("first stint lost across deltas: %+v", stints[0])
|
||||
}
|
||||
if stints[1].Compound != "HARD" || stints[1].Laps != 12 {
|
||||
t.Errorf("second stint = %+v", stints[1])
|
||||
}
|
||||
if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 {
|
||||
t.Errorf("current tyre should track the latest stint, got %+v", tyre)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||
"Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}}
|
||||
}`))
|
||||
// "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0
|
||||
// would overwrite the real first stint.
|
||||
state.ProcessTopic("TimingAppData", json.RawMessage(`{
|
||||
"Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}}
|
||||
}`))
|
||||
|
||||
stints := state.Snapshot().Stints["4"]
|
||||
if len(stints) != 1 {
|
||||
t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints)
|
||||
}
|
||||
if stints[0].Compound != "SOFT" || stints[0].Laps != 9 {
|
||||
t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTimingStats(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -405,42 +404,22 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
Stints json.RawMessage `json:"Stints"`
|
||||
}
|
||||
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
||||
// The feed sends stints as sparse deltas keyed by stint index:
|
||||
// a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge
|
||||
// each entry into the stint it addresses. Replacing the slice
|
||||
// wholesale discarded every earlier stint, so pit history
|
||||
// collapsed to one entry and tyre age stuck near zero for the
|
||||
// whole race.
|
||||
driverStints := append([]LiveStintData(nil), s.Stints[num]...)
|
||||
changed := false
|
||||
var driverStints []LiveStintData
|
||||
for _, sRaw := range indexedRawValues(line.Stints) {
|
||||
var st struct {
|
||||
Compound *string `json:"Compound"`
|
||||
New *string `json:"New"`
|
||||
TotalLaps *int `json:"TotalLaps"`
|
||||
Compound string `json:"Compound"`
|
||||
New string `json:"New"`
|
||||
TotalLaps int `json:"TotalLaps"`
|
||||
}
|
||||
if json.Unmarshal(sRaw.Raw, &st) != nil {
|
||||
continue
|
||||
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
|
||||
driverStints = append(driverStints, LiveStintData{
|
||||
Compound: st.Compound,
|
||||
New: st.New == "true" || st.New == "True",
|
||||
Laps: st.TotalLaps,
|
||||
})
|
||||
}
|
||||
if st.Compound == nil && st.New == nil && st.TotalLaps == nil {
|
||||
continue
|
||||
}
|
||||
for len(driverStints) <= sRaw.Index {
|
||||
driverStints = append(driverStints, LiveStintData{})
|
||||
}
|
||||
entry := &driverStints[sRaw.Index]
|
||||
if st.Compound != nil && *st.Compound != "" {
|
||||
entry.Compound = *st.Compound
|
||||
}
|
||||
if st.New != nil {
|
||||
entry.New = *st.New == "true" || *st.New == "True"
|
||||
}
|
||||
if st.TotalLaps != nil {
|
||||
entry.Laps = *st.TotalLaps
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if len(driverStints) > 0 {
|
||||
s.Stints[num] = driverStints
|
||||
lastStint := driverStints[len(driverStints)-1]
|
||||
t := s.Tyres[num]
|
||||
@@ -895,13 +874,8 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||
values := make([]indexedRaw, 0, len(obj))
|
||||
for k, v := range obj {
|
||||
// Keys are array indices in the feed's delta form. Non-numeric keys
|
||||
// are feed metadata — "_kf" (key frame) is the common one — and must
|
||||
// not be folded in as index 0, which would clobber the first entry.
|
||||
i, err := strconv.Atoi(k)
|
||||
if err != nil || i < 0 {
|
||||
continue
|
||||
}
|
||||
i := 0
|
||||
fmt.Sscanf(k, "%d", &i)
|
||||
values = append(values, indexedRaw{Index: i, Raw: v})
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
|
||||
Reference in New Issue
Block a user