feat: redesign Live Timing page with dynamic animations and premium dashboard layout

This commit is contained in:
2026-07-03 02:47:04 -04:00
parent cc5184c4b6
commit 85a8b6ad44
6 changed files with 225 additions and 23 deletions

View File

@@ -8,6 +8,7 @@
"name": "box-box-web", "name": "box-box-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0", "@tanstack/react-router": "^1.81.0",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -903,6 +904,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@formkit/auto-animate": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz",
"integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View File

@@ -11,6 +11,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.81.0", "@tanstack/react-router": "^1.81.0",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",

View File

@@ -1,36 +1,119 @@
import type { LiveRCMessage } from '../../types' import { useEffect, useRef, useState } from 'react'
import type { LiveRCMessage, LiveDriverInfo } from '../../types'
import { latestRaceControl, rcFlagClass } from '../../lib/live' import { latestRaceControl, rcFlagClass } from '../../lib/live'
import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react'
interface Props { interface Props {
messages: LiveRCMessage[] messages: LiveRCMessage[]
driverInfo?: Record<string, LiveDriverInfo>
} }
export function RaceControlFeed({ messages }: Props) { function FormattedMessage({ text, driverInfo }: { text: string, driverInfo?: Record<string, LiveDriverInfo> }) {
if (!text) return null;
const regex = /(CAR \d+(?: \([A-Z]{3}\))?|DELETED|TRACK LIMITS|INVESTIGATING|NO FURTHER INVESTIGATION|TIME PENALTY|DRIVE THROUGH PENALTY|STOP AND GO PENALTY|BLACK AND WHITE FLAG|DISQUALIFIED)/g;
const parts = text.split(regex);
return (
<span className="rc-message">
{parts.map((part, i) => {
if (!part) return null;
if (part.startsWith('CAR ')) {
let style = {}
const match = part.match(/CAR (\d+)/)
if (match && driverInfo && driverInfo[match[1]]) {
const hex = teamColor(driverInfo[match[1]].TeamColour)
style = {
backgroundColor: hex,
color: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
border: 'none'
}
}
return <span key={i} className="rc-hlt rc-hlt-car" style={style}>{part}</span>
}
if (part === 'DELETED' || part.includes('PENALTY') || part === 'DISQUALIFIED') return <span key={i} className="rc-hlt rc-hlt-bad">{part}</span>;
if (part === 'TRACK LIMITS' || part === 'INVESTIGATING' || part === 'BLACK AND WHITE FLAG') return <span key={i} className="rc-hlt rc-hlt-warn">{part}</span>;
if (part === 'NO FURTHER INVESTIGATION') return <span key={i} className="rc-hlt rc-hlt-ok">{part}</span>;
return <span key={i}>{part}</span>;
})}
</span>
)
}
export function RaceControlFeed({ messages, driverInfo }: Props) {
const latest = latestRaceControl(messages) const latest = latestRaceControl(messages)
const [listRef] = useAutoAnimate<HTMLDivElement>()
const prevMessagesRef = useRef<LiveRCMessage[]>([])
const [newKeys, setNewKeys] = useState<Set<string>>(new Set())
useEffect(() => {
const prev = prevMessagesRef.current
if (prev.length > 0 && messages.length > prev.length) {
// Find new messages
const newlyAdded = messages.filter(
(m) => !prev.some((p) => p.Time === m.Time && p.Message === m.Message)
)
if (newlyAdded.length > 0) {
const keys = newlyAdded.map((m) => `${m.Time}-${m.Message}`)
setNewKeys((current) => {
const updated = new Set(current)
keys.forEach(k => updated.add(k))
return updated
})
// Clear highlight after 3 seconds
setTimeout(() => {
setNewKeys((current) => {
const updated = new Set(current)
keys.forEach(k => updated.delete(k))
return updated
})
}, 3000)
}
}
prevMessagesRef.current = messages
}, [messages])
return ( return (
<section className="live-rc"> <section className="live-rc panel-glass">
<div className="sec-header"> <div className="sec-header sticky-header">
<span className="sec-title">Race Control</span> <span className="sec-title">Race Control</span>
{messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>} {messages.length > 0 && <span className="sec-meta">{messages.length} messages</span>}
</div> </div>
{latest.length === 0 ? ( {latest.length === 0 ? (
<div className="missing-notice">No race control messages in the current live snapshot.</div> <div className="missing-notice">No race control messages in the current live snapshot.</div>
) : ( ) : (
<div className="live-rc-list live-rc-scroll"> <div className="live-rc-list live-rc-scroll" ref={listRef}>
{latest.map((message, index) => ( {latest.map((message, index) => {
<div className="live-rc-row" key={`${message.Time}-${message.Message}-${index}`}> const key = `${message.Time}-${message.Message}`
<span className="rc-time">{message.Time || '--:--'}</span> const isNew = newKeys.has(key)
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>} let flashClass = ''
{message.Flag if (isNew) {
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span> if (message.Flag === 'YELLOW' || message.Flag === 'DOUBLE YELLOW') {
: message.Category && message.Category !== 'Other' flashClass = ' rc-item-yellow'
? <span className="rc-category">{message.Category}</span> } else if (message.Flag === 'RED') {
: null flashClass = ' rc-item-red'
} else {
flashClass = ' rc-item-new'
} }
<span className="rc-message">{message.Message}</span> }
</div>
))} return (
<div className={`live-rc-row${flashClass}`} key={key}>
<span className="rc-time">{message.Time || '--:--'}</span>
{message.Lap > 0 && <span className="rc-lap">L{message.Lap}</span>}
{message.Flag
? <span className={`rc-flag ${rcFlagClass(message.Flag)}`}>{message.Flag}</span>
: message.Category && message.Category !== 'Other'
? <span className="rc-category">{message.Category}</span>
: null
}
<FormattedMessage text={message.Message} driverInfo={driverInfo} />
</div>
)
})}
</div> </div>
)} )}
</section> </section>

View File

@@ -1,5 +1,6 @@
import { useState, Fragment } from 'react' import { useState, Fragment } from 'react'
import { teamColor } from '../../utils' import { teamColor } from '../../utils'
import { useAutoAnimate } from '@formkit/auto-animate/react'
import type { LiveStintData } from '../../types' import type { LiveStintData } from '../../types'
import type { LiveTimingRow } from '../../lib/live' import type { LiveTimingRow } from '../../lib/live'
import { import {
@@ -42,6 +43,7 @@ export function TimingTower({
}: Props) { }: Props) {
const [expandedRow, setExpandedRow] = useState<string | null>(null) const [expandedRow, setExpandedRow] = useState<string | null>(null)
const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval') const [gapMode, setGapMode] = useState<'interval' | 'leader'>('interval')
const [listRef] = useAutoAnimate<HTMLTableSectionElement>({ duration: 300, easing: 'ease-out' })
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@@ -85,7 +87,7 @@ export function TimingTower({
<th className="r"></th> <th className="r"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody ref={listRef}>
{rows.map((row) => { {rows.map((row) => {
const driver = row.Driver const driver = row.Driver
const delta = positionDelta(driver) const delta = positionDelta(driver)

View File

@@ -164,7 +164,7 @@ export function LiveTimingPage() {
/> />
</div> </div>
<div className="live-rc-col"> <div className="live-rc-col">
<RaceControlFeed messages={snapshot.RCMessages ?? []} /> <RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
</div> </div>
</div> </div>
</> </>

View File

@@ -665,10 +665,119 @@ a { color: inherit; text-decoration: none; }
.track-red { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.25); } .track-red { background: rgba(225,6,0,.12); color: #ff6b6b; border: 1px solid rgba(225,6,0,.25); }
.track-vsc { background: rgba(194,120,255,.12); color: var(--purple); border: 1px solid rgba(194,120,255,.25); } .track-vsc { background: rgba(194,120,255,.12); color: var(--purple); border: 1px solid rgba(194,120,255,.25); }
.live-tower { font-variant-numeric: tabular-nums; } .live-tower {
.live-tower .in-pit td { background: rgba(0, 80, 160, 0.14); } font-variant-numeric: tabular-nums;
.live-tower .pit-out td { background: rgba(57, 199, 58, 0.10); } border-collapse: separate;
.live-tower .retired td { opacity: 0.62; } border-spacing: 0 4px;
}
.live-tower th {
border-bottom: none !important;
padding-bottom: 12px;
}
.live-tower td {
border-bottom: none !important;
}
.live-tower tbody tr {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(8px);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.live-tower tbody tr:hover {
background: rgba(255, 255, 255, 0.07);
transform: scale(1.005) translateX(4px);
}
.live-tower tbody tr td:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
.live-tower tbody tr td:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
.live-tower .in-pit td { background: rgba(0, 80, 160, 0.2) !important; }
.live-tower .pit-out td { background: rgba(57, 199, 58, 0.15) !important; }
.live-tower .retired td { opacity: 0.5; filter: grayscale(80%); }
/* Race Control Panel & Animations */
.panel-glass {
background: rgba(20, 20, 20, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
backdrop-filter: blur(12px);
max-height: calc(100vh - 120px);
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.panel-glass .sticky-header {
position: sticky;
top: -16px;
background: rgba(20, 20, 20, 0.9);
padding: 16px 0 12px 0;
margin-top: -16px;
z-index: 10;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
}
.rc-hlt {
font-weight: 700;
padding: 0 2px;
border-radius: 2px;
}
.rc-hlt-car {
color: #fff;
background: rgba(255, 255, 255, 0.15);
}
.rc-hlt-bad {
color: #ff6b6b;
background: rgba(225, 6, 0, 0.15);
}
.rc-hlt-warn {
color: var(--yellow);
background: rgba(255, 214, 0, 0.15);
}
.rc-hlt-ok {
color: var(--green);
background: rgba(57, 199, 58, 0.15);
}
.panel-glass .sticky-header {
position: sticky;
top: -16px;
background: rgba(20, 20, 20, 0.9);
padding: 16px 0 12px 0;
margin-top: -16px;
z-index: 10;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
}
@keyframes flash-rc {
0% { background: rgba(255, 255, 255, 0.2); transform: scale(1.02); }
100% { background: transparent; transform: scale(1); }
}
.rc-item-new {
animation: flash-rc 2.5s ease-out forwards;
}
@keyframes flash-rc-yellow {
0% { background: rgba(255, 214, 0, 0.35); transform: scale(1.03); box-shadow: 0 0 15px rgba(255, 214, 0, 0.5); }
100% { background: rgba(255, 214, 0, 0.05); transform: scale(1); box-shadow: 0 0 0 transparent; }
}
.rc-item-yellow {
animation: flash-rc-yellow 2.5s ease-out forwards;
}
@keyframes flash-rc-red {
0% { background: rgba(225, 6, 0, 0.4); transform: scale(1.03); box-shadow: 0 0 15px rgba(225, 6, 0, 0.6); }
100% { background: rgba(225, 6, 0, 0.05); transform: scale(1); box-shadow: 0 0 0 transparent; }
}
.rc-item-red {
animation: flash-rc-red 2.5s ease-out forwards;
}
.pos-delta { font-family: var(--f-mono); color: var(--text-3); } .pos-delta { font-family: var(--f-mono); color: var(--text-3); }
.pos-delta.pos-gain { color: var(--green); } .pos-delta.pos-gain { color: var(--green); }
.pos-delta.pos-loss { color: var(--red); } .pos-delta.pos-loss { color: var(--red); }