mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 12:07:23 -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) {
|
export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
||||||
const isRace = isRaceSession(sessionType)
|
const isRace = isRaceSession(sessionType)
|
||||||
// In practice/qualifying deg trends are secondary — collapse by default so
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
// the Timing Tower stays above the fold. Races keep it open.
|
const [readerChose, setReaderChose] = useState(false)
|
||||||
const [collapsed, setCollapsed] = useState(!isRace)
|
|
||||||
const [stints, setStints] = useState<StintHistoryMap>({})
|
const [stints, setStints] = useState<StintHistoryMap>({})
|
||||||
|
|
||||||
// One lap-history update per received snapshot (rows is rebuilt per snapshot).
|
// 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],
|
[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
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,18 +105,25 @@ export function TyreDegPanel({ rows, sessionType, pinned }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="sec-header tyredeg-toggle"
|
className="sec-header tyredeg-toggle"
|
||||||
onClick={() => setCollapsed((prev) => !prev)}
|
onClick={() => {
|
||||||
|
setReaderChose(true)
|
||||||
|
setCollapsed((prev) => !prev)
|
||||||
|
}}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
>
|
>
|
||||||
<span className="sec-title">Tyre Deg & Pit Window</span>
|
<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>
|
<span className="tyredeg-chevron" aria-hidden="true">{collapsed ? '▸' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="tyredeg-rows">
|
<div className="tyredeg-rows">
|
||||||
{visible.map((row) => {
|
{visible.map((row) => {
|
||||||
const model = degradationModel(stints[row.RacingNumber]?.samples ?? [])
|
const model = models[row.RacingNumber]
|
||||||
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
const rejoin = isRace ? estimatePitRejoin(rows, row.RacingNumber) : null
|
||||||
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
const ageAnnotation = tyreAgeMeaning(row.Tyre?.Compound, row.Tyre?.Age)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -289,14 +289,16 @@ export function LiveTimingPage() {
|
|||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="live-rc-col">
|
||||||
|
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
||||||
<TeamRadioTicker
|
<TeamRadioTicker
|
||||||
captures={snapshot.TeamRadio ?? []}
|
captures={snapshot.TeamRadio ?? []}
|
||||||
driverInfo={snapshot.DriverInfo}
|
driverInfo={snapshot.DriverInfo}
|
||||||
session={snapshot.Session}
|
session={snapshot.Session}
|
||||||
/>
|
/>
|
||||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||||
<EventRail events={events} driverInfo={snapshot.DriverInfo} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -89,6 +89,16 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.app-nav::-webkit-scrollbar { display: 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 {
|
.nav-logo {
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|||||||
@@ -47,9 +47,20 @@ function snapshotRows(lap: number, lastLapTime: string): LiveTimingRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('TyreDegPanel', () => {
|
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={[]} />)
|
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
||||||
const panel = screen.getByTestId('tyredeg-panel')
|
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('VER')
|
||||||
expect(panel).toHaveTextContent('M +5')
|
expect(panel).toHaveTextContent('M +5')
|
||||||
expect(panel).toHaveTextContent('fresh')
|
expect(panel).toHaveTextContent('fresh')
|
||||||
@@ -61,6 +72,7 @@ describe('TyreDegPanel', () => {
|
|||||||
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
makeRow('1', 1, 'VER', { NumberOfLaps: 10, LastLapTime: '1:30.000' }, { Compound: 'MEDIUM', Age: 12 }),
|
||||||
]
|
]
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={[]} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /tyre deg/i }))
|
||||||
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
expect(screen.getByText('mid-life')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,16 +104,44 @@ describe('TyreDegPanel', () => {
|
|||||||
expect(panel).not.toHaveTextContent('~P')
|
expect(panel).not.toHaveTextContent('~P')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts expanded during a race', () => {
|
it('opens itself during a race as soon as a stint has signal', () => {
|
||||||
render(<TyreDegPanel rows={snapshotRows(3, '1:30.000')} sessionType="Race" pinned={[]} />)
|
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)
|
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', () => {
|
it('limits rows to the top ten plus pinned drivers', () => {
|
||||||
const rows = Array.from({ length: 15 }, (_, index) =>
|
const rows = Array.from({ length: 15 }, (_, index) =>
|
||||||
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
makeRow(String(index + 1), index + 1, `D${index + 1}`),
|
||||||
)
|
)
|
||||||
render(<TyreDegPanel rows={rows} sessionType="Race" pinned={['14']} />)
|
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.getAllByTestId('tyredeg-row')).toHaveLength(11)
|
||||||
expect(screen.getByText('D14')).toBeInTheDocument()
|
expect(screen.getByText('D14')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('D12')).not.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) {
|
func TestProcessTopicTimingStats(t *testing.T) {
|
||||||
state := live.NewState()
|
state := live.NewState()
|
||||||
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -405,42 +404,22 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
|||||||
Stints json.RawMessage `json:"Stints"`
|
Stints json.RawMessage `json:"Stints"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
||||||
// The feed sends stints as sparse deltas keyed by stint index:
|
var driverStints []LiveStintData
|
||||||
// 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
|
|
||||||
for _, sRaw := range indexedRawValues(line.Stints) {
|
for _, sRaw := range indexedRawValues(line.Stints) {
|
||||||
var st struct {
|
var st struct {
|
||||||
Compound *string `json:"Compound"`
|
Compound string `json:"Compound"`
|
||||||
New *string `json:"New"`
|
New string `json:"New"`
|
||||||
TotalLaps *int `json:"TotalLaps"`
|
TotalLaps int `json:"TotalLaps"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(sRaw.Raw, &st) != nil {
|
if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" {
|
||||||
continue
|
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 {
|
if len(driverStints) > 0 {
|
||||||
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 {
|
|
||||||
s.Stints[num] = driverStints
|
s.Stints[num] = driverStints
|
||||||
lastStint := driverStints[len(driverStints)-1]
|
lastStint := driverStints[len(driverStints)-1]
|
||||||
t := s.Tyres[num]
|
t := s.Tyres[num]
|
||||||
@@ -895,13 +874,8 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
|||||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||||
values := make([]indexedRaw, 0, len(obj))
|
values := make([]indexedRaw, 0, len(obj))
|
||||||
for k, v := range obj {
|
for k, v := range obj {
|
||||||
// Keys are array indices in the feed's delta form. Non-numeric keys
|
i := 0
|
||||||
// are feed metadata — "_kf" (key frame) is the common one — and must
|
fmt.Sscanf(k, "%d", &i)
|
||||||
// not be folded in as index 0, which would clobber the first entry.
|
|
||||||
i, err := strconv.Atoi(k)
|
|
||||||
if err != nil || i < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
values = append(values, indexedRaw{Index: i, Raw: v})
|
values = append(values, indexedRaw{Index: i, Raw: v})
|
||||||
}
|
}
|
||||||
sort.Slice(values, func(i, j int) bool {
|
sort.Slice(values, func(i, j int) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user