Compare commits
78 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e5857760 | ||
|
|
06223d992f | ||
|
|
ba55945b6f | ||
|
|
643af2355a | ||
|
|
45084558cd | ||
|
|
90c24ad6aa | ||
|
|
e028e3e3fe | ||
|
|
0a42c05487 | ||
|
|
08ff75b469 | ||
|
|
16c035b037 | ||
|
|
1dac73cdc5 | ||
|
|
02a84f67b4 | ||
|
|
f0a8e4e631 | ||
|
|
519053d340 | ||
|
|
e0d7b9981e | ||
|
|
79acf0579a | ||
|
|
82abbd6b35 | ||
|
|
faa173578c | ||
|
|
39f1560282 | ||
|
|
d3f7bab6dc | ||
|
|
5b112fb012 | ||
|
|
96d741424c | ||
|
|
c2617d39bb | ||
|
|
beacda278d | ||
|
|
f24ec4be94 | ||
|
|
09104451d0 | ||
|
|
f8bd183c26 | ||
|
|
01ec1f24e7 | ||
|
|
3f022a0cf9 | ||
|
|
5970649a9c | ||
|
|
7212eb9b44 | ||
|
|
785dccdca2 | ||
|
|
e8de2068c1 | ||
|
|
3cc6157337 | ||
|
|
1782fd313c | ||
|
|
be7eb3c034 | ||
|
|
71523641ad | ||
|
|
e51cabba62 | ||
|
|
fc6fc36d8f | ||
|
|
16d82afad5 | ||
|
|
91199ff5c0 | ||
|
|
144e93ee44 | ||
|
|
f4f5071e26 | ||
|
|
a1d71900d1 | ||
|
|
34b060238a | ||
|
|
a9920f0dd4 | ||
|
|
7c8a75f7ca | ||
|
|
e0174f7bf1 | ||
|
|
2d6fa531f2 | ||
|
|
8fd358748d | ||
|
|
b0fd252096 | ||
|
|
ad379e0f07 | ||
|
|
9ee14e5c89 | ||
|
|
cc4337be88 | ||
|
|
addfd6d24d | ||
|
|
b5d070e116 | ||
|
|
233eefaf12 | ||
|
|
51b0238b09 | ||
|
|
24bcac8038 | ||
|
|
93ac0ccebb | ||
|
|
66bf649729 | ||
|
|
2ddc930efe | ||
|
|
7d0b25b66e | ||
|
|
bc18a20829 | ||
|
|
2795ccf11b | ||
|
|
4813b958a0 | ||
|
|
a97e3caa4f | ||
|
|
655df9845b | ||
|
|
2d49a8bd00 | ||
|
|
4e62e93ea2 | ||
|
|
eacd5f26e9 | ||
|
|
f2bf1fd0fc | ||
|
|
6c72c12821 | ||
|
|
98126a06f9 | ||
|
|
47f0cd6d12 | ||
|
|
26642e3ff6 | ||
|
|
0977861806 | ||
|
|
c5a7ad51eb |
@@ -24,7 +24,7 @@ bin/dev CLI: `dev implement <issue#> --harness <name> [--dry-run]`
|
|||||||
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
|
- **Groom** (interactive, Claude): `/groom <issue#>` runs a seeded grill-me → writes a
|
||||||
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
|
Ready spec into the issue body → sets Effort/Priority → leaves Stage at `Research`.
|
||||||
You review and flip to `Ready`.
|
You review and flip to `Ready`.
|
||||||
- **Implement** (any harness): `.agents/bin/dev implement <issue#> --harness <name>`
|
- **Implement** (recommended harnesses: `codex` or `cursor`): `.agents/bin/dev implement <issue#> --harness <name>`
|
||||||
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
|
(or `/implement …` in Claude to supervise) → isolated worktree → runs the harness
|
||||||
headless on the spec → build gate → opens a PR → sets Stage `In Review`.
|
headless on the spec → build gate → opens a PR → sets Stage `In Review`.
|
||||||
- **Review + merge**: use the `review` skill from a harness different from the
|
- **Review + merge**: use the `review` skill from a harness different from the
|
||||||
@@ -42,8 +42,17 @@ local adapter state.
|
|||||||
## Adding / fixing a harness
|
## Adding / fixing a harness
|
||||||
|
|
||||||
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
|
Edit one function in `harnesses.sh`: `harness_<name> <workdir> <promptfile>`, running the
|
||||||
tool non-interactively in `<workdir>` on the prompt. `claude`/`codex`/`opencode` are
|
tool non-interactively in `<workdir>` on the prompt. For current implementation
|
||||||
wired; `pi`/`cursor` are stubs — confirm their headless flags before trusting.
|
dispatch, prefer `codex` or `cursor`; `claude` and `opencode` remain available, and
|
||||||
|
`pi` still needs flag verification before trusting.
|
||||||
|
|
||||||
|
`agy` / Antigravity is deliberately disabled for non-dry-run dispatch as of
|
||||||
|
2026-07-04. Phase 1 testing found the headless path unreliable: with
|
||||||
|
`--new-project` it ignored the prompt and tried to scaffold, while without it the CLI
|
||||||
|
could resume a stale conversation and hang past the print timeout. Keep using
|
||||||
|
`.agents/bin/dev implement <issue#> --harness agy --dry-run` for prompt inspection
|
||||||
|
only; real dispatch should use `codex` or `cursor` until a fresh Antigravity
|
||||||
|
headless invocation is verified and documented.
|
||||||
|
|
||||||
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
|
Always `--dry-run` a new harness first: it renders the exact prompt and plan, touching
|
||||||
nothing (no worktree, PR, or state change).
|
nothing (no worktree, PR, or state change).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
|
# .agents/bin/dev — box-box agentic dev CLI. Works from any harness's shell.
|
||||||
#
|
#
|
||||||
# dev implement <issue#> --harness <claude|codex|opencode|pi|cursor> [--dry-run] [--base <branch>]
|
# dev implement <issue#> --harness <codex|cursor|claude|opencode|pi|agy> [--dry-run] [--base <branch>]
|
||||||
#
|
#
|
||||||
# Grooming is driven interactively via the Claude Code /groom skill; this CLI covers
|
# Grooming is driven interactively via the Claude Code /groom skill; this CLI covers
|
||||||
# the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR).
|
# the implement lane (dispatch a Ready issue to a harness → worktree → gate → PR).
|
||||||
@@ -15,7 +15,7 @@ box-box dev CLI
|
|||||||
|
|
||||||
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
|
dev implement <issue#> --harness <name> [--dry-run] [--base <branch>]
|
||||||
|
|
||||||
harnesses: claude, codex, opencode (supported) · pi, cursor (verify flags in .agents/harnesses.sh)
|
harnesses: codex, cursor (recommended) · claude, opencode (available) · agy (dry-run only, disabled for dispatch) · pi (verify flags)
|
||||||
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
|
--dry-run render the prompt + plan, touch nothing (no worktree/PR/state change)
|
||||||
--base base branch for the worktree/PR (default: main)
|
--base base branch for the worktree/PR (default: main)
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -11,9 +11,22 @@
|
|||||||
|
|
||||||
# ---- MUST-HAVE ----
|
# ---- MUST-HAVE ----
|
||||||
|
|
||||||
|
harness_disabled_reason() { # <name> -> reason on stdout; 0 means disabled
|
||||||
|
case "$1" in
|
||||||
|
agy)
|
||||||
|
cat <<'EOF'
|
||||||
|
agy is disabled for implementation dispatch as of 2026-07-04: Antigravity headless mode was observed to ignore prompts with --new-project, resume stale conversations without it, and hang past print timeouts. Use codex or cursor until a fresh headless invocation is verified.
|
||||||
|
EOF
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
harness_claude() { # Claude Code — print mode, auto-accept edits
|
harness_claude() { # Claude Code — print mode, auto-accept edits
|
||||||
local dir="$1" prompt="$2"
|
local dir="$1" prompt="$2"
|
||||||
( cd "$dir" && claude -p "$(cat "$prompt")" --permission-mode acceptEdits )
|
local model="${BOXBOX_CLAUDE_MODEL:-opus}"
|
||||||
|
( cd "$dir" && claude -p "$(cat "$prompt")" --model "$model" --effort high --permission-mode acceptEdits )
|
||||||
}
|
}
|
||||||
|
|
||||||
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
|
harness_codex() { # OpenAI Codex CLI — non-interactive exec, full auto
|
||||||
@@ -28,15 +41,15 @@ harness_opencode() { # opencode — non-interactive run
|
|||||||
|
|
||||||
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
|
harness_cursor() { # Cursor CLI agent — composer-2.5, headless full-auto
|
||||||
local dir="$1" prompt="$2"
|
local dir="$1" prompt="$2"
|
||||||
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model composer-2.5 --force --trust )
|
local model="${BOXBOX_CURSOR_MODEL:-composer-2.5}"
|
||||||
|
( cd "$dir" && cursor-agent -p "$(cat "$prompt")" --model "$model" --force --trust )
|
||||||
}
|
}
|
||||||
|
|
||||||
harness_agy() { # Antigravity CLI — Gemini 3.1 Pro, headless full-auto
|
harness_agy() { # Antigravity CLI — disabled until headless is verified
|
||||||
# --new-project is required for --model to take effect (otherwise agy resumes the
|
local reason
|
||||||
# previous conversation and silently keeps its old model).
|
reason="$(harness_disabled_reason agy)"
|
||||||
local dir="$1" prompt="$2"
|
echo "harness_agy: $reason" >&2
|
||||||
( cd "$dir" && agy --print --new-project --print-timeout 60m \
|
return 2
|
||||||
--model="Gemini 3.1 Pro (High)" --dangerously-skip-permissions "$(cat "$prompt")" )
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----
|
# ---- NICE-TO-HAVE (verify the exact invocation for your version before trusting) ----
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ dispatch() { # <issue#> <harness> [--dry-run] [--base <branch>]
|
|||||||
if ! declare -f "harness_$harness" >/dev/null 2>&1; then
|
if ! declare -f "harness_$harness" >/dev/null 2>&1; then
|
||||||
echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2
|
echo "no adapter for harness '$harness' — add harness_$harness() to .agents/harnesses.sh" >&2; return 2
|
||||||
fi
|
fi
|
||||||
|
if [ "$dry" != 1 ] && declare -f harness_disabled_reason >/dev/null 2>&1; then
|
||||||
|
local disabled_reason
|
||||||
|
if disabled_reason="$(harness_disabled_reason "$harness")"; then
|
||||||
|
echo "harness '$harness' is disabled for non-dry-run dispatch." >&2
|
||||||
|
echo " $disabled_reason" >&2
|
||||||
|
echo " Use --dry-run for prompt inspection, or dispatch with --harness codex/cursor." >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
local repo_root title body slug branch wt prompt
|
local repo_root title body slug branch wt prompt
|
||||||
repo_root="$(git rev-parse --show-toplevel)" || return 1
|
repo_root="$(git rev-parse --show-toplevel)" || return 1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: groom
|
name: groom
|
||||||
description: Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.
|
description: 'Groom a box-box GitHub issue into a Ready spec via a seeded grill-me interrogation. Use when the user asks to groom an issue, for example "/groom <issue-number>" or "$groom <issue-number>" with optional "--lens architect". Single-issue path: asks targeted questions, writes a structured spec into the issue body, sets Effort/Priority, and leaves Stage at Research for approval.'
|
||||||
argument-hint: <issue-number> [--lens architect]
|
argument-hint: <issue-number> [--lens architect]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: implement
|
name: implement
|
||||||
description: Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.
|
description: 'Dispatch a Ready box-box issue to a coding harness (claude/codex/opencode/pi/cursor) in an isolated git worktree, run the build gate, and open a PR. Use when supervising implementation via "/implement <issue-number> --harness <name> [--dry-run]" or from any harness terminal with .agents/bin/dev.'
|
||||||
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
argument-hint: <issue-number> --harness <claude|codex|opencode|pi|cursor> [--dry-run]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: lens-architect
|
name: lens-architect
|
||||||
description: Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.
|
description: 'Grill/analyze a box-box issue or epic from a software-architecture perspective and post the findings as a comment. Use standalone as "/lens-architect <issue-number>" for an on-the-fly architecture review, or let /groom compose it via "--lens architect". Reads the base grill + architect persona and focuses on reuse, data flow, seams, testability, and risk.'
|
||||||
argument-hint: <issue-number>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: review
|
name: review
|
||||||
description: Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.
|
description: 'Locally review a box-box PR against its linked GitHub issue spec, run tests, capture visual screenshots when applicable, create a .review packet, and post a GitHub PR comment. Use when a ticket implementation is ready for independent local review before the human merge gate.'
|
||||||
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
argument-hint: <pr-number> [--harness <name>] [--publish-screenshots]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: write-spec
|
name: write-spec
|
||||||
description: Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue's spec from agreed decisions. Does not change Stage.
|
description: 'Render a groomed design into the box-box Ready-spec template and write it into a GitHub issue body, then set Effort and Priority. Called by /groom after a grill session, or run standalone as "/write-spec <issue-number>" to (re)write an issue''s spec from agreed decisions. Does not change Stage.'
|
||||||
argument-hint: <issue-number>
|
argument-hint: <issue-number>
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
59
.agents/test/dispatch_disabled_harness_test.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$ROOT/.agents/lib/dispatch.sh"
|
||||||
|
|
||||||
|
issue_title() { echo "Disable agy harness smoke"; }
|
||||||
|
issue_body() { echo "## Spec"; echo; echo "Smoke prompt body"; }
|
||||||
|
get_field() { echo "Ready"; }
|
||||||
|
set_stage() { echo "unexpected set_stage $*" >&2; return 99; }
|
||||||
|
run_gate() { echo "unexpected run_gate $*" >&2; return 99; }
|
||||||
|
|
||||||
|
unexpected_git_file="$(mktemp "${TMPDIR:-/tmp}/boxbox-agy-git.XXXX")"
|
||||||
|
rm -f "$unexpected_git_file"
|
||||||
|
git() {
|
||||||
|
if [ "${1:-}" = "rev-parse" ]; then
|
||||||
|
command git "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "unexpected git $*" >&2
|
||||||
|
touch "$unexpected_git_file"
|
||||||
|
return 99
|
||||||
|
}
|
||||||
|
|
||||||
|
set +e
|
||||||
|
non_dry_output="$(dispatch 47 agy 2>&1)"
|
||||||
|
non_dry_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
[ "$non_dry_status" -eq 2 ] || {
|
||||||
|
echo "expected agy non-dry-run to exit 2, got $non_dry_status" >&2
|
||||||
|
echo "$non_dry_output" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$non_dry_output" == *"harness 'agy' is disabled"* ]] || {
|
||||||
|
echo "expected disabled-harness message" >&2
|
||||||
|
echo "$non_dry_output" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[ ! -e "$unexpected_git_file" ] || {
|
||||||
|
echo "agy non-dry-run reached git before failing" >&2
|
||||||
|
echo "$non_dry_output" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
dry_output="$(dispatch 47 agy --dry-run 2>&1)"
|
||||||
|
[[ "$dry_output" == *"[dry-run] no worktree / harness / PR / state change"* ]] || {
|
||||||
|
echo "expected agy dry-run to render dispatch preview" >&2
|
||||||
|
echo "$dry_output" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
[[ "$dry_output" == *"Smoke prompt body"* ]] || {
|
||||||
|
echo "expected agy dry-run prompt body" >&2
|
||||||
|
echo "$dry_output" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
42
cmd/main.go
@@ -27,6 +27,7 @@ func main() {
|
|||||||
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
|
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
|
||||||
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
|
||||||
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
|
ingestNews := flag.Bool("ingest-news", false, "Refresh RSS/Atom paddock briefing feeds")
|
||||||
|
prefetchTrackOutlines := flag.Int("prefetch-track-outlines", 0, "Warm the web track-outline cache for a season year (for web-only hosts, run before --web so /api/v1/track-outline can serve live maps)")
|
||||||
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
|
||||||
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
|
force := flag.Bool("force", false, "Re-ingest datasets even if already tracked in the session_coverage table as completed")
|
||||||
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
|
coverageYear := flag.Int("coverage", 0, "Show season coverage report for the given year")
|
||||||
@@ -76,11 +77,21 @@ func main() {
|
|||||||
if *ingestNews {
|
if *ingestNews {
|
||||||
ingestFlags++
|
ingestFlags++
|
||||||
}
|
}
|
||||||
|
if *prefetchTrackOutlines != 0 {
|
||||||
|
ingestFlags++
|
||||||
|
}
|
||||||
if ingestFlags > 0 {
|
if ingestFlags > 0 {
|
||||||
if ingestFlags > 1 {
|
if ingestFlags > 1 {
|
||||||
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, or --ingest-news may be set")
|
fmt.Fprintln(os.Stderr, "box-box: only one of --ingest-year, --backfill-season, --ingest-meeting, --ingest-session, --ingest-news, or --prefetch-track-outlines may be set")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if *prefetchTrackOutlines != 0 {
|
||||||
|
if err := runTrackOutlinePrefetch(client, *prefetchTrackOutlines); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "box-box track outline prefetch error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if *ingestNews {
|
if *ingestNews {
|
||||||
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
|
if err := runNewsIngestion(*dryRun, *dbPath); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "box-box ingest error: %v\n", err)
|
||||||
@@ -174,6 +185,35 @@ func runIngestion(client *api.OpenF1Client, year, meetingKey, sessionKey int, fo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runTrackOutlinePrefetch(client *api.OpenF1Client, year int) error {
|
||||||
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "track outlines: warming HTTP cache %s for %d\n", api.DefaultCacheDBPath(), year)
|
||||||
|
|
||||||
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch meetings for %d: %w", year, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := client.PrefetchTrackOutlinesForYear(year, meetings)
|
||||||
|
fmt.Printf(
|
||||||
|
"track outlines %d: cached %d/%d unique circuit(s) before, %d/%d after; %d skipped, %d fetched, %d failed\n",
|
||||||
|
result.Year,
|
||||||
|
result.CachedBefore,
|
||||||
|
result.UniqueCircuits,
|
||||||
|
result.CachedAfter,
|
||||||
|
result.UniqueCircuits,
|
||||||
|
result.Skipped,
|
||||||
|
result.Fetched,
|
||||||
|
result.Failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.CachedAfter == 0 {
|
||||||
|
return fmt.Errorf("cached zero track outlines for %d", year)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func runNewsIngestion(dryRun bool, dbPath string) error {
|
func runNewsIngestion(dryRun bool, dbPath string) error {
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
|
|||||||
BIN
docs/phase-1/screenshots/command-center-hero-between.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/phase-1/screenshots/live-event-tyre-radio-mocked.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/phase-1/screenshots/race-hub-compare-telemetry-delta.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/phase-1/screenshots/race-hub-strategy-timeline.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/phase-2/19-21-20-race-story.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/phase-2/22-driver-profile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/phase-2/23-teammate-h2h.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/phase-2/24-rivalry-compare.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/phase-2/25-race-preview.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/phase-2/26-briefing-digest.png
Normal file
|
After Width: | Height: | Size: 683 KiB |
32
docs/product/v0.4.0/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# v0.4.0 — Current Weekend & Product Truth
|
||||||
|
|
||||||
|
Product research references for the v0.4.0 feature sprint.
|
||||||
|
|
||||||
|
## Direction
|
||||||
|
|
||||||
|
The sprint replaces route-first navigation with a state-aware Weekend experience:
|
||||||
|
|
||||||
|
- Weekend is the adaptive home for what happened, what is live, and what is next.
|
||||||
|
- Preview content folds into Weekend before a session.
|
||||||
|
- Live remains a stable deep link and becomes Weekend's active-session state.
|
||||||
|
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
|
||||||
|
- Championship and Briefing remain dedicated destinations.
|
||||||
|
- Explore owns secondary discovery; Admin moves to operator utility.
|
||||||
|
|
||||||
|
## Mockups
|
||||||
|
|
||||||
|
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
|
||||||
|
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
|
||||||
|
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
|
||||||
|
|
||||||
|
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- No OpenF1 REST dependency during active sessions.
|
||||||
|
- Public live GPS is not assumed to be available.
|
||||||
|
- Championship round numbers exclude tests and cancelled meetings.
|
||||||
|
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
|
||||||
|
- Future sessions must not render empty post-session analysis.
|
||||||
|
|
||||||
|
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.
|
||||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,14 +1,21 @@
|
|||||||
import type {
|
import type {
|
||||||
ArticleContent,
|
ArticleContent,
|
||||||
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
|
DriverSummary,
|
||||||
|
EnrichedGrid,
|
||||||
|
EnrichedResult,
|
||||||
|
LapsComparisonResponse,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
LiveSessionMeta,
|
LiveSessionMeta,
|
||||||
Meeting,
|
Meeting,
|
||||||
NewsItem,
|
NewsItem,
|
||||||
RaceHub,
|
RaceHub,
|
||||||
|
ReplayFramesResponse,
|
||||||
Session,
|
Session,
|
||||||
TrackOutline,
|
TrackOutline,
|
||||||
Weekend,
|
Weekend,
|
||||||
|
WeekendContext,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||||
@@ -38,7 +45,11 @@ export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
||||||
const res = await fetch(`/api/v1/meetings?year=${year}&source=openf1`)
|
return fetchMeetings(year, 'openf1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMeetings(year: number, source = 'auto'): Promise<Meeting[]> {
|
||||||
|
const res = await fetch(`/api/v1/meetings?year=${year}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
}
|
}
|
||||||
@@ -46,6 +57,47 @@ export async function fetchSeasonMeetings(year: number): Promise<Meeting[]> {
|
|||||||
return Array.isArray(meetings) ? meetings : []
|
return Array.isArray(meetings) ? meetings : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchResults(sessionKey: number, source = 'auto'): Promise<EnrichedResult[]> {
|
||||||
|
const res = await fetch(`/api/v1/results?session_key=${sessionKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const results = await res.json()
|
||||||
|
return Array.isArray(results) ? results : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStartingGrid(sessionKey: number, source = 'auto'): Promise<EnrichedGrid[]> {
|
||||||
|
const res = await fetch(`/api/v1/grid?session_key=${sessionKey}&source=${source}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const grid = await res.json()
|
||||||
|
return Array.isArray(grid) ? grid : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTrackOutline(circuitKey: number, year: number): Promise<TrackOutline | null> {
|
||||||
|
const res = await fetch(`/api/v1/track-outline?circuit_key=${circuitKey}&year=${year}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.error || !Array.isArray(data?.points) || data.points.length < 2) return null
|
||||||
|
return data as TrackOutline
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReplayFrames(
|
||||||
|
sessionKey: number,
|
||||||
|
intervalMs = 5000,
|
||||||
|
): Promise<ReplayFramesResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
session_key: String(sessionKey),
|
||||||
|
interval_ms: String(intervalMs),
|
||||||
|
})
|
||||||
|
const res = await fetch(`/api/v1/replay/frames?${params}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
export async function fetchSessions(meetingKey: number, source = 'openf1'): Promise<Session[]> {
|
||||||
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
const res = await fetch(`/api/v1/sessions?meeting_key=${meetingKey}&source=${source}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -63,6 +115,19 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||||
|
// (backend story #72). The response is the authoritative WeekendContext shape and
|
||||||
|
// is used verbatim as the Weekend home's source of truth. Any HTTP error throws
|
||||||
|
// so the hook can surface an explicit error state; there is no client-side
|
||||||
|
// re-derivation of the contract.
|
||||||
|
export async function fetchWeekendContext(): Promise<WeekendContext> {
|
||||||
|
const res = await fetch('/api/v1/weekend-context')
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||||
const params = new URLSearchParams({ source: 'auto' })
|
const params = new URLSearchParams({ source: 'auto' })
|
||||||
if (year) params.set('year', year.toString())
|
if (year) params.set('year', year.toString())
|
||||||
@@ -74,6 +139,19 @@ export async function fetchChampionshipHub(year?: number): Promise<ChampionshipH
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchDriverSummary(
|
||||||
|
driverNumber: number,
|
||||||
|
year?: number,
|
||||||
|
): Promise<DriverSummary> {
|
||||||
|
const params = new URLSearchParams({ driver_number: String(driverNumber) })
|
||||||
|
if (year) params.set('year', String(year))
|
||||||
|
const res = await fetch(`/api/v1/driver/summary?${params.toString()}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
export async function fetchLiveState(): Promise<LiveStateResponse> {
|
||||||
const res = await fetch('/api/v1/live/state')
|
const res = await fetch('/api/v1/live/state')
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -126,3 +204,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
|||||||
body: JSON.stringify({ url: articleUrl }),
|
body: JSON.stringify({ url: articleUrl }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTelemetry(
|
||||||
|
sessionKey: number,
|
||||||
|
driverNumber: number,
|
||||||
|
): Promise<CarDataSample[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLapsComparison(
|
||||||
|
sessionKey: number,
|
||||||
|
drivers?: number[],
|
||||||
|
): Promise<LapsComparisonResponse> {
|
||||||
|
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||||
|
if (drivers?.length) {
|
||||||
|
params.set('drivers', drivers.join(','))
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|||||||
116
frontend/src/components/ChapterStrip.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { BookOpen } from 'lucide-react'
|
||||||
|
import type { Chapter } from '../types'
|
||||||
|
import {
|
||||||
|
activeChapterIndex,
|
||||||
|
chapterKindLabel,
|
||||||
|
chapterLapRange,
|
||||||
|
chapterStartScrub,
|
||||||
|
} from '../lib/chapters'
|
||||||
|
import { EmptyStateCard } from './EmptyStateCard'
|
||||||
|
import '../styles/chapters.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chapters: Chapter[]
|
||||||
|
scrubTime: number | null
|
||||||
|
tMin: number
|
||||||
|
tRange: number
|
||||||
|
tourActive: boolean
|
||||||
|
tourChapterIndex: number | null
|
||||||
|
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
||||||
|
selectedChapterIndex?: number | null
|
||||||
|
onChapterClick: (index: number, scrub: number) => void
|
||||||
|
onTourToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChapterStrip({
|
||||||
|
chapters,
|
||||||
|
scrubTime,
|
||||||
|
tMin,
|
||||||
|
tRange,
|
||||||
|
tourActive,
|
||||||
|
tourChapterIndex,
|
||||||
|
selectedChapterIndex = null,
|
||||||
|
onChapterClick,
|
||||||
|
onTourToggle,
|
||||||
|
}: Props) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||||
|
const highlightedIndex = tourActive
|
||||||
|
? tourChapterIndex
|
||||||
|
: (selectedChapterIndex ?? activeIndex)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightedIndex === null || !scrollRef.current) return
|
||||||
|
const card = scrollRef.current.querySelector<HTMLElement>(
|
||||||
|
`[data-testid="chapter-card-${highlightedIndex}"]`,
|
||||||
|
)
|
||||||
|
card?.scrollIntoView?.({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
|
}, [highlightedIndex])
|
||||||
|
|
||||||
|
if (chapters.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={BookOpen}
|
||||||
|
title="No story chapters"
|
||||||
|
hint="This session does not have enough race-control or position data to build narrative chapters."
|
||||||
|
testId="chapter-strip-empty"
|
||||||
|
className="chapter-strip-empty-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chapter-strip" data-testid="chapter-strip">
|
||||||
|
<div className="chapter-strip-header">
|
||||||
|
<span className="chapter-strip-title">Race chapters</span>
|
||||||
|
<div className="chapter-strip-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
|
||||||
|
onClick={onTourToggle}
|
||||||
|
aria-pressed={tourActive}
|
||||||
|
>
|
||||||
|
{tourActive ? 'Exit 90s' : '90s tour'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chapter-strip-scroll-wrap">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="chapter-strip-scroll"
|
||||||
|
role="list"
|
||||||
|
aria-label="Race story chapters"
|
||||||
|
>
|
||||||
|
{chapters.map((chapter, index) => {
|
||||||
|
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
||||||
|
const isActive = highlightedIndex === index
|
||||||
|
const headline = chapter.headline || chapter.title
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
|
||||||
|
onClick={() => onChapterClick(index, scrub)}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
data-testid={`chapter-card-${index}`}
|
||||||
|
>
|
||||||
|
<div className="chapter-card-top">
|
||||||
|
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
|
||||||
|
{chapterKindLabel(chapter.kind)}
|
||||||
|
</span>
|
||||||
|
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="chapter-headline">{headline}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
253
frontend/src/components/CompareView.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
||||||
|
import {
|
||||||
|
buildBestLapTraceSeries,
|
||||||
|
compareDriverOptions,
|
||||||
|
comparisonToDeltaSeries,
|
||||||
|
defaultCompareDriverNumbers,
|
||||||
|
formatPitLapsCaption,
|
||||||
|
} from '../lib/compare'
|
||||||
|
import type { Driver, EnrichedResult } from '../types'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import { DriverCell } from './DriverCell'
|
||||||
|
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
|
||||||
|
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
|
||||||
|
import '../styles/compare-view.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionKey: number
|
||||||
|
results: EnrichedResult[]
|
||||||
|
drivers: Driver[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
empty: boolean
|
||||||
|
emptyMessage: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading-state">loading…</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="error-box">{error.message}</div>
|
||||||
|
}
|
||||||
|
if (empty) {
|
||||||
|
return <div className="missing-notice">{emptyMessage}</div>
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompareView({ sessionKey, results, drivers }: Props) {
|
||||||
|
const driverOptions = useMemo(
|
||||||
|
() => compareDriverOptions(drivers, results),
|
||||||
|
[drivers, results],
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialPair = useMemo(
|
||||||
|
() => defaultCompareDriverNumbers(results, drivers),
|
||||||
|
[results, drivers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const previousSessionKey = useRef(sessionKey)
|
||||||
|
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
||||||
|
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousSessionKey.current !== sessionKey) {
|
||||||
|
previousSessionKey.current = sessionKey
|
||||||
|
setDriverA(initialPair?.[0] ?? null)
|
||||||
|
setDriverB(initialPair?.[1] ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driverA != null && driverB != null) return
|
||||||
|
if (!initialPair) return
|
||||||
|
setDriverA(initialPair[0])
|
||||||
|
setDriverB(initialPair[1])
|
||||||
|
}, [sessionKey, initialPair, driverA, driverB])
|
||||||
|
|
||||||
|
const pair = useMemo((): [number, number] | null => {
|
||||||
|
if (driverA == null || driverB == null || driverA === driverB) return null
|
||||||
|
return [driverA, driverB]
|
||||||
|
}, [driverA, driverB])
|
||||||
|
|
||||||
|
const comparisonQuery = useQuery({
|
||||||
|
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
|
||||||
|
queryFn: () => fetchLapsComparison(sessionKey, pair!),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemetryAQuery = useQuery({
|
||||||
|
queryKey: ['telemetry', sessionKey, pair?.[0]],
|
||||||
|
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemetryBQuery = useQuery({
|
||||||
|
queryKey: ['telemetry', sessionKey, pair?.[1]],
|
||||||
|
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const comparison = comparisonQuery.data
|
||||||
|
const referenceLabel = useMemo(() => {
|
||||||
|
if (!pair) return undefined
|
||||||
|
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
|
||||||
|
const session = drivers.find((d) => d.driver_number === pair[0])
|
||||||
|
return meta?.name_acronym || session?.name_acronym
|
||||||
|
}, [pair, comparison, drivers])
|
||||||
|
|
||||||
|
const deltaSeries = useMemo(() => {
|
||||||
|
if (!comparison || !pair) return []
|
||||||
|
return comparisonToDeltaSeries(comparison, pair, drivers)
|
||||||
|
}, [comparison, pair, drivers])
|
||||||
|
|
||||||
|
const pitCaption = useMemo(() => {
|
||||||
|
if (!comparison || !pair) return null
|
||||||
|
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
|
||||||
|
}, [comparison, pair, drivers])
|
||||||
|
|
||||||
|
const traceSeries = useMemo(() => {
|
||||||
|
if (!pair || !comparison) return []
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const dn of pair) {
|
||||||
|
const comp = comparison.drivers.find((d) => d.driver_number === dn)
|
||||||
|
const session = drivers.find((d) => d.driver_number === dn)
|
||||||
|
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
|
||||||
|
const color = teamColor(comp?.team_colour || session?.team_colour)
|
||||||
|
|
||||||
|
const carData =
|
||||||
|
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
|
||||||
|
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
|
||||||
|
if (series) out.push(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
|
||||||
|
|
||||||
|
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
|
||||||
|
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
|
||||||
|
|
||||||
|
if (driverOptions.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="missing-notice" data-testid="compare-view-empty">
|
||||||
|
Need at least two drivers in this session to compare.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
|
||||||
|
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compare-view" data-testid="compare-view">
|
||||||
|
<div className="compare-pickers">
|
||||||
|
<div className="compare-picker">
|
||||||
|
<span className="compare-picker-label">Reference</span>
|
||||||
|
<select
|
||||||
|
className="compare-picker-select"
|
||||||
|
value={driverA ?? ''}
|
||||||
|
onChange={(e) => setDriverA(Number(e.target.value))}
|
||||||
|
data-testid="compare-picker-a"
|
||||||
|
>
|
||||||
|
{driverOptions.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · #{d.driver_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{driverAInfo && (
|
||||||
|
<DriverCell
|
||||||
|
acronym={driverAInfo.name_acronym}
|
||||||
|
number={driverAInfo.driver_number}
|
||||||
|
colour={driverAInfo.team_colour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-picker">
|
||||||
|
<span className="compare-picker-label">Challenger</span>
|
||||||
|
<select
|
||||||
|
className="compare-picker-select"
|
||||||
|
value={driverB ?? ''}
|
||||||
|
onChange={(e) => setDriverB(Number(e.target.value))}
|
||||||
|
data-testid="compare-picker-b"
|
||||||
|
>
|
||||||
|
{driverOptions.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · #{d.driver_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{driverBInfo && (
|
||||||
|
<DriverCell
|
||||||
|
acronym={driverBInfo.name_acronym}
|
||||||
|
number={driverBInfo.driver_number}
|
||||||
|
colour={driverBInfo.team_colour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{driverA === driverB && (
|
||||||
|
<div className="analysis-notice">
|
||||||
|
<strong>Pick two different drivers</strong> to run a comparison.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="compare-section" data-testid="compare-telemetry-section">
|
||||||
|
<div>
|
||||||
|
<div className="compare-section-title">Best lap telemetry</div>
|
||||||
|
<div className="compare-section-meta">
|
||||||
|
Speed, throttle, and brake traces for each driver's fastest lap
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionState
|
||||||
|
loading={telemetryLoading}
|
||||||
|
error={telemetryError instanceof Error ? telemetryError : null}
|
||||||
|
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
|
||||||
|
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
|
||||||
|
>
|
||||||
|
<TelemetryTraceChart series={traceSeries} />
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="compare-section" data-testid="compare-pace-section">
|
||||||
|
<div>
|
||||||
|
<div className="compare-section-title">Race pace</div>
|
||||||
|
<div className="compare-section-meta">
|
||||||
|
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
|
||||||
|
only where the reference lap is valid; gaps appear when the reference has no lap
|
||||||
|
time.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionState
|
||||||
|
loading={comparisonQuery.isLoading}
|
||||||
|
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
|
||||||
|
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
|
||||||
|
emptyMessage="No lap comparison data for the selected drivers."
|
||||||
|
>
|
||||||
|
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
|
||||||
|
{pitCaption && (
|
||||||
|
<p className="compare-pit-caption" data-testid="compare-pit-caption">
|
||||||
|
{pitCaption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/EmptyStateCard.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
hint: ReactNode
|
||||||
|
testId?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyStateCard({ icon: Icon, title, hint, testId, className = '' }: Props) {
|
||||||
|
return (
|
||||||
|
<div className={`empty-state ui-card ${className}`.trim()} data-testid={testId}>
|
||||||
|
<Icon size={32} className="empty-state-icon" aria-hidden />
|
||||||
|
<div className="empty-state-title">{title}</div>
|
||||||
|
<div className="empty-state-desc">{hint}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/components/Meaning.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import '../styles/meaning.css'
|
||||||
|
|
||||||
|
export interface MeaningProps {
|
||||||
|
value: ReactNode
|
||||||
|
meaning?: string | null
|
||||||
|
/** Long-form explanation for the native tooltip; falls back to meaning. */
|
||||||
|
title?: string | null
|
||||||
|
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Meaning({ value, meaning, title, tone }: MeaningProps) {
|
||||||
|
if (!meaning) {
|
||||||
|
return <>{value}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltip = title ?? meaning
|
||||||
|
const toneClass = tone ? `meaning-caption--${tone}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="meaning" title={tooltip}>
|
||||||
|
<span className="meaning-value">{value}</span>
|
||||||
|
<span className={`meaning-caption ${toneClass}`.trim()}>{meaning}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,33 +1,66 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Compass, FileText, Home, Trophy } from 'lucide-react'
|
||||||
|
|
||||||
|
const PRIMARY = [
|
||||||
|
{ to: '/', label: 'Weekend', icon: Home, exact: true },
|
||||||
|
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
|
||||||
|
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
|
||||||
|
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nav renders one primary navigation system per breakpoint:
|
||||||
|
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
|
||||||
|
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
|
||||||
|
* are hidden via CSS so the two are never both active at once.
|
||||||
|
*
|
||||||
|
* Admin is an operator utility, deliberately outside every Primary landmark — it
|
||||||
|
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
|
||||||
|
*/
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
return (
|
return (
|
||||||
<nav className="app-nav">
|
<>
|
||||||
<Link to="/" className="nav-logo">
|
<header className="app-nav">
|
||||||
box<em>-</em>box
|
<Link to="/" className="nav-logo">
|
||||||
</Link>
|
box<em>-</em>box
|
||||||
<div className="nav-links">
|
|
||||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
|
||||||
Command
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
<nav className="nav-links" aria-label="Primary">
|
||||||
Live
|
{PRIMARY.map(({ to, label, exact }) => (
|
||||||
</Link>
|
<Link
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
key={to}
|
||||||
Race Hub
|
to={to}
|
||||||
</Link>
|
activeProps={{ className: 'active' }}
|
||||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
activeOptions={exact ? { exact: true } : undefined}
|
||||||
Championship
|
>
|
||||||
</Link>
|
{label}
|
||||||
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
</Link>
|
||||||
Briefing
|
))}
|
||||||
</Link>
|
</nav>
|
||||||
</div>
|
<div className="nav-utility" role="toolbar" aria-label="Operator utilities">
|
||||||
<div className="nav-utility">
|
<Link
|
||||||
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
|
to="/admin"
|
||||||
Admin
|
className="nav-utility-link"
|
||||||
</Link>
|
activeProps={{ className: 'nav-utility-link active' }}
|
||||||
</div>
|
>
|
||||||
</nav>
|
Admin
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="app-bottom-nav" aria-label="Primary">
|
||||||
|
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className="bottom-nav-link"
|
||||||
|
activeProps={{ className: 'bottom-nav-link active' }}
|
||||||
|
activeOptions={exact ? { exact: true } : undefined}
|
||||||
|
>
|
||||||
|
<Icon size={18} aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchNews } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
} from '../lib/digest'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
const SOURCE_DISPLAY: Record<string, string> = {
|
const SOURCE_DISPLAY: Record<string, string> = {
|
||||||
'fia': 'FIA',
|
'fia': 'FIA',
|
||||||
@@ -14,14 +30,53 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PaddockBriefing() {
|
export function PaddockBriefing() {
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: news, isLoading, isError } = useQuery({
|
const { data: news, isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
queryFn: () => fetchNews(100),
|
queryFn: () => fetchNews(100),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', latestSeason],
|
||||||
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const hub = hubQuery.data
|
||||||
|
const tagged = useMemo(
|
||||||
|
() => tagItems(news ?? [], hub?.drivers ?? [], hub?.teams ?? []),
|
||||||
|
[news, hub],
|
||||||
|
)
|
||||||
|
|
||||||
|
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||||
|
const activeWindow = useMemo(
|
||||||
|
() => activeDigestWindow(windows, meetings, now),
|
||||||
|
[windows, meetings, now],
|
||||||
|
)
|
||||||
|
const sinceItems = useMemo(
|
||||||
|
() => itemsForWindow(tagged, activeWindow),
|
||||||
|
[tagged, activeWindow],
|
||||||
|
)
|
||||||
|
const sinceTags = useMemo(() => topTags(sinceItems, 4), [sinceItems])
|
||||||
|
const sinceLabel = sinceLastLabel(meetings, now)
|
||||||
|
|
||||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||||
const preview = news?.slice(0, 5) ?? []
|
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||||
@@ -37,6 +92,27 @@ export function PaddockBriefing() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{meetings.length > 0 && (
|
||||||
|
<div className="cc-brief-digest-meta" data-testid="cc-brief-digest-meta">
|
||||||
|
<span>Since {sinceLabel}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{sinceItems.length} items</span>
|
||||||
|
{sinceTags.length > 0 && (
|
||||||
|
<div className="cc-brief-digest-tags">
|
||||||
|
{sinceTags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.key}
|
||||||
|
className="cc-brief-tag"
|
||||||
|
style={{ borderColor: tagColour(tag.colour) }}
|
||||||
|
>
|
||||||
|
{tag.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
{isLoading && <div className="briefing-state loading-state">loading…</div>}
|
||||||
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
{isError && <div className="briefing-state error-box">Failed to load briefing</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { EnrichedResult, EnrichedGrid, PositionSample, Lap, Session } from '../types'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { LineChart } from 'lucide-react'
|
||||||
|
import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types'
|
||||||
|
import { fetchReplayFrames, fetchTrackOutline } from '../api'
|
||||||
|
import { ReplayTrackMap } from './ReplayTrackMap'
|
||||||
|
import { ChapterStrip } from './ChapterStrip'
|
||||||
|
import { EmptyStateCard } from './EmptyStateCard'
|
||||||
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
|
||||||
|
import {
|
||||||
|
chapterBandFill,
|
||||||
|
chapterEndScrub,
|
||||||
|
chapterStartScrub,
|
||||||
|
chapterTourDurations,
|
||||||
|
deCollideYPositions,
|
||||||
|
decimatedPositionLabels,
|
||||||
|
} from '../lib/chapters'
|
||||||
|
import { isReplayMapAvailable } from '../lib/replayMap'
|
||||||
|
import '../styles/race-story.css'
|
||||||
|
|
||||||
|
const CHAPTER_TOUR_MS = 90_000
|
||||||
|
const CHART_W = 640
|
||||||
|
const CHART_H = 180
|
||||||
|
const CHART_PL = 40
|
||||||
|
const CHART_PR = 48
|
||||||
|
const CHART_PT = 8
|
||||||
|
const CHART_PB = 20
|
||||||
|
|
||||||
|
interface ChartTiming {
|
||||||
|
tMin: number
|
||||||
|
tMax: number
|
||||||
|
tRange: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
@@ -12,38 +42,256 @@ interface Props {
|
|||||||
race_control?: any[]
|
race_control?: any[]
|
||||||
pit_stops?: any[]
|
pit_stops?: any[]
|
||||||
session?: Session
|
session?: Session
|
||||||
|
meeting?: Meeting
|
||||||
|
drivers?: Driver[]
|
||||||
|
chapters?: Chapter[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position samples are recorded for the entire session, including the pre-race
|
||||||
|
* grid period. Use the winning driver's laps to define the race window so
|
||||||
|
* that the x-axis starts at lights-out rather than at the earliest sample.
|
||||||
|
*/
|
||||||
|
function raceChartTiming(
|
||||||
|
positions: PositionSample[],
|
||||||
|
laps: Lap[],
|
||||||
|
results: EnrichedResult[],
|
||||||
|
): ChartTiming | null {
|
||||||
|
const positionTimes = positions
|
||||||
|
.map((position) => new Date(position.date).getTime())
|
||||||
|
.filter(Number.isFinite)
|
||||||
|
if (positionTimes.length === 0) return null
|
||||||
|
|
||||||
|
const fallbackMin = Math.min(...positionTimes)
|
||||||
|
const fallbackMax = Math.max(...positionTimes)
|
||||||
|
const fallback = {
|
||||||
|
tMin: fallbackMin,
|
||||||
|
tMax: fallbackMax,
|
||||||
|
tRange: Math.max(fallbackMax - fallbackMin, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner = results.find((result) => result.position === 1)
|
||||||
|
if (!winner) return fallback
|
||||||
|
|
||||||
|
const winnerLaps = laps
|
||||||
|
.filter((lap) => lap.driver_number === winner.driver_number && lap.lap_number > 0)
|
||||||
|
.map((lap) => ({ ...lap, start: new Date(lap.date_start).getTime() }))
|
||||||
|
.filter((lap) => Number.isFinite(lap.start))
|
||||||
|
.sort((a, b) => a.lap_number - b.lap_number)
|
||||||
|
if (winnerLaps.length === 0) return fallback
|
||||||
|
|
||||||
|
const firstLap = winnerLaps[0]
|
||||||
|
const lastLap = winnerLaps[winnerLaps.length - 1]
|
||||||
|
const finalLapDuration = lastLap.lap_duration ?? 0
|
||||||
|
const tMax = lastLap.start + (finalLapDuration > 0 ? finalLapDuration * 1000 : 0)
|
||||||
|
if (tMax <= firstLap.start) return fallback
|
||||||
|
|
||||||
|
return { tMin: firstLap.start, tMax, tRange: tMax - firstLap.start }
|
||||||
|
}
|
||||||
|
|
||||||
export function RaceStoryCanvas({ data }: Props) {
|
export function RaceStoryCanvas({ data }: Props) {
|
||||||
const { results, starting_grid: grid, positions, datasets, race_control = [], pit_stops = [], laps = [] } = data
|
const {
|
||||||
|
results,
|
||||||
|
starting_grid: grid,
|
||||||
|
positions,
|
||||||
|
datasets,
|
||||||
|
race_control = [],
|
||||||
|
pit_stops = [],
|
||||||
|
laps = [],
|
||||||
|
session,
|
||||||
|
meeting,
|
||||||
|
drivers = [],
|
||||||
|
chapters = [],
|
||||||
|
} = data
|
||||||
const hasPositions = datasets['positions']?.status === 'available'
|
const hasPositions = datasets['positions']?.status === 'available'
|
||||||
|
|
||||||
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
const [scrubTime, setScrubTime] = useState<number | null>(null)
|
||||||
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
const [hoverDriver, setHoverDriver] = useState<number | null>(null)
|
||||||
|
const [mapOpen, setMapOpen] = useState(false)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||||
|
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||||
|
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
||||||
|
const [selectedChapterIndex, setSelectedChapterIndex] = useState<number | null>(null)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
|
||||||
|
|
||||||
// Position Evolution Chart Logic
|
const chartTiming = useMemo(() => raceChartTiming(positions, laps, results), [laps, positions, results])
|
||||||
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
|
const hasChartData = hasPositions && chartTiming !== null
|
||||||
const hasChartData = hasPositions && allTimes.length > 0
|
const circuitKey = session?.circuit_key ?? meeting?.circuit_key ?? 0
|
||||||
|
const outlineYear = meeting?.year ?? (session?.date_start ? new Date(session.date_start).getFullYear() : 0)
|
||||||
|
const canProbeMap = Boolean(session?.session_key) && circuitKey > 0 && outlineYear > 0
|
||||||
|
|
||||||
|
const replayQuery = useQuery({
|
||||||
|
queryKey: ['replay-frames', session?.session_key, 5000],
|
||||||
|
queryFn: () => fetchReplayFrames(session!.session_key, 5000),
|
||||||
|
enabled: canProbeMap,
|
||||||
|
})
|
||||||
|
const outlineQuery = useQuery({
|
||||||
|
queryKey: ['track-outline', circuitKey, outlineYear],
|
||||||
|
queryFn: () => fetchTrackOutline(circuitKey, outlineYear),
|
||||||
|
enabled: canProbeMap,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapProbeSettled = !canProbeMap || (!replayQuery.isLoading && !outlineQuery.isLoading)
|
||||||
|
const mapAvailable = useMemo(
|
||||||
|
() =>
|
||||||
|
canProbeMap &&
|
||||||
|
isReplayMapAvailable(
|
||||||
|
replayQuery.data,
|
||||||
|
outlineQuery.data,
|
||||||
|
replayQuery.isError || outlineQuery.isError,
|
||||||
|
),
|
||||||
|
[canProbeMap, outlineQuery.data, outlineQuery.isError, replayQuery.data, replayQuery.isError],
|
||||||
|
)
|
||||||
|
const showMapPanel = mapOpen && mapAvailable
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapAvailable && mapOpen) {
|
||||||
|
setMapOpen(false)
|
||||||
|
}
|
||||||
|
}, [mapAvailable, mapOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !chartTiming) return
|
||||||
|
|
||||||
|
let frame = 0
|
||||||
|
let last = performance.now()
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const delta = now - last
|
||||||
|
last = now
|
||||||
|
setScrubTime((current) => {
|
||||||
|
const next = Math.min(1, (current ?? 0) + (delta * playbackSpeed) / chartTiming.tRange)
|
||||||
|
if (next >= 1) {
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [chartTiming, isPlaying, playbackSpeed])
|
||||||
|
|
||||||
|
const stopChapterTour = () => {
|
||||||
|
setChapterTourActive(false)
|
||||||
|
setTourChapterIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpToChapter = (index: number, scrub: number) => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
stopChapterTour()
|
||||||
|
setSelectedChapterIndex(index)
|
||||||
|
setScrubTime(scrub)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearChapterSelection = () => {
|
||||||
|
setSelectedChapterIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChapterTour = () => {
|
||||||
|
if (chapterTourActive) {
|
||||||
|
stopChapterTour()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!chartTiming || chapters.length === 0) return
|
||||||
|
setIsPlaying(false)
|
||||||
|
setSelectedChapterIndex(null)
|
||||||
|
setChapterTourActive(true)
|
||||||
|
setTourChapterIndex(0)
|
||||||
|
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||||
|
setScrubTime(startScrub)
|
||||||
|
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||||
|
tourRef.current = {
|
||||||
|
chapterIndex: 0,
|
||||||
|
startedAt: performance.now(),
|
||||||
|
durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||||
|
startScrub,
|
||||||
|
endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chapterTourActive || !chartTiming || chapters.length === 0) return
|
||||||
|
|
||||||
|
let frame = 0
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const state = tourRef.current
|
||||||
|
const elapsed = now - state.startedAt
|
||||||
|
const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1))
|
||||||
|
const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress
|
||||||
|
setScrubTime(scrub)
|
||||||
|
setTourChapterIndex(state.chapterIndex)
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
const nextIndex = state.chapterIndex + 1
|
||||||
|
if (nextIndex >= chapters.length) {
|
||||||
|
stopChapterTour()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS)
|
||||||
|
const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0
|
||||||
|
const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub
|
||||||
|
tourRef.current = {
|
||||||
|
chapterIndex: nextIndex,
|
||||||
|
startedAt: now,
|
||||||
|
durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length,
|
||||||
|
startScrub,
|
||||||
|
endScrub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [chapterTourActive, chartTiming, chapters])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chapterTourActive) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
stopChapterTour()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [chapterTourActive])
|
||||||
|
|
||||||
|
const replayTMs = useMemo(() => {
|
||||||
|
const replay = replayQuery.data
|
||||||
|
const frames = replay?.frames ?? []
|
||||||
|
if (frames.length === 0) return 0
|
||||||
|
const lastFrameT = frames[frames.length - 1].t
|
||||||
|
const progress = scrubTime ?? 0
|
||||||
|
if (!chartTiming || !replay?.start_time) {
|
||||||
|
return Math.max(0, Math.min(lastFrameT, Math.round(progress * lastFrameT)))
|
||||||
|
}
|
||||||
|
const replayStart = new Date(replay.start_time).getTime()
|
||||||
|
const chartTime = chartTiming.tMin + progress * chartTiming.tRange
|
||||||
|
return Math.max(0, Math.min(lastFrameT, Math.round(chartTime - replayStart)))
|
||||||
|
}, [chartTiming, replayQuery.data, scrubTime])
|
||||||
|
|
||||||
let chartContent = null
|
let chartContent = null
|
||||||
let displayResults = results
|
let displayResults = results
|
||||||
|
|
||||||
if (hasChartData) {
|
if (hasChartData && chartTiming) {
|
||||||
const tMin = new Date(allTimes[0]).getTime()
|
const { tMin, tRange } = chartTiming
|
||||||
const tMax = new Date(allTimes[allTimes.length - 1]).getTime()
|
const normaliseTime = (time: number) => Math.max(0, Math.min(1, (time - tMin) / tRange))
|
||||||
const tRange = Math.max(tMax - tMin, 1)
|
|
||||||
|
|
||||||
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
const byDriver = new Map<number, Array<{ t: number; pos: number }>>()
|
||||||
for (const p of positions) {
|
for (const p of positions) {
|
||||||
|
const time = new Date(p.date).getTime()
|
||||||
|
if (!Number.isFinite(time)) continue
|
||||||
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
if (!byDriver.has(p.driver_number)) byDriver.set(p.driver_number, [])
|
||||||
byDriver.get(p.driver_number)!.push({
|
byDriver.get(p.driver_number)!.push({
|
||||||
t: (new Date(p.date).getTime() - tMin) / tRange,
|
t: normaliseTime(time),
|
||||||
pos: p.position,
|
pos: p.position,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const dnfSet = new Set(results.filter(r => r.dnf || r.dns || r.dsq).map(r => r.driver_number))
|
const dnfSet = new Set(results.filter((r) => r.dnf || r.dns || r.dsq).map((r) => r.driver_number))
|
||||||
for (const [dNum, samples] of byDriver.entries()) {
|
for (const [dNum, samples] of byDriver.entries()) {
|
||||||
samples.sort((a, b) => a.t - b.t)
|
samples.sort((a, b) => a.t - b.t)
|
||||||
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
if (samples.length > 0 && !dnfSet.has(dNum)) {
|
||||||
@@ -51,16 +299,16 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInterpPos = (samples: {t: number, pos: number}[], t: number) => {
|
const getInterpPos = (samples: { t: number; pos: number }[], t: number) => {
|
||||||
if (!samples || samples.length === 0) return null
|
if (!samples || samples.length === 0) return null
|
||||||
if (t <= samples[0].t) return samples[0].pos
|
if (t <= samples[0].t) return samples[0].pos
|
||||||
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
if (t >= samples[samples.length - 1].t) return samples[samples.length - 1].pos
|
||||||
for (let i = 0; i < samples.length - 1; i++) {
|
for (let i = 0; i < samples.length - 1; i++) {
|
||||||
if (samples[i].t <= t && samples[i+1].t >= t) {
|
if (samples[i].t <= t && samples[i + 1].t >= t) {
|
||||||
const dt = samples[i+1].t - samples[i].t
|
const dt = samples[i + 1].t - samples[i].t
|
||||||
if (dt === 0) return samples[i].pos
|
if (dt === 0) return samples[i].pos
|
||||||
const frac = (t - samples[i].t) / dt
|
const frac = (t - samples[i].t) / dt
|
||||||
return samples[i].pos + (samples[i+1].pos - samples[i].pos) * frac
|
return samples[i].pos + (samples[i + 1].pos - samples[i].pos) * frac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -84,34 +332,37 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
const maxPos = Math.max(...positions.map((p) => p.position), results.length, 2)
|
||||||
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
|
const colorByDriver = new Map(results.map((r) => [r.driver_number, r.team_colour]))
|
||||||
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
const acronymByDriver = new Map(results.map((r) => [r.driver_number, r.name_acronym]))
|
||||||
|
const positionLabels = decimatedPositionLabels(maxPos)
|
||||||
|
|
||||||
const W = 640
|
const W = CHART_W
|
||||||
const H = 180
|
const H = CHART_H
|
||||||
const PL = 40
|
const PL = CHART_PL
|
||||||
const PR = 48
|
const PR = CHART_PR
|
||||||
const PT = 8
|
const PT = CHART_PT
|
||||||
const PB = 20
|
const PB = CHART_PB
|
||||||
const plotW = W - PL - PR
|
const plotW = W - PL - PR
|
||||||
const plotH = H - PT - PB
|
const plotH = H - PT - PB
|
||||||
|
|
||||||
const toX = (t: number) => PL + t * plotW
|
const toX = (t: number) => PL + t * plotW
|
||||||
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
const toY = (pos: number) => PT + ((pos - 1) / Math.max(maxPos - 1, 1)) * plotH
|
||||||
|
|
||||||
const winner = results.find(r => r.position === 1)
|
const winner = results.find((r) => r.position === 1)
|
||||||
const winnerLaps = winner ? laps.filter(l => l.driver_number === winner.driver_number) : []
|
const winnerLaps = winner ? laps.filter((l) => l.driver_number === winner.driver_number) : []
|
||||||
const lapTicks: { lap: number, t: number }[] = []
|
const lapTicks: { lap: number; t: number }[] = []
|
||||||
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
const lapInterval = winnerLaps.length < 30 ? 5 : 10
|
||||||
|
|
||||||
for (const lap of winnerLaps) {
|
for (let i = 0; i < winnerLaps.length; i++) {
|
||||||
|
const lap = winnerLaps[i]
|
||||||
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
if (lap.lap_number > 0 && lap.lap_number % lapInterval === 0) {
|
||||||
const t = (new Date(lap.date_start).getTime() - tMin) / tRange
|
const lapStart = new Date(lap.date_start).getTime()
|
||||||
if (t >= 0 && t <= 1) {
|
const nextLapStart = winnerLaps[i + 1] ? new Date(winnerLaps[i + 1].date_start).getTime() : NaN
|
||||||
lapTicks.push({ lap: lap.lap_number, t })
|
const lapEnd = lap.lap_duration && lap.lap_duration > 0
|
||||||
}
|
? lapStart + lap.lap_duration * 1000
|
||||||
|
: nextLapStart
|
||||||
|
if (Number.isFinite(lapEnd)) lapTicks.push({ lap: lap.lap_number, t: normaliseTime(lapEnd) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety Car / VSC periods
|
|
||||||
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
const scPeriods: { start: number; end: number | null; type: 'SC' | 'VSC' }[] = []
|
||||||
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
let activeSC: { start: number; type: 'SC' | 'VSC' } | null = null
|
||||||
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
const rc = [...race_control].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
@@ -136,23 +387,73 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
scPeriods.push({ start: activeSC.start, end: null, type: activeSC.type })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chapterBands = chapters
|
||||||
|
.map((chapter, index) => {
|
||||||
|
const startT = chapterStartScrub(chapter, tMin, tRange)
|
||||||
|
const endT = chapterEndScrub(chapter, tMin, tRange)
|
||||||
|
if (startT === null) return null
|
||||||
|
const end = endT ?? startT
|
||||||
|
return {
|
||||||
|
key: `${chapter.kind}-${index}`,
|
||||||
|
start: Math.min(startT, end),
|
||||||
|
end: Math.max(startT, end),
|
||||||
|
fill: chapterBandFill(chapter.kind),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((band): band is NonNullable<typeof band> => band !== null)
|
||||||
|
|
||||||
|
const labelCandidates = Array.from(byDriver.entries())
|
||||||
|
.map(([dNum, samples]) => {
|
||||||
|
const last = samples[samples.length - 1]
|
||||||
|
if (!last) return null
|
||||||
|
return { key: dNum, y: toY(last.pos) }
|
||||||
|
})
|
||||||
|
.filter((item): item is { key: number; y: number } => item !== null)
|
||||||
|
const labelYByDriver = deCollideYPositions(labelCandidates, 12)
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
if (!svgRef.current) return
|
if (!svgRef.current) return
|
||||||
const rect = svgRef.current.getBoundingClientRect()
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
const x = e.clientX - rect.left
|
if (rect.width <= 0) return
|
||||||
|
// Pointer coordinates are CSS pixels; convert them to the SVG viewBox
|
||||||
|
// before comparing with the fixed chart margins and plot width.
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||||
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
const t = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
setScrubTime(t)
|
setScrubTime(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
chartContent = (
|
chartContent = (
|
||||||
<div className="rs-chart-container scroll-x" data-testid="position-chart">
|
<div
|
||||||
|
className={`rs-chart-container scroll-x${showMapPanel ? '' : ' rs-chart-container--full'}`}
|
||||||
|
data-testid="position-chart"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
|
className="rs-position-chart-svg"
|
||||||
viewBox={`0 0 ${W} ${H}`}
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
style={{ width: '100%', minWidth: 280, maxWidth: W, display: 'block' }}
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Position evolution chart"
|
aria-label="Position evolution chart"
|
||||||
>
|
>
|
||||||
|
{chapterBands.map((band) => {
|
||||||
|
const x1 = toX(Math.max(0, band.start))
|
||||||
|
const x2 = toX(Math.min(1, band.end))
|
||||||
|
if (x2 <= PL || x1 >= W - PR) return null
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={band.key}
|
||||||
|
x={x1}
|
||||||
|
y={PT}
|
||||||
|
width={Math.max(0, x2 - x1)}
|
||||||
|
height={plotH}
|
||||||
|
fill={band.fill}
|
||||||
|
data-testid="chapter-band"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{scPeriods.map((sc, i) => {
|
{scPeriods.map((sc, i) => {
|
||||||
const startT = (sc.start - tMin) / tRange
|
const startT = (sc.start - tMin) / tRange
|
||||||
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
const endT = sc.end ? (sc.end - tMin) / tRange : 1
|
||||||
@@ -171,33 +472,41 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{Array.from({ length: maxPos }, (_, i) => i + 1).map((pos) => (
|
{positionLabels.map((pos) => (
|
||||||
<g key={pos}>
|
<g key={pos}>
|
||||||
<line
|
<line
|
||||||
x1={PL}
|
x1={PL}
|
||||||
x2={W - PR}
|
x2={W - PR}
|
||||||
y1={toY(pos)}
|
y1={toY(pos)}
|
||||||
y2={toY(pos)}
|
y2={toY(pos)}
|
||||||
stroke="var(--border)"
|
className="rs-chart-grid-line"
|
||||||
strokeWidth={0.5}
|
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={PL - 4}
|
x={PL - 4}
|
||||||
y={toY(pos) + 4}
|
y={toY(pos) + 4}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
fill="var(--text-3)"
|
className="rs-chart-axis-label"
|
||||||
fontSize={8}
|
|
||||||
fontFamily="var(--f-mono)"
|
|
||||||
>
|
>
|
||||||
P{pos}
|
P{pos}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{lapTicks.map(tick => (
|
{lapTicks.map((tick) => (
|
||||||
<g key={`lap-${tick.lap}`}>
|
<g key={`lap-${tick.lap}`}>
|
||||||
<line x1={toX(tick.t)} x2={toX(tick.t)} y1={H - PB} y2={H - PB + 4} stroke="var(--border)" strokeWidth={1} />
|
<line
|
||||||
<text x={toX(tick.t)} y={H - PB + 14} textAnchor="middle" fill="var(--text-3)" fontSize={9} fontFamily="var(--f-mono)">
|
x1={toX(tick.t)}
|
||||||
|
x2={toX(tick.t)}
|
||||||
|
y1={H - PB}
|
||||||
|
y2={H - PB + 4}
|
||||||
|
className="rs-chart-lap-tick"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={toX(tick.t)}
|
||||||
|
y={H - PB + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="rs-chart-axis-label"
|
||||||
|
>
|
||||||
L{tick.lap}
|
L{tick.lap}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -210,8 +519,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const last = samples[samples.length - 1]
|
const last = samples[samples.length - 1]
|
||||||
const isHovered = hoverDriver === dNum
|
const isHovered = hoverDriver === dNum
|
||||||
const isFaded = hoverDriver !== null && !isHovered
|
const isFaded = hoverDriver !== null && !isHovered
|
||||||
|
const labelY = last ? (labelYByDriver.get(dNum) ?? toY(last.pos)) : 0
|
||||||
|
|
||||||
const driverPits = pit_stops.filter(p => p.driver_number === dNum)
|
const driverPits = pit_stops.filter((p) => p.driver_number === dNum)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -232,8 +542,9 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{driverPits.map((p, i) => {
|
{driverPits.map((p, i) => {
|
||||||
const t = (new Date(p.date).getTime() - tMin) / tRange
|
const time = new Date(p.date).getTime()
|
||||||
if (t < 0 || t > 1) return null
|
if (!Number.isFinite(time) || time < tMin || time > tMin + tRange) return null
|
||||||
|
const t = normaliseTime(time)
|
||||||
const pos = getInterpPos(samples, t)
|
const pos = getInterpPos(samples, t)
|
||||||
if (pos === null) return null
|
if (pos === null) return null
|
||||||
return (
|
return (
|
||||||
@@ -253,7 +564,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
{last && (
|
{last && (
|
||||||
<text
|
<text
|
||||||
x={toX(last.t) + 6}
|
x={toX(last.t) + 6}
|
||||||
y={toY(last.pos) + 4}
|
y={labelY + 4}
|
||||||
fill={color}
|
fill={color}
|
||||||
fontSize={isHovered ? 11 : 9}
|
fontSize={isHovered ? 11 : 9}
|
||||||
fontFamily="var(--f-mono)"
|
fontFamily="var(--f-mono)"
|
||||||
@@ -287,25 +598,110 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
width={plotW}
|
width={plotW}
|
||||||
height={plotH}
|
height={plotH}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
|
data-testid="position-chart-interaction"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => setScrubTime(null)}
|
onPointerLeave={() => {
|
||||||
|
if (!isPlaying) {
|
||||||
|
clearChapterSelection()
|
||||||
|
setScrubTime(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<div className="rs-segmented-control" aria-label="Race replay controls" data-testid="replay-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rs-segment ${isPlaying ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setScrubTime((current) => current ?? 0)
|
||||||
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
|
setIsPlaying((current) => !current)
|
||||||
|
}}
|
||||||
|
aria-pressed={isPlaying}
|
||||||
|
>
|
||||||
|
{isPlaying ? 'Pause' : 'Play'}
|
||||||
|
</button>
|
||||||
|
<span className="rs-segment-divider" aria-hidden />
|
||||||
|
{[1, 10, 30].map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
type="button"
|
||||||
|
className={`rs-segment ${playbackSpeed === speed ? 'active' : ''}`}
|
||||||
|
onClick={() => setPlaybackSpeed(speed)}
|
||||||
|
aria-pressed={playbackSpeed === speed}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{mapProbeSettled && mapAvailable && (
|
||||||
|
<>
|
||||||
|
<span className="rs-segment-divider" aria-hidden />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rs-segment ${mapOpen ? 'active' : ''}`}
|
||||||
|
onClick={() => setMapOpen((current) => !current)}
|
||||||
|
aria-pressed={mapOpen}
|
||||||
|
data-testid="replay-map-toggle"
|
||||||
|
>
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="race-story-canvas">
|
<div className="race-story-canvas">
|
||||||
{hasChartData ? (
|
{hasChartData && chartTiming && chapters.length > 0 && (
|
||||||
chartContent
|
<ChapterStrip
|
||||||
) : (
|
chapters={chapters}
|
||||||
<div className="analysis-notice">
|
scrubTime={scrubTime}
|
||||||
<strong>Lap-by-lap positions not available.</strong> This session does not
|
tMin={chartTiming.tMin}
|
||||||
have ingested position samples in <code>/api/v1/race-hub</code>.
|
tRange={chartTiming.tRange}
|
||||||
</div>
|
tourActive={chapterTourActive}
|
||||||
|
tourChapterIndex={tourChapterIndex}
|
||||||
|
selectedChapterIndex={selectedChapterIndex}
|
||||||
|
onChapterClick={jumpToChapter}
|
||||||
|
onTourToggle={toggleChapterTour}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className={`rs-replay-shell${showMapPanel ? ' rs-replay-shell--split' : ''}`}>
|
||||||
|
<div className="rs-replay-main">
|
||||||
|
{hasChartData ? (
|
||||||
|
chartContent
|
||||||
|
) : (
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={LineChart}
|
||||||
|
title="Lap-by-lap positions not available"
|
||||||
|
hint={
|
||||||
|
<>
|
||||||
|
This session does not have ingested position samples in{' '}
|
||||||
|
<code>/api/v1/race-hub</code>.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
testId="race-story-no-positions"
|
||||||
|
className="race-story-empty-card"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showMapPanel && (
|
||||||
|
<div className="rs-replay-map-slot" data-testid="replay-map-slot">
|
||||||
|
<ReplayTrackMap
|
||||||
|
outline={outlineQuery.data}
|
||||||
|
replay={replayQuery.data}
|
||||||
|
tMs={replayTMs}
|
||||||
|
drivers={drivers}
|
||||||
|
results={results}
|
||||||
|
loading={outlineQuery.isLoading || replayQuery.isLoading}
|
||||||
|
error={outlineQuery.isError || replayQuery.isError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{displayResults.length > 0 && (
|
{displayResults.length > 0 && (
|
||||||
<div className="rs-field-list">
|
<div className="rs-field-list">
|
||||||
@@ -313,7 +709,8 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
const gridPos = grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
|
||||||
const currentPos = scrubTime !== null ? i + 1 : r.position
|
const currentPos = scrubTime !== null ? i + 1 : r.position
|
||||||
const isWinner = i === 0 && r.position === 1
|
const isWinner = i === 0 && r.position === 1
|
||||||
const pClass = currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
const pClass =
|
||||||
|
currentPos === 1 ? 'rs-pos-p1' : currentPos === 2 ? 'rs-pos-p2' : currentPos === 3 ? 'rs-pos-p3' : ''
|
||||||
|
|
||||||
let currentPoints: number | string = r.points
|
let currentPoints: number | string = r.points
|
||||||
if (scrubTime !== null) {
|
if (scrubTime !== null) {
|
||||||
@@ -330,9 +727,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
onMouseLeave={() => setHoverDriver(null)}
|
onMouseLeave={() => setHoverDriver(null)}
|
||||||
>
|
>
|
||||||
<div className="rs-driver-left">
|
<div className="rs-driver-left">
|
||||||
<div className={`rs-pos-col ${pClass}`}>
|
<div className={`rs-pos-col ${pClass}`}>{currentPos}</div>
|
||||||
{currentPos}
|
|
||||||
</div>
|
|
||||||
<div className="rs-driver-cell">
|
<div className="rs-driver-cell">
|
||||||
<div
|
<div
|
||||||
className="rs-driver-color"
|
className="rs-driver-color"
|
||||||
|
|||||||
159
frontend/src/components/ReplayTrackMap.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Loader2, MapPin, Satellite } from 'lucide-react'
|
||||||
|
import type { Driver, EnrichedResult, ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
|
import { interpolateReplayCars, replayCarToSvg } from '../lib/replay'
|
||||||
|
import { EmptyStateCard } from './EmptyStateCard'
|
||||||
|
import '../styles/replay-map.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
outline?: TrackOutline | null
|
||||||
|
replay?: ReplayFramesResponse | null
|
||||||
|
tMs: number
|
||||||
|
drivers: Driver[]
|
||||||
|
results: EnrichedResult[]
|
||||||
|
loading?: boolean
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplayTrackMap({
|
||||||
|
outline,
|
||||||
|
replay,
|
||||||
|
tMs,
|
||||||
|
drivers,
|
||||||
|
results,
|
||||||
|
loading = false,
|
||||||
|
error = false,
|
||||||
|
}: Props) {
|
||||||
|
const [pinned, setPinned] = useState<string | null>(null)
|
||||||
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
const driverInfo = useMemo(() => {
|
||||||
|
const info = new Map<string, { label: string; color: string }>()
|
||||||
|
for (const driver of drivers) {
|
||||||
|
info.set(String(driver.driver_number), {
|
||||||
|
label: driver.name_acronym || String(driver.driver_number),
|
||||||
|
color: normalizeColor(driver.team_colour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const result of results) {
|
||||||
|
const key = String(result.driver_number)
|
||||||
|
if (!info.has(key)) {
|
||||||
|
info.set(key, {
|
||||||
|
label: result.name_acronym || key,
|
||||||
|
color: normalizeColor(result.team_colour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}, [drivers, results])
|
||||||
|
|
||||||
|
const cars = useMemo(() => {
|
||||||
|
if (!outline?.bounds || !replay?.frames?.length) return []
|
||||||
|
const positions = interpolateReplayCars(replay.frames, tMs)
|
||||||
|
return Object.entries(positions)
|
||||||
|
.map(([number, car]) => ({
|
||||||
|
number,
|
||||||
|
svg: replayCarToSvg(car, outline.bounds),
|
||||||
|
info: driverInfo.get(number),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(a.number) - Number(b.number))
|
||||||
|
}, [driverInfo, outline?.bounds, replay?.frames, tMs])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={Loader2}
|
||||||
|
title="Loading replay map"
|
||||||
|
hint="Fetching track outline and historical GPS frames."
|
||||||
|
testId="replay-map-loading"
|
||||||
|
className="replay-map-empty-card"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={Satellite}
|
||||||
|
title="Replay GPS unavailable"
|
||||||
|
hint="This session does not have ingested location samples for the replay map."
|
||||||
|
testId="replay-map-error"
|
||||||
|
className="replay-map-empty-card"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outline || !outlinePath) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={MapPin}
|
||||||
|
title="Track outline unavailable"
|
||||||
|
hint="Circuit GPS outline data is missing for this session."
|
||||||
|
testId="replay-map-no-outline"
|
||||||
|
className="replay-map-empty-card"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replay?.frames?.length || cars.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<EmptyStateCard
|
||||||
|
icon={Satellite}
|
||||||
|
title="Historical GPS unavailable"
|
||||||
|
hint="Fewer than two replay frames were returned for this session."
|
||||||
|
testId="replay-map-no-frames"
|
||||||
|
className="replay-map-empty-card"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="replay-map-panel" data-testid="replay-track-map">
|
||||||
|
<div className="replay-map-stage">
|
||||||
|
<svg className="replay-map-svg" viewBox="0 0 100 100" role="img" aria-label="Replay track map">
|
||||||
|
<path className="replay-map-outline-shadow" d={outlinePath} />
|
||||||
|
<path className="replay-map-outline" d={outlinePath} />
|
||||||
|
{cars.map((car) => {
|
||||||
|
const label = car.info?.label ?? car.number
|
||||||
|
const selected = pinned === car.number
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={car.number}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${label} replay position`}
|
||||||
|
className={`replay-car ${selected ? 'replay-car-pinned' : ''}`}
|
||||||
|
transform={`translate(${car.svg.x.toFixed(2)} ${car.svg.y.toFixed(2)})`}
|
||||||
|
onClick={() => setPinned(selected ? null : car.number)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setPinned(selected ? null : car.number)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>{label}</title>
|
||||||
|
<circle r="2.7" fill={car.info?.color ?? '#777777'} />
|
||||||
|
<text y="0.85">{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(color: string | undefined): string {
|
||||||
|
const raw = color?.trim()
|
||||||
|
if (!raw) return '#777777'
|
||||||
|
return raw.startsWith('#') ? raw : `#${raw}`
|
||||||
|
}
|
||||||
271
frontend/src/components/RivalryCompare.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import { gapSeries, h2hTally, lastRounds } from '../lib/rivalry'
|
||||||
|
import '../styles/rivalry.css'
|
||||||
|
|
||||||
|
const PAD_L = 48
|
||||||
|
const PAD_T = 16
|
||||||
|
const PLOT_W = 882
|
||||||
|
const PLOT_H = 316
|
||||||
|
const GAP_PLOT_H = 200
|
||||||
|
|
||||||
|
function fmtPts(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to ~7 evenly spaced round labels, always including the last round. */
|
||||||
|
function xTicks(n: number, labels: string[], x: (i: number) => number): { x: number; label: string }[] {
|
||||||
|
const step = Math.max(1, Math.ceil(n / 7))
|
||||||
|
const ticks: { x: number; label: string }[] = []
|
||||||
|
for (let i = 0; i < n; i += step) {
|
||||||
|
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||||
|
}
|
||||||
|
const last = labels[n - 1] ?? `R${n}`
|
||||||
|
if (ticks[ticks.length - 1]?.label !== last) {
|
||||||
|
ticks.push({ x: x(n - 1), label: last })
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
function polyline(values: number[], x: (i: number) => number, y: (v: number) => number): string {
|
||||||
|
return values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PickerProps {
|
||||||
|
id: 'a' | 'b'
|
||||||
|
label: string
|
||||||
|
drivers: ChampHubDriver[]
|
||||||
|
value: number
|
||||||
|
color: string
|
||||||
|
onChange: (driverNumber: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriverPicker({ id, label, drivers, value, color, onChange }: PickerProps) {
|
||||||
|
return (
|
||||||
|
<label className="rivalry-picker">
|
||||||
|
<span className="rivalry-picker-label">{label}</span>
|
||||||
|
<select
|
||||||
|
className="rivalry-picker-select"
|
||||||
|
style={{ borderLeft: `3px solid ${color}` }}
|
||||||
|
value={value}
|
||||||
|
data-testid={`rivalry-pick-${id}`}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · {d.full_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RivalryCompare({ hub }: { hub: ChampionshipHub }) {
|
||||||
|
const { drivers } = hub
|
||||||
|
const [pickA, setPickA] = useState<number | null>(null)
|
||||||
|
const [pickB, setPickB] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Default to the top two in the standings; fall back there if a picked
|
||||||
|
// driver disappears (e.g. season switch re-fetches the hub).
|
||||||
|
const a = drivers.find((d) => d.driver_number === pickA) ?? drivers[0]
|
||||||
|
const b = drivers.find((d) => d.driver_number === pickB) ?? drivers[1]
|
||||||
|
|
||||||
|
const tally = useMemo(
|
||||||
|
() => (a && b ? h2hTally(a.round_positions ?? [], b.round_positions ?? [], hub.round_labels) : null),
|
||||||
|
[a, b, hub.round_labels],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (drivers.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="champ-chart-empty" data-testid="champ-view-rivalry">
|
||||||
|
Need at least two drivers in the standings to compare a rivalry.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorA = teamColor(a.team_colour)
|
||||||
|
const colorB = teamColor(b.team_colour)
|
||||||
|
const sameTeam = a.team_name === b.team_name
|
||||||
|
|
||||||
|
const cumA = a.cumulative ?? []
|
||||||
|
const cumB = b.cumulative ?? []
|
||||||
|
const rounds = Math.min(cumA.length, cumB.length)
|
||||||
|
const gaps = gapSeries(cumA, cumB)
|
||||||
|
const lastGap = gaps[gaps.length - 1] ?? 0
|
||||||
|
const strip = tally ? lastRounds(tally, 5) : []
|
||||||
|
|
||||||
|
const pickers = (
|
||||||
|
<div className="rivalry-pickers">
|
||||||
|
<DriverPicker id="a" label="Driver A" drivers={drivers} value={a.driver_number} color={colorA} onChange={setPickA} />
|
||||||
|
<span className="rivalry-vs mono">vs</span>
|
||||||
|
<DriverPicker id="b" label="Driver B" drivers={drivers} value={b.driver_number} color={colorB} onChange={setPickB} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rounds === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||||
|
{pickers}
|
||||||
|
<div className="champ-chart-empty">
|
||||||
|
No completed rounds yet — the rivalry will appear after the first race.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Points race scales.
|
||||||
|
const peak = Math.max(...cumA.slice(0, rounds), ...cumB.slice(0, rounds), 1)
|
||||||
|
const maxY = Math.max(50, Math.ceil(peak / 50) * 50)
|
||||||
|
const px = (i: number) => (rounds <= 1 ? PAD_L : PAD_L + (i * PLOT_W) / (rounds - 1))
|
||||||
|
const py = (v: number) => PAD_T + PLOT_H - (v / maxY) * PLOT_H
|
||||||
|
const pyGrid = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: py(maxY * f), label: Math.round(maxY * f) }))
|
||||||
|
const pxGrid = xTicks(rounds, hub.round_labels, px)
|
||||||
|
|
||||||
|
// Gap scales: symmetric around zero.
|
||||||
|
const maxAbs = Math.max(10, Math.ceil(Math.max(...gaps.map(Math.abs), 1) / 10) * 10)
|
||||||
|
const gy = (v: number) => PAD_T + GAP_PLOT_H / 2 - (v / maxAbs) * (GAP_PLOT_H / 2)
|
||||||
|
const gyGrid = [maxAbs, 0, -maxAbs].map((v) => ({ y: gy(v), label: v > 0 ? `+${v}` : String(v) }))
|
||||||
|
const gxGrid = xTicks(rounds, hub.round_labels, px)
|
||||||
|
|
||||||
|
const gapLeader = lastGap === 0 ? null : lastGap > 0 ? a : b
|
||||||
|
const gapCaption = gapLeader
|
||||||
|
? `${gapLeader.name_acronym} leads by ${fmtPts(Math.abs(lastGap))} pts after ${hub.round_labels[rounds - 1] ?? `R${rounds}`}.`
|
||||||
|
: 'Dead level on points.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rivalry" data-testid="champ-view-rivalry">
|
||||||
|
{pickers}
|
||||||
|
|
||||||
|
<div className="rivalry-h2h" data-testid="rivalry-h2h">
|
||||||
|
<div className="rivalry-h2h-score">
|
||||||
|
<span className="rivalry-h2h-code mono" style={{ color: colorA }}>
|
||||||
|
{a.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="rivalry-h2h-num mono" data-testid="rivalry-h2h-num">
|
||||||
|
{tally ? `${tally.a}–${tally.b}` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="rivalry-h2h-code mono" style={{ color: colorB }}>
|
||||||
|
{b.name_acronym}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="rivalry-h2h-meta">
|
||||||
|
Race head-to-head · {tally?.rounds.length ?? 0} round{(tally?.rounds.length ?? 0) === 1 ? '' : 's'} counted
|
||||||
|
{tally && tally.skipped > 0 ? ` · ${tally.skipped} skipped` : ''}
|
||||||
|
</span>
|
||||||
|
{strip.length > 0 && (
|
||||||
|
<div className="rivalry-strip" data-testid="rivalry-strip">
|
||||||
|
<span className="rivalry-strip-label">Last {strip.length}</span>
|
||||||
|
{strip.map((r) => (
|
||||||
|
<span className="rivalry-chip" key={r.round} title={`${r.label}: ${a.name_acronym} P${r.posA} · ${b.name_acronym} P${r.posB}`}>
|
||||||
|
<span className="rivalry-chip-round mono">{r.label}</span>
|
||||||
|
<span className="rivalry-chip-winner mono" style={{ color: r.winner === 'a' ? colorA : colorB }}>
|
||||||
|
{r.winner === 'a' ? a.name_acronym : b.name_acronym}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rivalry-section" data-testid="rivalry-points-race">
|
||||||
|
<div className="champ-chart-head">
|
||||||
|
<span className="champ-chart-title mono">
|
||||||
|
Points race — {a.name_acronym} vs {b.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="champ-chart-meta">
|
||||||
|
Rounds 1–{rounds} · {hub.season}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="champ-chart">
|
||||||
|
<svg viewBox="0 0 1000 380" className="champ-chart-svg" preserveAspectRatio="none">
|
||||||
|
{pyGrid.map((g) => (
|
||||||
|
<g key={g.label}>
|
||||||
|
<line x1={PAD_L} y1={g.y} x2={930} y2={g.y} stroke="var(--border)" strokeWidth="1" />
|
||||||
|
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{pxGrid.map((g, i) => (
|
||||||
|
<text key={i} x={g.x} y={372} textAnchor="middle" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{[
|
||||||
|
{ slot: 'a', d: a, color: colorA, dash: '0', values: cumA.slice(0, rounds) },
|
||||||
|
{ slot: 'b', d: b, color: colorB, dash: sameTeam ? '5 4' : '0', values: cumB.slice(0, rounds) },
|
||||||
|
].map((s) => (
|
||||||
|
<g key={s.slot}>
|
||||||
|
<polyline
|
||||||
|
points={polyline(s.values, px, py)}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeDasharray={s.dash}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={px(s.values.length - 1)} cy={py(s.values[s.values.length - 1])} r="3" fill={s.color} />
|
||||||
|
<text
|
||||||
|
x={px(s.values.length - 1) + 8}
|
||||||
|
y={py(s.values[s.values.length - 1]) + 4}
|
||||||
|
fill={s.color}
|
||||||
|
className="champ-chart-label"
|
||||||
|
>
|
||||||
|
{s.d.name_acronym}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rivalry-section" data-testid="rivalry-gap">
|
||||||
|
<div className="champ-chart-head">
|
||||||
|
<span className="champ-chart-title mono">
|
||||||
|
Gap over season — {a.name_acronym} − {b.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="champ-chart-meta">{gapCaption}</span>
|
||||||
|
</div>
|
||||||
|
<div className="champ-chart">
|
||||||
|
<svg viewBox="0 0 1000 250" className="champ-chart-svg" preserveAspectRatio="none">
|
||||||
|
{gyGrid.map((g) => (
|
||||||
|
<g key={g.label}>
|
||||||
|
<line
|
||||||
|
x1={PAD_L}
|
||||||
|
y1={g.y}
|
||||||
|
x2={930}
|
||||||
|
y2={g.y}
|
||||||
|
stroke={g.label === '0' ? 'var(--border-2)' : 'var(--border)'}
|
||||||
|
strokeWidth={g.label === '0' ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
<text x={40} y={g.y + 4} textAnchor="end" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{gxGrid.map((g, i) => (
|
||||||
|
<text key={i} x={g.x} y={242} textAnchor="middle" className="champ-chart-axis">
|
||||||
|
{g.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<polyline
|
||||||
|
points={polyline(gaps, px, gy)}
|
||||||
|
fill="none"
|
||||||
|
stroke={colorA}
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={px(gaps.length - 1)} cy={gy(lastGap)} r="3" fill={colorA} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="rivalry-caption">
|
||||||
|
Above the zero line: {a.name_acronym} ahead. Below: {b.name_acronym} ahead.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,9 @@
|
|||||||
import type { EnrichedResult, Stint, PitStop } from '../types'
|
import type { EnrichedResult, Stint, PitStop } from '../types'
|
||||||
import { compareFinishPosition } from '../utils'
|
import { compareFinishPosition } from '../utils'
|
||||||
|
import {
|
||||||
const COMPOUND_COLORS: Record<string, string> = {
|
TyreStintTimeline,
|
||||||
SOFT: '#e8002d',
|
type StintTimelineRow,
|
||||||
MEDIUM: '#ffd600',
|
} from './charts/TyreStintTimeline'
|
||||||
HARD: '#e8e8e4',
|
|
||||||
INTERMEDIATE: '#39b54a',
|
|
||||||
WET: '#0067ff',
|
|
||||||
}
|
|
||||||
|
|
||||||
function compoundColor(c: string): string {
|
|
||||||
return COMPOUND_COLORS[c.toUpperCase()] ?? '#666'
|
|
||||||
}
|
|
||||||
|
|
||||||
function compoundInitial(c: string): string {
|
|
||||||
const abbr: Record<string, string> = {
|
|
||||||
SOFT: 'S',
|
|
||||||
MEDIUM: 'M',
|
|
||||||
HARD: 'H',
|
|
||||||
INTERMEDIATE: 'I',
|
|
||||||
WET: 'W',
|
|
||||||
}
|
|
||||||
return abbr[c.toUpperCase()] ?? c[0] ?? '?'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
results: EnrichedResult[]
|
results: EnrichedResult[]
|
||||||
@@ -89,147 +70,33 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) {
|
|||||||
const cmp = compareFinishPosition(a.position, b.position)
|
const cmp = compareFinishPosition(a.position, b.position)
|
||||||
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
|
return cmp !== 0 ? cmp : a.driver_number - b.driver_number
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalLaps = Math.max(
|
const totalLaps = Math.max(
|
||||||
...stints.map((s) => s.lap_end),
|
...stints.map((s) => s.lap_end),
|
||||||
...results.map((r) => r.number_of_laps),
|
...results.map((r) => r.number_of_laps),
|
||||||
1
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
const SVG_W = 640
|
const timelineRows: StintTimelineRow[] = sortedDrivers.map((driver) => ({
|
||||||
const LEFT = 48
|
label: driver.name_acronym || String(driver.driver_number),
|
||||||
const RIGHT = 12
|
color: driver.team_colour ? `#${driver.team_colour}` : '#888',
|
||||||
const ROW_H = 28
|
stints: stints
|
||||||
const BAR_H = 14
|
.filter((s) => s.driver_number === driver.driver_number)
|
||||||
const BAR_Y = 7
|
.map((s) => ({
|
||||||
const BAR_W = SVG_W - LEFT - RIGHT
|
compound: s.compound,
|
||||||
const SVG_H = sortedDrivers.length * ROW_H + 8
|
lapStart: s.lap_start,
|
||||||
|
lapEnd: s.lap_end,
|
||||||
const lapX = (lap: number) => LEFT + ((lap - 1) / totalLaps) * BAR_W
|
isNew: s.tyre_age_at_start === 0,
|
||||||
const stintW = (s: Stint) =>
|
})),
|
||||||
Math.max(2, ((s.lap_end - s.lap_start + 1) / totalLaps) * BAR_W)
|
pitStops: pit_stops
|
||||||
|
.filter((p) => p.driver_number === driver.driver_number)
|
||||||
const usedCompounds = [...new Set(stints.map((s) => s.compound.toUpperCase()))].filter(
|
.map((p) => p.lap_number)
|
||||||
(c) => c in COMPOUND_COLORS
|
.sort((a, b) => a - b),
|
||||||
)
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="strategy-chart">
|
<div data-testid="strategy-chart">
|
||||||
<div className="scroll-x">
|
<TyreStintTimeline rows={timelineRows} totalLaps={totalLaps} />
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
|
||||||
style={{ width: '100%', minWidth: 280, maxWidth: SVG_W, display: 'block' }}
|
|
||||||
role="img"
|
|
||||||
aria-label="Race strategy stint chart"
|
|
||||||
>
|
|
||||||
{sortedDrivers.map((driver, i) => {
|
|
||||||
const rowY = i * ROW_H
|
|
||||||
const color = driver.team_colour ? `#${driver.team_colour}` : '#888'
|
|
||||||
const dStints = stints.filter((s) => s.driver_number === driver.driver_number)
|
|
||||||
const dPits = pit_stops.filter((p) => p.driver_number === driver.driver_number)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={driver.driver_number} transform={`translate(0,${rowY})`}>
|
|
||||||
<text
|
|
||||||
x={LEFT - 5}
|
|
||||||
y={BAR_Y + BAR_H / 2 + 4}
|
|
||||||
textAnchor="end"
|
|
||||||
fill={color}
|
|
||||||
fontFamily="var(--f-mono)"
|
|
||||||
fontWeight={700}
|
|
||||||
fontSize={10}
|
|
||||||
>
|
|
||||||
{driver.name_acronym}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{dStints.map((stint, si) => {
|
|
||||||
const x = lapX(stint.lap_start)
|
|
||||||
const w = stintW(stint)
|
|
||||||
const fill = compoundColor(stint.compound)
|
|
||||||
return (
|
|
||||||
<g key={si}>
|
|
||||||
<rect x={x} y={BAR_Y} width={w} height={BAR_H} fill={fill} rx={1.5} />
|
|
||||||
{w > 18 && (
|
|
||||||
<text
|
|
||||||
x={x + w / 2}
|
|
||||||
y={BAR_Y + BAR_H / 2 + 4}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="#111"
|
|
||||||
fontFamily="var(--f-mono)"
|
|
||||||
fontWeight={700}
|
|
||||||
fontSize={8}
|
|
||||||
>
|
|
||||||
{compoundInitial(stint.compound)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{dPits.map((pit, pi) => {
|
|
||||||
const x = lapX(pit.lap_number)
|
|
||||||
return (
|
|
||||||
<line
|
|
||||||
key={pi}
|
|
||||||
x1={x}
|
|
||||||
x2={x}
|
|
||||||
y1={BAR_Y - 3}
|
|
||||||
y2={BAR_Y + BAR_H + 3}
|
|
||||||
stroke="var(--text)"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
opacity={0.7}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 'var(--s4)',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginTop: 'var(--s4)',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--text-3)',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{usedCompounds.map((c) => (
|
|
||||||
<span key={c} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
background: COMPOUND_COLORS[c],
|
|
||||||
borderRadius: 2,
|
|
||||||
display: 'inline-block',
|
|
||||||
border: c === 'HARD' ? '1px solid #555' : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{c.charAt(0) + c.slice(1).toLowerCase()}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{pit_stops.length > 0 && (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, marginLeft: 4 }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 1,
|
|
||||||
height: 12,
|
|
||||||
background: 'var(--text)',
|
|
||||||
display: 'inline-block',
|
|
||||||
opacity: 0.7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Pit stop
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span style={{ marginLeft: 'auto', fontFamily: 'var(--f-mono)', fontSize: 10 }}>
|
|
||||||
{totalLaps} laps
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type Tab =
|
|||||||
| 'overview'
|
| 'overview'
|
||||||
| 'race_story'
|
| 'race_story'
|
||||||
| 'strategy'
|
| 'strategy'
|
||||||
|
| 'compare'
|
||||||
| 'lap_data'
|
| 'lap_data'
|
||||||
| 'conditions'
|
| 'conditions'
|
||||||
| 'race_control'
|
| 'race_control'
|
||||||
@@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [
|
|||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'race_story', label: 'Race Story' },
|
{ id: 'race_story', label: 'Race Story' },
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
|
{ id: 'compare', label: 'Compare' },
|
||||||
{ id: 'lap_data', label: 'Lap Data' },
|
{ id: 'lap_data', label: 'Lap Data' },
|
||||||
{ id: 'conditions', label: 'Conditions' },
|
{ id: 'conditions', label: 'Conditions' },
|
||||||
{ id: 'race_control', label: 'Race Control' },
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
|
|||||||
55
frontend/src/components/TeammateH2H.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { teamColor } from '../utils'
|
||||||
|
import '../styles/h2h.css'
|
||||||
|
|
||||||
|
export interface TeammateH2HProps {
|
||||||
|
teamName: string
|
||||||
|
teamColour: string
|
||||||
|
driverATla: string
|
||||||
|
driverBTla: string
|
||||||
|
winsA: number
|
||||||
|
winsB: number
|
||||||
|
/** e.g. "+1" when more than two drivers share the team. */
|
||||||
|
extraNote?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeammateH2H({
|
||||||
|
teamName,
|
||||||
|
teamColour,
|
||||||
|
driverATla,
|
||||||
|
driverBTla,
|
||||||
|
winsA,
|
||||||
|
winsB,
|
||||||
|
extraNote,
|
||||||
|
}: TeammateH2HProps) {
|
||||||
|
const total = winsA + winsB || 1
|
||||||
|
const pctA = (winsA / total) * 100
|
||||||
|
const color = teamColor(teamColour)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h2h-row" data-testid="teammate-h2h">
|
||||||
|
<div className="h2h-team">
|
||||||
|
<span className="h2h-team-name">{teamName}</span>
|
||||||
|
{extraNote ? <span className="h2h-extra mono">{extraNote}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="h2h-bar-wrap">
|
||||||
|
<span className="h2h-tla h2h-tla--left mono">{driverATla}</span>
|
||||||
|
<div className="h2h-bar" data-testid="h2h-bar">
|
||||||
|
<span
|
||||||
|
className="h2h-bar-seg"
|
||||||
|
data-testid="h2h-bar-a"
|
||||||
|
style={{ width: `${pctA}%`, background: color }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="h2h-bar-seg h2h-bar-seg--muted"
|
||||||
|
data-testid="h2h-bar-b"
|
||||||
|
style={{ width: `${100 - pctA}%`, background: color }}
|
||||||
|
/>
|
||||||
|
<span className="h2h-score mono" data-testid="h2h-score">
|
||||||
|
{winsA}–{winsB}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="h2h-tla h2h-tla--right mono">{driverBTla}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
282
frontend/src/components/charts/DeltaTimeGraph.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
computeCumulativeDeltas,
|
||||||
|
deltaPolylineSegments,
|
||||||
|
formatDeltaSeconds,
|
||||||
|
type DeltaSeries,
|
||||||
|
type DriverDeltaResult,
|
||||||
|
} from '../../lib/delta'
|
||||||
|
import '../../styles/delta-graph.css'
|
||||||
|
|
||||||
|
export type { DeltaSeries }
|
||||||
|
|
||||||
|
export interface DeltaTimeGraphProps {
|
||||||
|
series: DeltaSeries[]
|
||||||
|
referenceLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 640
|
||||||
|
const H = 220
|
||||||
|
const PL = 44
|
||||||
|
const PR = 16
|
||||||
|
const PT = 12
|
||||||
|
const PB = 28
|
||||||
|
|
||||||
|
function lapCount(series: ReadonlyArray<DeltaSeries>): number {
|
||||||
|
return series.reduce((max, s) => Math.max(max, s.lapTimes.length), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function yExtent(drivers: ReadonlyArray<DriverDeltaResult>): { min: number; max: number } {
|
||||||
|
let min = 0
|
||||||
|
let max = 0
|
||||||
|
for (const driver of drivers) {
|
||||||
|
for (const delta of driver.deltas) {
|
||||||
|
if (delta === null) continue
|
||||||
|
min = Math.min(min, delta)
|
||||||
|
max = Math.max(max, delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (min === max) {
|
||||||
|
const pad = 1
|
||||||
|
return { min: min - pad, max: max + pad }
|
||||||
|
}
|
||||||
|
const span = max - min
|
||||||
|
const pad = span * 0.08
|
||||||
|
return { min: min - pad, max: max + pad }
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceYTicks(min: number, max: number): number[] {
|
||||||
|
const span = max - min
|
||||||
|
if (span <= 0) return [0]
|
||||||
|
const rough = span / 4
|
||||||
|
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)))
|
||||||
|
const step = Math.ceil(rough / magnitude) * magnitude
|
||||||
|
const ticks: number[] = []
|
||||||
|
const start = Math.ceil(min / step) * step
|
||||||
|
for (let v = start; v <= max + step * 0.01; v += step) {
|
||||||
|
ticks.push(Number(v.toFixed(6)))
|
||||||
|
}
|
||||||
|
if (!ticks.some((t) => Math.abs(t) < step * 0.01)) {
|
||||||
|
ticks.push(0)
|
||||||
|
ticks.sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaTimeGraph({ series, referenceLabel }: DeltaTimeGraphProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const [hoverLap, setHoverLap] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const drivers = useMemo(
|
||||||
|
() => computeCumulativeDeltas(series, referenceLabel),
|
||||||
|
[series, referenceLabel],
|
||||||
|
)
|
||||||
|
|
||||||
|
const laps = useMemo(() => lapCount(series), [series])
|
||||||
|
const plotW = W - PL - PR
|
||||||
|
const plotH = H - PT - PB
|
||||||
|
const { min: yMin, max: yMax } = useMemo(() => yExtent(drivers), [drivers])
|
||||||
|
const yTicks = useMemo(() => niceYTicks(yMin, yMax), [yMin, yMax])
|
||||||
|
|
||||||
|
const toX = useCallback(
|
||||||
|
(lapIndex: number) => {
|
||||||
|
if (laps <= 1) return PL + plotW / 2
|
||||||
|
return PL + (lapIndex / (laps - 1)) * plotW
|
||||||
|
},
|
||||||
|
[laps, plotW],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toY = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
const span = yMax - yMin || 1
|
||||||
|
return PT + ((delta - yMin) / span) * plotH
|
||||||
|
},
|
||||||
|
[yMin, yMax, plotH],
|
||||||
|
)
|
||||||
|
|
||||||
|
const lapTickNumbers = useMemo(() => {
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let lap = 5; lap <= laps; lap += 5) {
|
||||||
|
ticks.push(lap)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}, [laps])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent<SVGRectElement> | React.MouseEvent<SVGRectElement>) => {
|
||||||
|
if (!svgRef.current || laps === 0) return
|
||||||
|
const rect = svgRef.current.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0) return
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * W
|
||||||
|
const frac = Math.max(0, Math.min(1, (x - PL) / plotW))
|
||||||
|
const lapIndex = laps <= 1 ? 0 : Math.round(frac * (laps - 1))
|
||||||
|
if (!Number.isFinite(lapIndex)) return
|
||||||
|
setHoverLap(Math.max(0, Math.min(laps - 1, lapIndex)))
|
||||||
|
},
|
||||||
|
[laps, plotW],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback(() => setHoverLap(null), [])
|
||||||
|
|
||||||
|
if (series.length === 0 || laps === 0) {
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph-empty">
|
||||||
|
<p className="delta-graph-empty">No lap data to compare.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drivers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph-empty">
|
||||||
|
<p className="delta-graph-empty">Select at least two drivers to compare.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverX = hoverLap !== null ? toX(hoverLap) : null
|
||||||
|
const tooltipRows = hoverLap !== null
|
||||||
|
? drivers
|
||||||
|
.map((d) => {
|
||||||
|
const delta = d.deltas[hoverLap]
|
||||||
|
if (delta == null) return null
|
||||||
|
return { label: d.label, color: d.color, delta }
|
||||||
|
})
|
||||||
|
.filter((row): row is { label: string; color: string; delta: number } => row !== null)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const tooltipH = 18 + tooltipRows.length * 14
|
||||||
|
const tooltipW = 120
|
||||||
|
const tooltipX = hoverX !== null ? Math.min(Math.max(hoverX + 8, PL), W - PR - tooltipW) : 0
|
||||||
|
const tooltipY = PT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="delta-graph" data-testid="delta-time-graph">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="delta-graph-svg"
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="Cumulative delta time chart"
|
||||||
|
>
|
||||||
|
{yTicks.map((tick) => (
|
||||||
|
<g key={`y-${tick}`}>
|
||||||
|
<line
|
||||||
|
x1={PL}
|
||||||
|
x2={W - PR}
|
||||||
|
y1={toY(tick)}
|
||||||
|
y2={toY(tick)}
|
||||||
|
className={Math.abs(tick) < 1e-9 ? 'delta-graph-zero-line' : 'delta-graph-grid-line'}
|
||||||
|
data-testid={Math.abs(tick) < 1e-9 ? 'delta-zero-line' : undefined}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={PL - 6}
|
||||||
|
y={toY(tick) + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
className="delta-graph-axis-label"
|
||||||
|
>
|
||||||
|
{formatDeltaSeconds(tick)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{lapTickNumbers.map((lap) => {
|
||||||
|
const x = toX(lap - 1)
|
||||||
|
return (
|
||||||
|
<g key={`lap-${lap}`}>
|
||||||
|
<line
|
||||||
|
x1={x}
|
||||||
|
x2={x}
|
||||||
|
y1={H - PB}
|
||||||
|
y2={H - PB + 4}
|
||||||
|
className="delta-graph-grid-line"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={H - PB + 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="delta-graph-axis-label"
|
||||||
|
>
|
||||||
|
{lap}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{drivers.map((driver) => {
|
||||||
|
const segments = deltaPolylineSegments(driver.deltas, (lapIndex, delta) =>
|
||||||
|
`${toX(lapIndex).toFixed(1)},${toY(delta).toFixed(1)}`,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<g key={driver.label} data-testid={`delta-line-${driver.label}`}>
|
||||||
|
{segments.map((points, i) => (
|
||||||
|
<polyline
|
||||||
|
key={`${driver.label}-${i}`}
|
||||||
|
points={points}
|
||||||
|
className="delta-graph-driver-line"
|
||||||
|
stroke={driver.color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{hoverX !== null && (
|
||||||
|
<>
|
||||||
|
<line
|
||||||
|
x1={hoverX}
|
||||||
|
x2={hoverX}
|
||||||
|
y1={PT}
|
||||||
|
y2={H - PB}
|
||||||
|
className="delta-graph-crosshair"
|
||||||
|
data-testid="delta-crosshair"
|
||||||
|
/>
|
||||||
|
{tooltipRows.length > 0 && (
|
||||||
|
<g className="delta-graph-tooltip" data-testid="delta-tooltip">
|
||||||
|
<rect
|
||||||
|
x={tooltipX}
|
||||||
|
y={tooltipY}
|
||||||
|
width={tooltipW}
|
||||||
|
height={tooltipH}
|
||||||
|
rx={4}
|
||||||
|
className="delta-graph-tooltip-bg"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={tooltipX + 8}
|
||||||
|
y={tooltipY + 14}
|
||||||
|
className="delta-graph-tooltip-title"
|
||||||
|
>
|
||||||
|
Lap {hoverLap! + 1}
|
||||||
|
</text>
|
||||||
|
{tooltipRows.map((row, i) => (
|
||||||
|
<text
|
||||||
|
key={row.label}
|
||||||
|
x={tooltipX + 8}
|
||||||
|
y={tooltipY + 28 + i * 14}
|
||||||
|
className="delta-graph-tooltip-row"
|
||||||
|
fill={row.color}
|
||||||
|
>
|
||||||
|
{row.label} {formatDeltaSeconds(row.delta)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={PL}
|
||||||
|
y={PT}
|
||||||
|
width={plotW}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
className="delta-graph-hover-layer"
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onMouseMove={handlePointerMove}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onMouseLeave={handlePointerLeave}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
frontend/src/components/charts/TelemetryTraceChart.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import '../../styles/telemetry-trace.css'
|
||||||
|
|
||||||
|
export interface TelemetryTraceSample {
|
||||||
|
speed: number
|
||||||
|
throttle: number
|
||||||
|
brake: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelemetryTraceSeries {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
samples: TelemetryTraceSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TelemetryTraceChannel = 'speed' | 'throttle' | 'brake'
|
||||||
|
|
||||||
|
export interface TelemetryTraceChartProps {
|
||||||
|
series: TelemetryTraceSeries[]
|
||||||
|
channels?: TelemetryTraceChannel[]
|
||||||
|
/** Height of each channel panel in SVG units (viewBox space). */
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_WIDTH = 800
|
||||||
|
const PANEL_GAP = 18
|
||||||
|
const PAD_TOP = 6
|
||||||
|
const PAD_BOTTOM = 6
|
||||||
|
|
||||||
|
const CHANNEL_LABELS: Record<TelemetryTraceChannel, string> = {
|
||||||
|
speed: 'Speed',
|
||||||
|
throttle: 'Throttle',
|
||||||
|
brake: 'Brake',
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
||||||
|
|
||||||
|
function channelValue(sample: TelemetryTraceSample, channel: TelemetryTraceChannel): number {
|
||||||
|
const raw = sample[channel]
|
||||||
|
// Throttle/brake are percentages; clamp so out-of-range API values can't
|
||||||
|
// draw outside the panel. Speed is clamped to >= 0.
|
||||||
|
if (channel === 'speed') return Math.max(0, raw)
|
||||||
|
return clamp(raw, 0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number, channel: TelemetryTraceChannel): string {
|
||||||
|
if (channel === 'speed') return `${Math.round(value)} km/h`
|
||||||
|
return `${Math.round(value)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TelemetryTraceChart({
|
||||||
|
series,
|
||||||
|
channels = ['speed', 'throttle', 'brake'],
|
||||||
|
height = 110,
|
||||||
|
}: TelemetryTraceChartProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||||
|
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Series are index-aligned; mismatched lengths are clamped to the shortest
|
||||||
|
// series so every drawn x has a value for every driver.
|
||||||
|
const sampleCount = useMemo(
|
||||||
|
() => (series.length === 0 ? 0 : Math.min(...series.map((s) => s.samples.length))),
|
||||||
|
[series],
|
||||||
|
)
|
||||||
|
|
||||||
|
const speedMax = useMemo(() => {
|
||||||
|
let max = 0
|
||||||
|
for (const s of series) {
|
||||||
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
|
max = Math.max(max, channelValue(s.samples[i], 'speed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max > 0 ? max : 1
|
||||||
|
}, [series, sampleCount])
|
||||||
|
|
||||||
|
if (series.length === 0 || sampleCount === 0 || channels.length === 0) {
|
||||||
|
return <div className="telemetry-trace-empty">No telemetry data</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp a stale hover index in case the series prop shrank between renders.
|
||||||
|
const hover = hoverIndex === null ? null : Math.min(hoverIndex, sampleCount - 1)
|
||||||
|
|
||||||
|
const panelHeight = height
|
||||||
|
const totalHeight = channels.length * panelHeight + (channels.length - 1) * PANEL_GAP
|
||||||
|
const xAt = (i: number) => (i / Math.max(1, sampleCount - 1)) * VIEW_WIDTH
|
||||||
|
|
||||||
|
const channelMax = (channel: TelemetryTraceChannel) => (channel === 'speed' ? speedMax : 100)
|
||||||
|
|
||||||
|
const yAt = (value: number, channel: TelemetryTraceChannel, panelTop: number) => {
|
||||||
|
const usable = panelHeight - PAD_TOP - PAD_BOTTOM
|
||||||
|
const frac = channelValue({ speed: value, throttle: value, brake: value }, channel) / channelMax(channel)
|
||||||
|
return panelTop + PAD_TOP + (1 - frac) * usable
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
const rect = svgRef.current?.getBoundingClientRect()
|
||||||
|
if (!rect || rect.width === 0) {
|
||||||
|
setHoverIndex(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const frac = clamp((e.clientX - rect.left) / rect.width, 0, 1)
|
||||||
|
setHoverIndex(clamp(Math.round(frac * (sampleCount - 1)), 0, sampleCount - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="telemetry-trace" data-testid="telemetry-trace">
|
||||||
|
<div className="telemetry-trace-legend">
|
||||||
|
{series.map((s) => (
|
||||||
|
<span key={s.label} className="telemetry-trace-legend-item">
|
||||||
|
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="telemetry-trace-svg"
|
||||||
|
viewBox={`0 0 ${VIEW_WIDTH} ${totalHeight}`}
|
||||||
|
role="img"
|
||||||
|
aria-label="Telemetry trace chart"
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setHoverIndex(null)}
|
||||||
|
>
|
||||||
|
{channels.map((channel, panelIdx) => {
|
||||||
|
const panelTop = panelIdx * (panelHeight + PANEL_GAP)
|
||||||
|
return (
|
||||||
|
<g key={channel} className="telemetry-trace-panel" data-channel={channel}>
|
||||||
|
<rect
|
||||||
|
className="telemetry-trace-panel-bg"
|
||||||
|
x={0}
|
||||||
|
y={panelTop}
|
||||||
|
width={VIEW_WIDTH}
|
||||||
|
height={panelHeight}
|
||||||
|
/>
|
||||||
|
<text className="telemetry-trace-panel-title" x={6} y={panelTop + 13}>
|
||||||
|
{CHANNEL_LABELS[channel]}
|
||||||
|
</text>
|
||||||
|
<text className="telemetry-trace-axis-max" x={VIEW_WIDTH - 6} y={panelTop + 13} textAnchor="end">
|
||||||
|
{channel === 'speed' ? `${Math.round(speedMax)} km/h` : '100%'}
|
||||||
|
</text>
|
||||||
|
{series.map((s) => {
|
||||||
|
const points = Array.from({ length: sampleCount }, (_, i) => {
|
||||||
|
const x = xAt(i)
|
||||||
|
const y = yAt(s.samples[i][channel], channel, panelTop)
|
||||||
|
return `${x.toFixed(2)},${y.toFixed(2)}`
|
||||||
|
}).join(' ')
|
||||||
|
return (
|
||||||
|
<polyline
|
||||||
|
key={s.label}
|
||||||
|
className="telemetry-trace-line"
|
||||||
|
data-channel={channel}
|
||||||
|
data-series={s.label}
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
strokeWidth={1.6}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{hover !== null && (
|
||||||
|
<line
|
||||||
|
className="telemetry-trace-crosshair"
|
||||||
|
data-testid="telemetry-trace-crosshair"
|
||||||
|
x1={xAt(hover)}
|
||||||
|
y1={0}
|
||||||
|
x2={xAt(hover)}
|
||||||
|
y2={totalHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{hover !== null && (
|
||||||
|
<div className="telemetry-trace-readout" data-testid="telemetry-trace-readout">
|
||||||
|
<span className="telemetry-trace-readout-index">Sample {hover}</span>
|
||||||
|
{series.map((s) => (
|
||||||
|
<span key={s.label} className="telemetry-trace-readout-driver">
|
||||||
|
<span className="telemetry-trace-swatch" style={{ background: s.color }} aria-hidden="true" />
|
||||||
|
<span className="telemetry-trace-readout-label">{s.label}</span>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<span key={channel} className="telemetry-trace-readout-value">
|
||||||
|
{formatValue(channelValue(s.samples[hover], channel), channel)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
frontend/src/components/charts/TyreStintTimeline.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { compoundClass } from '../../lib/live'
|
||||||
|
import '../../styles/stint-timeline.css'
|
||||||
|
|
||||||
|
export interface StintTimelineStint {
|
||||||
|
compound: string
|
||||||
|
lapStart: number
|
||||||
|
lapEnd: number
|
||||||
|
isNew?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StintTimelineRow {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
stints: StintTimelineStint[]
|
||||||
|
/** Lap numbers where the driver pitted; optional — rows without stops render normally. */
|
||||||
|
pitStops?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TyreStintTimelineProps {
|
||||||
|
rows: StintTimelineRow[]
|
||||||
|
totalLaps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG_W = 640
|
||||||
|
const LEFT = 48
|
||||||
|
const RIGHT = 12
|
||||||
|
const ROW_H = 28
|
||||||
|
const BAR_H = 14
|
||||||
|
const BAR_Y = 7
|
||||||
|
const AXIS_H = 20
|
||||||
|
const BAR_W = SVG_W - LEFT - RIGHT
|
||||||
|
|
||||||
|
const COMPOUND_ORDER = ['SOFT', 'MEDIUM', 'HARD', 'INTERMEDIATE', 'WET'] as const
|
||||||
|
|
||||||
|
function compoundLabel(compound: string): string {
|
||||||
|
const upper = compound.toUpperCase()
|
||||||
|
if (upper === 'INTERMEDIATE') return 'Intermediate'
|
||||||
|
return upper.charAt(0) + upper.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintLength(stint: StintTimelineStint): number {
|
||||||
|
return stint.lapEnd - stint.lapStart + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintTitle(stint: StintTimelineStint): string {
|
||||||
|
const length = stintLength(stint)
|
||||||
|
return `${compoundLabel(stint.compound)} · L${stint.lapStart}–${stint.lapEnd} · ${length} lap${length === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function lapX(lap: number, totalLaps: number): number {
|
||||||
|
return LEFT + (lap / totalLaps) * BAR_W
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintBarX(stint: StintTimelineStint, totalLaps: number): number {
|
||||||
|
return LEFT + ((stint.lapStart - 1) / totalLaps) * BAR_W
|
||||||
|
}
|
||||||
|
|
||||||
|
function stintBarW(stint: StintTimelineStint, totalLaps: number): number {
|
||||||
|
return Math.max(2, (stintLength(stint) / totalLaps) * BAR_W)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pitMarkerX(lapNumber: number, totalLaps: number): number {
|
||||||
|
return LEFT + ((lapNumber - 1) / totalLaps) * BAR_W
|
||||||
|
}
|
||||||
|
|
||||||
|
function pitMarkerTitle(driverLabel: string, lapNumber: number): string {
|
||||||
|
return `${driverLabel} pit stop · L${lapNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisTicks(totalLaps: number): number[] {
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let lap = 0; lap <= totalLaps; lap += 10) {
|
||||||
|
ticks.push(lap)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectUsedCompounds(rows: StintTimelineRow[]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const stint of row.stints) {
|
||||||
|
seen.add(stint.compound.toUpperCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered = COMPOUND_ORDER.filter((c) => seen.has(c))
|
||||||
|
const extras = [...seen]
|
||||||
|
.filter((c) => !COMPOUND_ORDER.includes(c as (typeof COMPOUND_ORDER)[number]))
|
||||||
|
.sort()
|
||||||
|
return [...ordered, ...extras]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TyreStintTimeline({ rows, totalLaps }: TyreStintTimelineProps) {
|
||||||
|
const safeTotal = Math.max(totalLaps, 1)
|
||||||
|
const usedCompounds = collectUsedCompounds(rows)
|
||||||
|
const ticks = axisTicks(safeTotal)
|
||||||
|
const chartH = rows.length * ROW_H
|
||||||
|
const svgH = chartH + AXIS_H + 4
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="stint-timeline" data-testid="stint-timeline-empty">
|
||||||
|
<div className="stint-timeline__empty">No stint data to display.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stint-timeline" data-testid="stint-timeline">
|
||||||
|
<div className="stint-timeline__scroll">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${SVG_W} ${svgH}`}
|
||||||
|
className="stint-timeline__svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Tyre stint timeline"
|
||||||
|
>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const rowY = i * ROW_H
|
||||||
|
return (
|
||||||
|
<g key={`${row.label}-${i}`} transform={`translate(0,${rowY})`}>
|
||||||
|
<text
|
||||||
|
x={LEFT - 5}
|
||||||
|
y={BAR_Y + BAR_H / 2 + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fill={row.color}
|
||||||
|
className="stint-timeline__label"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{row.stints.map((stint, si) => (
|
||||||
|
<rect
|
||||||
|
key={si}
|
||||||
|
x={stintBarX(stint, safeTotal)}
|
||||||
|
y={BAR_Y}
|
||||||
|
width={stintBarW(stint, safeTotal)}
|
||||||
|
height={BAR_H}
|
||||||
|
rx={3}
|
||||||
|
className={`stint-timeline__bar ${compoundClass(stint.compound)}${stint.isNew ? ' stint-timeline__bar--new' : ''}`}
|
||||||
|
>
|
||||||
|
<title>{stintTitle(stint)}</title>
|
||||||
|
</rect>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(row.pitStops ?? []).map((lapNumber, pi) => (
|
||||||
|
<line
|
||||||
|
key={`pit-${pi}`}
|
||||||
|
x1={pitMarkerX(lapNumber, safeTotal)}
|
||||||
|
x2={pitMarkerX(lapNumber, safeTotal)}
|
||||||
|
y1={BAR_Y - 3}
|
||||||
|
y2={BAR_Y + BAR_H + 3}
|
||||||
|
className="stint-timeline__pit-marker"
|
||||||
|
data-testid="pit-marker"
|
||||||
|
data-lap={lapNumber}
|
||||||
|
>
|
||||||
|
<title>{pitMarkerTitle(row.label, lapNumber)}</title>
|
||||||
|
</line>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<g transform={`translate(0,${chartH})`}>
|
||||||
|
<line
|
||||||
|
x1={LEFT}
|
||||||
|
x2={LEFT + BAR_W}
|
||||||
|
y1={0}
|
||||||
|
y2={0}
|
||||||
|
className="stint-timeline__axis-line"
|
||||||
|
/>
|
||||||
|
{ticks.map((lap) => (
|
||||||
|
<g key={lap}>
|
||||||
|
<line
|
||||||
|
x1={lapX(lap, safeTotal)}
|
||||||
|
x2={lapX(lap, safeTotal)}
|
||||||
|
y1={0}
|
||||||
|
y2={4}
|
||||||
|
className="stint-timeline__axis-line"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={lapX(lap, safeTotal)}
|
||||||
|
y={14}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="stint-timeline__axis-tick"
|
||||||
|
>
|
||||||
|
{lap}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stint-timeline__legend">
|
||||||
|
{usedCompounds.map((compound) => (
|
||||||
|
<span key={compound} className="stint-timeline__legend-item">
|
||||||
|
<span
|
||||||
|
className={`stint-timeline__legend-swatch ${compoundClass(compound)}`}
|
||||||
|
data-testid={`legend-${compound.toLowerCase()}`}
|
||||||
|
/>
|
||||||
|
{compoundLabel(compound)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="stint-timeline__meta">{safeTotal} laps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
frontend/src/components/live/EventRail.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useAutoAnimate } from '@formkit/auto-animate/react'
|
||||||
|
import {
|
||||||
|
Flag,
|
||||||
|
LogOut,
|
||||||
|
Megaphone,
|
||||||
|
OctagonX,
|
||||||
|
Swords,
|
||||||
|
Timer,
|
||||||
|
TrendingDown,
|
||||||
|
TrendingUp,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import type { LiveDriverInfo } from '../../types'
|
||||||
|
import type { LiveEvent, LiveEventKind } from '../../lib/events'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import '../../styles/event-rail.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: LiveEvent[]
|
||||||
|
driverInfo?: Record<string, LiveDriverInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_ICONS: Record<LiveEventKind, LucideIcon> = {
|
||||||
|
overtake: Swords,
|
||||||
|
'position-gain': TrendingUp,
|
||||||
|
'position-loss': TrendingDown,
|
||||||
|
'pit-in': Wrench,
|
||||||
|
'pit-out': LogOut,
|
||||||
|
'personal-best': Timer,
|
||||||
|
'fastest-lap': Zap,
|
||||||
|
retirement: OctagonX,
|
||||||
|
'track-status': Flag,
|
||||||
|
'race-control': Megaphone,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventRail({ events, driverInfo }: Props) {
|
||||||
|
const [listRef] = useAutoAnimate<HTMLDivElement>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="event-rail panel-glass" data-testid="event-rail">
|
||||||
|
<div className="sec-header sticky-header">
|
||||||
|
<span className="sec-title">What Just Happened</span>
|
||||||
|
{events.length > 0 && <span className="sec-meta">{events.length} events</span>}
|
||||||
|
</div>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="missing-notice">Events will appear here as the session unfolds.</div>
|
||||||
|
) : (
|
||||||
|
<div className="event-rail-list" ref={listRef}>
|
||||||
|
{events.map((event) => {
|
||||||
|
const Icon = KIND_ICONS[event.kind] ?? Megaphone
|
||||||
|
const colour = driverInfo?.[event.racingNumbers[0]]?.TeamColour
|
||||||
|
const accent = event.racingNumbers.length > 0 ? teamColor(colour) : 'var(--border-2)'
|
||||||
|
return (
|
||||||
|
<div className={`event-rail-row event-kind-${event.kind}`} key={event.id} style={{ borderLeftColor: accent }}>
|
||||||
|
<span className="event-rail-icon" aria-hidden="true">
|
||||||
|
<Icon size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="event-rail-body">
|
||||||
|
<span className="event-rail-headline">{event.headline}</span>
|
||||||
|
{event.detail && <span className="event-rail-detail">{event.detail}</span>}
|
||||||
|
</div>
|
||||||
|
{event.lap > 0 && <span className="event-rail-lap">L{event.lap}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,18 +5,20 @@ import { WeatherStrip } from './WeatherStrip'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
|
isArchive?: boolean
|
||||||
snapshot: LiveStreamData
|
snapshot: LiveStreamData
|
||||||
rows: LiveTimingRow[]
|
rows: LiveTimingRow[]
|
||||||
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
connection: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||||
now: number
|
now: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props) {
|
export function SessionBanner({ isLive, isArchive = false, snapshot, rows, connection, now }: Props) {
|
||||||
const session = snapshot.Session
|
const session = snapshot.Session
|
||||||
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
const clock = extrapolateClock(snapshot.Clock, snapshot.ClockRefTime, snapshot.ClockExtrapolating, now)
|
||||||
const display = liveSessionDisplay(session, rows)
|
const display = liveSessionDisplay(session, rows)
|
||||||
const atRiskLabel =
|
const atRiskLabel =
|
||||||
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
display.atRiskStart && display.atRiskEnd ? `P${display.atRiskStart}-P${display.atRiskEnd} at risk` : ''
|
||||||
|
const stateLabel = isLive ? 'live' : isArchive ? 'archive' : 'stale'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="live-banner">
|
<section className="live-banner">
|
||||||
@@ -39,7 +41,7 @@ export function SessionBanner({ isLive, snapshot, rows, connection, now }: Props
|
|||||||
<span>
|
<span>
|
||||||
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
L<strong>{snapshot.CurrentLap || '-'}</strong>/<strong>{snapshot.TotalLaps || '-'}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{isLive ? 'live' : 'stale'}</span>
|
<span className={isLive ? 'live-state live-state-on' : 'live-state'}>{stateLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import { Pause, Play } from 'lucide-react'
|
||||||
|
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
|
||||||
|
import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import '../../styles/team-radio.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
captures?: LiveRadioCapture[]
|
||||||
|
driverInfo?: Record<string, LiveDriverInfo>
|
||||||
|
session?: LiveSessionMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(utc: string): string {
|
||||||
|
const timestamp = new Date(utc).getTime()
|
||||||
|
if (!utc || Number.isNaN(timestamp)) return '--'
|
||||||
|
|
||||||
|
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
|
||||||
|
if (diffSeconds < 60) return `${diffSeconds}s ago`
|
||||||
|
const minutes = Math.floor(diffSeconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
|
||||||
|
return info?.Tla || racingNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const [playingKey, setPlayingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const newestFirst = useMemo(() => [...captures].reverse(), [captures])
|
||||||
|
|
||||||
|
const toggleCapture = (capture: LiveRadioCapture) => {
|
||||||
|
const key = radioCaptureKey(capture)
|
||||||
|
const audio = audioRef.current
|
||||||
|
const url = radioClipUrl(session, capture)
|
||||||
|
if (!audio || !url) return
|
||||||
|
|
||||||
|
if (playingKey === key) {
|
||||||
|
audio.pause()
|
||||||
|
setPlayingKey(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.pause()
|
||||||
|
audio.src = url
|
||||||
|
setPlayingKey(key)
|
||||||
|
const play = audio.play()
|
||||||
|
if (play) {
|
||||||
|
play.catch(() => setPlayingKey(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="team-radio panel-glass" data-testid="team-radio-ticker">
|
||||||
|
<div className="sec-header sticky-header">
|
||||||
|
<span className="sec-title">Team Radio</span>
|
||||||
|
{captures.length > 0 && <span className="sec-meta">{captures.length} clips</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
className="team-radio-audio"
|
||||||
|
onEnded={() => setPlayingKey(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{newestFirst.length === 0 ? (
|
||||||
|
<div className="missing-notice">No team radio clips in the current live snapshot.</div>
|
||||||
|
) : (
|
||||||
|
<div className="team-radio-list">
|
||||||
|
{newestFirst.map((capture) => {
|
||||||
|
const key = radioCaptureKey(capture)
|
||||||
|
const info = driverInfo[capture.RacingNumber]
|
||||||
|
const label = driverLabel(info, capture.RacingNumber)
|
||||||
|
const isPlaying = playingKey === key
|
||||||
|
const url = radioClipUrl(session, capture)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="team-radio-row" key={key}>
|
||||||
|
<button
|
||||||
|
className="team-radio-play"
|
||||||
|
type="button"
|
||||||
|
aria-label={`${isPlaying ? 'Pause' : 'Play'} ${label} radio`}
|
||||||
|
disabled={!url}
|
||||||
|
onClick={() => toggleCapture(capture)}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="team-radio-driver"
|
||||||
|
style={{ borderColor: teamColor(info?.TeamColour) }}
|
||||||
|
title={info?.TeamName || undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="team-radio-time">{relativeTime(capture.Utc)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
tyreLabel,
|
tyreLabel,
|
||||||
} from '../../lib/live'
|
} from '../../lib/live'
|
||||||
import type { GapHistoryMap } from '../../lib/gapHistory'
|
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 { GapSparkline } from './GapSparkline'
|
||||||
import { StintHistory } from './StintHistory'
|
import { StintHistory } from './StintHistory'
|
||||||
import { Pin } from 'lucide-react'
|
import { Pin } from 'lucide-react'
|
||||||
@@ -100,6 +103,10 @@ export function TimingTower({
|
|||||||
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
const showCutoffAfter = row.Position === sessionDisplay.cutoffPosition
|
||||||
|
|
||||||
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
const gapText = gapMode === 'interval' && isRace ? (driver.Interval || driver.GapToLeader) : driver.GapToLeader
|
||||||
|
const intervalAnnotation =
|
||||||
|
gapMode === 'interval' && isRace && row.Position > 1
|
||||||
|
? intervalMeaning(parseIntervalSeconds(gapText))
|
||||||
|
: null
|
||||||
|
|
||||||
const renderSector = (idx: number) => {
|
const renderSector = (idx: number) => {
|
||||||
const sec = driver.Sectors?.[idx]
|
const sec = driver.Sectors?.[idx]
|
||||||
@@ -149,7 +156,14 @@ export function TimingTower({
|
|||||||
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
<td className={driver.LastLapOB ? 'mono lap-ob' : driver.LastLapPB ? 'mono lap-pb' : 'mono'}>
|
||||||
{driver.LastLapTime || '-'}
|
{driver.LastLapTime || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="mono">{gapText || '-'}</td>
|
<td className="mono">
|
||||||
|
<Meaning
|
||||||
|
value={gapText || '-'}
|
||||||
|
meaning={intervalAnnotation?.caption}
|
||||||
|
title={intervalAnnotation?.title}
|
||||||
|
tone={intervalAnnotation?.tone}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
{isRace && (
|
{isRace && (
|
||||||
<td className="spark-cell">
|
<td className="spark-cell">
|
||||||
|
|||||||
140
frontend/src/components/live/TyreDegPanel.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
frontend/src/components/weekend/BetweenRacesView.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
|
||||||
|
import type {
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendViewState,
|
||||||
|
} from '../../types'
|
||||||
|
import {
|
||||||
|
BriefingStrip,
|
||||||
|
ChampionshipImpactCard,
|
||||||
|
ChampionshipRoundStrip,
|
||||||
|
CountdownDisplay,
|
||||||
|
EventPodium,
|
||||||
|
Flag,
|
||||||
|
} from './shared'
|
||||||
|
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
import { parseScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
|
const EYEBROW: Record<string, string> = {
|
||||||
|
between_weekends: 'Between races',
|
||||||
|
post_weekend: 'Post-weekend',
|
||||||
|
season_complete: 'Season complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionLine(name: string | undefined, start: string | undefined): string {
|
||||||
|
if (!name) return 'Schedule to be confirmed'
|
||||||
|
const date = start ? parseScheduleTime(start) : null
|
||||||
|
if (!date) return name
|
||||||
|
const when = date.toLocaleString('en-GB', {
|
||||||
|
weekday: 'long',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
return `${name} · ${when}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BetweenRacesView({
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
view,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
}: {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
view: WeekendViewState
|
||||||
|
championship?: WeekendChampionshipImpact
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
}) {
|
||||||
|
const eyebrow = EYEBROW[view] ?? 'Between races'
|
||||||
|
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting)
|
||||||
|
const nextMeeting = meetingIdentity(context.next_meeting)
|
||||||
|
const nextSession = context.next_session?.session
|
||||||
|
const analysisKey = analysisSessionKey(context)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-between" data-testid="weekend-between-races" data-state={view}>
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
|
||||||
|
|
||||||
|
<div className="wk-top-grid">
|
||||||
|
{previousMeeting && (
|
||||||
|
<section className="wk-event-card wk-event-last" data-testid="wk-last-event">
|
||||||
|
<header className="wk-event-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
|
||||||
|
<h2 className="wk-event-name">{previousMeeting.meeting_name}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="wk-event-tag wk-tag-done mono">
|
||||||
|
<FlagIcon size={13} aria-hidden="true" /> Completed
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="wk-event-body">
|
||||||
|
<EventPodium sessionKey={analysisKey} />
|
||||||
|
<div className="wk-story-card">
|
||||||
|
<span className="wk-story-label">What decided it?</span>
|
||||||
|
{analysisKey ? (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: analysisKey }}
|
||||||
|
className="wk-cta wk-cta-primary"
|
||||||
|
data-testid="wk-explore-race-story"
|
||||||
|
>
|
||||||
|
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="wk-story-text" data-testid="wk-no-analysis">
|
||||||
|
Analysis for this session is not available locally yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextMeeting ? (
|
||||||
|
<section className="wk-event-card wk-event-next" data-testid="wk-next-event">
|
||||||
|
<header className="wk-event-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
|
||||||
|
<h2 className="wk-event-name">{nextMeeting.meeting_name}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="wk-event-tag wk-tag-next mono">Next event</span>
|
||||||
|
</header>
|
||||||
|
<div className="wk-next-body">
|
||||||
|
<CountdownDisplay target={nextSession?.date_start ?? nextMeeting.date_start} now={now} />
|
||||||
|
<div className="wk-next-session">
|
||||||
|
<span className="wk-next-label mono">Next session</span>
|
||||||
|
<span className="wk-next-value">
|
||||||
|
{formatSessionLine(nextSession?.session_name, nextSession?.date_start)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare">
|
||||||
|
Prepare for {nextMeeting.short_name}
|
||||||
|
<ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
|
||||||
|
<header className="wk-event-head">
|
||||||
|
<h2 className="wk-event-name">That's a wrap</h2>
|
||||||
|
<span className="wk-event-tag wk-tag-next mono">Off-season</span>
|
||||||
|
</header>
|
||||||
|
<div className="wk-next-body">
|
||||||
|
<p className="wk-season-complete-copy">
|
||||||
|
The {context.season} calendar is complete. Explore the season's races or revisit the championship
|
||||||
|
battle while the next schedule is confirmed.
|
||||||
|
</p>
|
||||||
|
<Link to="/explore" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-season-complete-explore">
|
||||||
|
Explore the season <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wk-mid-grid">
|
||||||
|
{championship && <ChampionshipImpactCard impact={championship} />}
|
||||||
|
<ChampionshipRoundStrip
|
||||||
|
round={context.championship_round}
|
||||||
|
total={context.total_championship_rounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
frontend/src/components/weekend/BetweenSessionsView.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
|
||||||
|
import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
|
||||||
|
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
import { parseScheduleTime } from '../../lib/schedule'
|
||||||
|
|
||||||
|
function nextSessionWhen(start: string | undefined): string {
|
||||||
|
const date = start ? parseScheduleTime(start) : null
|
||||||
|
if (!date) return 'Time to be confirmed'
|
||||||
|
return date.toLocaleString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BetweenSessionsView({
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
view,
|
||||||
|
briefing,
|
||||||
|
}: {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
view: WeekendViewState
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
}) {
|
||||||
|
const settling = view === 'session_settling'
|
||||||
|
const focus = meetingIdentity(context.focus_meeting)
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
const previousName = previous?.session.session_name ?? 'Last session'
|
||||||
|
const next = context.next_session?.session
|
||||||
|
const analysisKey = analysisSessionKey(context)
|
||||||
|
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={view}>
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">
|
||||||
|
{settling ? 'Session settling' : 'Between sessions'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="wk-sessions-head">
|
||||||
|
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
|
||||||
|
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SessionRail nodes={nodes} />
|
||||||
|
|
||||||
|
{previous && (
|
||||||
|
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||||
|
<span className="wk-recap-eyebrow mono">
|
||||||
|
{previousName} · {settling ? 'Settling' : 'Complete'}
|
||||||
|
</span>
|
||||||
|
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||||
|
<EventPodium sessionKey={analysisKey} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{next && (
|
||||||
|
<section className="wk-upnext-card" data-testid="wk-next-session">
|
||||||
|
<span className="wk-upnext-eyebrow mono">Up next</span>
|
||||||
|
<h2 className="wk-upnext-title">{next.session_name}</h2>
|
||||||
|
<CountdownDisplay target={next.date_start} now={now} compact />
|
||||||
|
<p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
|
||||||
|
{analysisKey && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: analysisKey }}
|
||||||
|
className="wk-cta wk-cta-primary wk-cta-wide"
|
||||||
|
data-testid="wk-view-recap"
|
||||||
|
>
|
||||||
|
View {previousName} recap <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
frontend/src/components/weekend/LiveHandoffView.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight, Radio } from 'lucide-react'
|
||||||
|
import type { WeekendContext } from '../../types'
|
||||||
|
import { EventPodium, Flag, SessionRail, railNodes } from './shared'
|
||||||
|
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
|
||||||
|
export function LiveHandoffView({ context }: { context: WeekendContext }) {
|
||||||
|
const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
|
||||||
|
const active = context.active_session?.session
|
||||||
|
const previous = context.previous_completed_session
|
||||||
|
const previousName = previous?.session.session_name ?? 'Last session'
|
||||||
|
const analysisKey = analysisSessionKey(context)
|
||||||
|
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-live" data-testid="weekend-live" data-state="session_live">
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
|
||||||
|
|
||||||
|
<section className="wk-live-card" data-testid="wk-live-card">
|
||||||
|
<div className="wk-live-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={focus?.country_code} flag={focus?.country_flag} />
|
||||||
|
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Live session'}</h1>
|
||||||
|
</div>
|
||||||
|
<span className="wk-live-pulse" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||||
|
<p className="wk-live-lead">
|
||||||
|
{active?.session_name ? `${active.session_name} is on track now.` : 'A session is running now.'}
|
||||||
|
</p>
|
||||||
|
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live">
|
||||||
|
<Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SessionRail nodes={nodes} />
|
||||||
|
|
||||||
|
{previous && (
|
||||||
|
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||||
|
<span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
|
||||||
|
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||||
|
<EventPodium sessionKey={analysisKey} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/components/weekend/PreSessionView.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import type { WeekendContext } from '../../types'
|
||||||
|
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
||||||
|
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||||
|
import { meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreSessionView folds the race preview surface into the Weekend home so there is
|
||||||
|
* no separate Preview destination. It reuses the existing preview page content and
|
||||||
|
* frames it with the next-session countdown and compact season navigation. It also
|
||||||
|
* backs the /preview alias, so saved preview links resolve here instead of looping.
|
||||||
|
*/
|
||||||
|
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||||
|
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
|
||||||
|
const next = context.next_session?.session
|
||||||
|
const nextStart = next?.date_start ?? meeting?.date_start
|
||||||
|
const nodes = railNodes(
|
||||||
|
context.previous_completed_session,
|
||||||
|
context.active_session,
|
||||||
|
context.next_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
|
||||||
|
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||||
|
|
||||||
|
{meeting && (
|
||||||
|
<header className="wk-pre-head" data-testid="wk-pre-head">
|
||||||
|
<div className="wk-event-id">
|
||||||
|
<Flag code={meeting.country_code} flag={meeting.country_flag} />
|
||||||
|
<h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="wk-pre-countdown">
|
||||||
|
<span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
|
||||||
|
<CountdownDisplay target={nextStart} now={now} />
|
||||||
|
</div>
|
||||||
|
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
|
||||||
|
Live timing <ChevronRight size={14} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nodes.length > 0 && <SessionRail nodes={nodes} />}
|
||||||
|
|
||||||
|
<div className="wk-pre-preview" data-testid="wk-pre-preview">
|
||||||
|
<RacePreviewPage />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChampionshipRoundStrip
|
||||||
|
round={context.championship_round}
|
||||||
|
total={context.total_championship_rounds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
frontend/src/components/weekend/StatusViews.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function WeekendLoading() {
|
||||||
|
return (
|
||||||
|
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
|
||||||
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
|
<p className="wk-status-title">Reading the weekend…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendError({ message }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
||||||
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
|
<p className="wk-status-title">Weekend unavailable</p>
|
||||||
|
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendLimited({ message, season }: { message?: string; season?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
|
||||||
|
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||||
|
<p className="wk-status-title">Nothing to show yet</p>
|
||||||
|
<p className="wk-status-sub">
|
||||||
|
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
|
||||||
|
</p>
|
||||||
|
<div className="wk-status-actions">
|
||||||
|
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
|
||||||
|
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
277
frontend/src/components/weekend/shared.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { fetchRaceHub } from '../../api'
|
||||||
|
import { countryFlag } from '../../lib/gpIdentity'
|
||||||
|
import { parseScheduleTime } from '../../lib/schedule'
|
||||||
|
import { podiumFromResults } from '../../lib/weekendContext'
|
||||||
|
import { teamColor } from '../../utils'
|
||||||
|
import type {
|
||||||
|
ContextSession,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendPodiumEntry,
|
||||||
|
} from '../../types'
|
||||||
|
|
||||||
|
export function Flag({ code, flag }: { code?: string; flag?: string }) {
|
||||||
|
const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' })
|
||||||
|
if (!glyph) return null
|
||||||
|
return <span className="wk-flag" aria-hidden="true">{glyph}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountdownParts {
|
||||||
|
days: number
|
||||||
|
hours: number
|
||||||
|
minutes: number
|
||||||
|
seconds: number
|
||||||
|
reached: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countdownParts(target: string | undefined, now: Date): CountdownParts | null {
|
||||||
|
const date = target ? parseScheduleTime(target) : null
|
||||||
|
if (!date) return null
|
||||||
|
const diff = date.getTime() - now.getTime()
|
||||||
|
const clamped = Math.max(0, diff)
|
||||||
|
const totalSeconds = Math.floor(clamped / 1000)
|
||||||
|
return {
|
||||||
|
days: Math.floor(totalSeconds / 86400),
|
||||||
|
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||||
|
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||||
|
seconds: totalSeconds % 60,
|
||||||
|
reached: diff <= 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownDisplay({
|
||||||
|
target,
|
||||||
|
now,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
target: string | undefined
|
||||||
|
now: Date
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
|
const parts = countdownParts(target, now)
|
||||||
|
if (!parts) {
|
||||||
|
return (
|
||||||
|
<span className="wk-countdown-tbc mono" data-testid="wk-countdown">
|
||||||
|
Schedule TBC
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (parts.reached) {
|
||||||
|
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
|
||||||
|
}
|
||||||
|
if (compact || parts.days === 0) {
|
||||||
|
const hh = String(parts.days * 24 + parts.hours).padStart(2, '0')
|
||||||
|
return (
|
||||||
|
<span className="wk-countdown-clock mono" data-testid="wk-countdown">
|
||||||
|
{hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="wk-countdown" data-testid="wk-countdown">
|
||||||
|
<span className="wk-count-unit"><b>{parts.days}</b>D</span>
|
||||||
|
<span className="wk-count-unit"><b>{String(parts.hours).padStart(2, '0')}</b>H</span>
|
||||||
|
<span className="wk-count-unit"><b>{String(parts.minutes).padStart(2, '0')}</b>M</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventPodium fetches and renders the podium for a completed analysis session.
|
||||||
|
* When no analysable session key is available (e.g. an archived result with no
|
||||||
|
* local analysis), it renders an explicit empty state rather than fetching.
|
||||||
|
*/
|
||||||
|
export function EventPodium({ sessionKey }: { sessionKey?: number }) {
|
||||||
|
const enabled = typeof sessionKey === 'number' && sessionKey > 0
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['race-hub', sessionKey, 'podium'],
|
||||||
|
queryFn: () => fetchRaceHub(sessionKey as number),
|
||||||
|
enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||||
|
Result not available yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (podium.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||||
|
{query.isLoading ? 'Loading result…' : 'Result not available yet.'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="wk-podium" data-testid="wk-podium">
|
||||||
|
{podium.map((p) => (
|
||||||
|
<li key={p.driver_number} className="wk-podium-row" style={{ ['--wk-team' as string]: teamColor(p.team_colour) }}>
|
||||||
|
<span className="wk-podium-pos mono">{p.position}</span>
|
||||||
|
<span className="wk-podium-code">{p.name_acronym}</span>
|
||||||
|
<span className="wk-podium-gap mono">{p.gap}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) {
|
||||||
|
const leader = impact.leaders[0]
|
||||||
|
return (
|
||||||
|
<section className="wk-champ" data-testid="wk-champ-impact">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title">Championship impact</span>
|
||||||
|
<Link to="/championship" className="wk-card-link">Standings →</Link>
|
||||||
|
</header>
|
||||||
|
<ol className="wk-champ-list">
|
||||||
|
{impact.leaders.map((d) => {
|
||||||
|
const gap = leader && d.position !== 1 ? leader.points - d.points : 0
|
||||||
|
return (
|
||||||
|
<li key={d.driver_number} className="wk-champ-row" style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}>
|
||||||
|
<span className="wk-champ-pos mono">{d.position}</span>
|
||||||
|
<span className="wk-champ-code">{d.name_acronym}</span>
|
||||||
|
<span className="wk-champ-pts mono">{d.points}</span>
|
||||||
|
<span className="wk-champ-gap mono">{d.position === 1 ? '—' : `-${gap}`}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
{impact.note && <p className="wk-champ-note">{impact.note}</p>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<section className="wk-briefing" data-testid="wk-briefing">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title">Since the chequered flag</span>
|
||||||
|
<Link to="/briefing" className="wk-card-link">All briefing →</Link>
|
||||||
|
</header>
|
||||||
|
<ul className="wk-briefing-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.url} className="wk-briefing-item">
|
||||||
|
<a href={item.url} target="_blank" rel="noreferrer" className="wk-briefing-link">
|
||||||
|
{item.category && <span className="wk-briefing-cat mono">{item.category}</span>}
|
||||||
|
<span className="wk-briefing-title">{item.title}</span>
|
||||||
|
<span className="wk-briefing-source mono">{item.source}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChampionshipRoundStrip is a compact, progressively-disclosed round indicator.
|
||||||
|
* The canonical contract exposes only the current round and total, so the strip
|
||||||
|
* shows "Round N of M" and links out to Explore for the full calendar rather than
|
||||||
|
* reintroducing the whole calendar on the Weekend home.
|
||||||
|
*/
|
||||||
|
export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) {
|
||||||
|
if (round <= 0 || total <= 0) return null
|
||||||
|
const pct = Math.min(100, Math.round((round / total) * 100))
|
||||||
|
return (
|
||||||
|
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
|
||||||
|
<div className="wk-season-progress">
|
||||||
|
<div className="wk-season-progress-head">
|
||||||
|
<span className="wk-card-title">Season progress</span>
|
||||||
|
<Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
|
||||||
|
Full calendar →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="wk-season-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={round}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-label={`Round ${round} of ${total}`}
|
||||||
|
>
|
||||||
|
<span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="wk-season-progress-label mono">
|
||||||
|
Round {round} of {total}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineNode {
|
||||||
|
key: string
|
||||||
|
session_name: string
|
||||||
|
status: 'done' | 'live' | 'next'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionRail renders the previous/active/next sessions the canonical context
|
||||||
|
* exposes as a compact three-node rail. It is intentionally derived only from the
|
||||||
|
* canonical refs — the contract does not enumerate a full weekend session list.
|
||||||
|
*/
|
||||||
|
export function SessionRail({ nodes }: { nodes: TimelineNode[] }) {
|
||||||
|
if (nodes.length === 0) return null
|
||||||
|
return (
|
||||||
|
<ol className="wk-timeline" data-testid="wk-timeline" role="list">
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<li key={n.key} className={`wk-timeline-node wk-timeline-${n.status}`} role="listitem">
|
||||||
|
<span className="wk-timeline-dot" aria-hidden="true" />
|
||||||
|
<span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
|
||||||
|
<span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the compact session rail from the canonical previous/active/next refs. */
|
||||||
|
export function railNodes(
|
||||||
|
previous: ContextSession | undefined,
|
||||||
|
active: ContextSession | undefined,
|
||||||
|
next: ContextSession | undefined,
|
||||||
|
): TimelineNode[] {
|
||||||
|
const nodes: TimelineNode[] = []
|
||||||
|
if (previous?.session.session_name) {
|
||||||
|
nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' })
|
||||||
|
}
|
||||||
|
if (active?.session.session_name) {
|
||||||
|
nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' })
|
||||||
|
}
|
||||||
|
if (next?.session.session_name) {
|
||||||
|
nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' })
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSessionName(name: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'Practice 1': 'FP1',
|
||||||
|
'Practice 2': 'FP2',
|
||||||
|
'Practice 3': 'FP3',
|
||||||
|
Qualifying: 'Quali',
|
||||||
|
'Sprint Qualifying': 'SQ',
|
||||||
|
'Sprint Shootout': 'SQ',
|
||||||
|
}
|
||||||
|
return map[name] ?? name
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(status: TimelineNode['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'done':
|
||||||
|
return 'Complete'
|
||||||
|
case 'live':
|
||||||
|
return 'Live'
|
||||||
|
default:
|
||||||
|
return 'Next'
|
||||||
|
}
|
||||||
|
}
|
||||||
89
frontend/src/hooks/useWeekendContext.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
|
||||||
|
import { championshipImpact, briefingItems } from '../lib/weekendContext'
|
||||||
|
import type {
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendContext,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export type WeekendLoadState = 'loading' | 'error' | 'ready'
|
||||||
|
|
||||||
|
export interface UseWeekendContextResult {
|
||||||
|
/** Canonical context, present only when loadState === 'ready'. */
|
||||||
|
context: WeekendContext | null
|
||||||
|
loadState: WeekendLoadState
|
||||||
|
error?: Error
|
||||||
|
/** Supplementary championship movers (not part of the #72 contract). */
|
||||||
|
championship?: WeekendChampionshipImpact
|
||||||
|
/** Supplementary briefing items (not part of the #72 contract). */
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
now: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
|
||||||
|
* single source of truth for the Weekend home. When the canonical read succeeds
|
||||||
|
* it layers on two pieces of supplementary data that the contract intentionally
|
||||||
|
* omits — championship movers and the paddock briefing — and nothing else.
|
||||||
|
*
|
||||||
|
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
|
||||||
|
* session / live-state queries: those would defeat the local-first canonical read
|
||||||
|
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
|
||||||
|
* Supplementary queries are gated on a successful canonical read so a failing or
|
||||||
|
* pending endpoint issues no extra requests.
|
||||||
|
*/
|
||||||
|
export function useWeekendContext(): UseWeekendContextResult {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
const nowDate = useMemo(() => new Date(now), [now])
|
||||||
|
|
||||||
|
const contextQuery = useQuery({
|
||||||
|
queryKey: ['weekend-context'],
|
||||||
|
queryFn: fetchWeekendContext,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canonical = contextQuery.data ?? null
|
||||||
|
const canonicalReady = canonical != null
|
||||||
|
const season = canonical?.season
|
||||||
|
|
||||||
|
// Supplementary data — only fetched once the canonical context has resolved,
|
||||||
|
// so a pending/failed canonical read never triggers a request fan-out.
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', season ?? 'current'],
|
||||||
|
queryFn: () => fetchChampionshipHub(season),
|
||||||
|
enabled: canonicalReady,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newsQuery = useQuery({
|
||||||
|
queryKey: ['news', 6],
|
||||||
|
queryFn: () => fetchNews(6),
|
||||||
|
enabled: canonicalReady,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const championship = useMemo(
|
||||||
|
() => championshipImpact(championshipQuery.data),
|
||||||
|
[championshipQuery.data],
|
||||||
|
)
|
||||||
|
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
|
||||||
|
|
||||||
|
let loadState: WeekendLoadState = 'loading'
|
||||||
|
if (contextQuery.isError) loadState = 'error'
|
||||||
|
else if (canonicalReady) loadState = 'ready'
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: canonical,
|
||||||
|
loadState,
|
||||||
|
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
|
||||||
|
championship,
|
||||||
|
briefing,
|
||||||
|
now: nowDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
132
frontend/src/lib/chapters.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Chapter } from '../types'
|
||||||
|
|
||||||
|
export function chapterKindLabel(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'start':
|
||||||
|
return 'GO'
|
||||||
|
case 'safety_car':
|
||||||
|
return 'SC'
|
||||||
|
case 'virtual_safety_car':
|
||||||
|
return 'VSC'
|
||||||
|
case 'red_flag':
|
||||||
|
return 'RF'
|
||||||
|
case 'pit_phase':
|
||||||
|
return 'PIT'
|
||||||
|
case 'decisive_swing':
|
||||||
|
return '▲'
|
||||||
|
case 'finish':
|
||||||
|
return 'FIN'
|
||||||
|
default:
|
||||||
|
return kind.slice(0, 3).toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chapterLapRange(chapter: Chapter): string {
|
||||||
|
if (chapter.start_lap === chapter.end_lap) {
|
||||||
|
return `L${chapter.start_lap}`
|
||||||
|
}
|
||||||
|
return `L${chapter.start_lap}–L${chapter.end_lap}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized scrub position (0–1) for a chapter's start time on the chart axis. */
|
||||||
|
export function chapterStartScrub(
|
||||||
|
chapter: Chapter,
|
||||||
|
tMin: number,
|
||||||
|
tRange: number,
|
||||||
|
): number | null {
|
||||||
|
if (!chapter.start_time || tRange <= 0) return null
|
||||||
|
const ms = new Date(chapter.start_time).getTime()
|
||||||
|
if (Number.isNaN(ms)) return null
|
||||||
|
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized scrub position (0–1) for a chapter's end time on the chart axis. */
|
||||||
|
export function chapterEndScrub(
|
||||||
|
chapter: Chapter,
|
||||||
|
tMin: number,
|
||||||
|
tRange: number,
|
||||||
|
): number | null {
|
||||||
|
const raw = chapter.end_time ?? chapter.start_time
|
||||||
|
if (!raw || tRange <= 0) return null
|
||||||
|
const ms = new Date(raw).getTime()
|
||||||
|
if (Number.isNaN(ms)) return null
|
||||||
|
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the chapter containing the current scrub position, if any.
|
||||||
|
* Uses the same 0–1 clamped bounds as chapterStartScrub/chapterEndScrub so
|
||||||
|
* chapters whose timestamps fall outside the position-sample window still
|
||||||
|
* activate when the scrubber is parked at the clamped edge. */
|
||||||
|
export function activeChapterIndex(
|
||||||
|
chapters: Chapter[],
|
||||||
|
scrubTime: number | null,
|
||||||
|
tMin: number,
|
||||||
|
tRange: number,
|
||||||
|
): number | null {
|
||||||
|
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||||
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
|
const start = chapterStartScrub(chapters[i], tMin, tRange)
|
||||||
|
const end = chapterEndScrub(chapters[i], tMin, tRange)
|
||||||
|
if (start === null || end === null) continue
|
||||||
|
const lo = Math.min(start, end)
|
||||||
|
const hi = Math.max(start, end)
|
||||||
|
if (scrubTime >= lo && scrubTime <= hi) return i
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duration in ms each chapter should play during a ~90s tour. */
|
||||||
|
export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] {
|
||||||
|
if (chapters.length === 0) return []
|
||||||
|
const perChapter = totalMs / chapters.length
|
||||||
|
return chapters.map(() => perChapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decimated position axis labels, e.g. P1, P5, P10, P15, P20. */
|
||||||
|
export function decimatedPositionLabels(maxPos: number): number[] {
|
||||||
|
if (maxPos <= 1) return [1]
|
||||||
|
const labels = new Set<number>([1])
|
||||||
|
for (let pos = 5; pos < maxPos; pos += 5) {
|
||||||
|
labels.add(pos)
|
||||||
|
}
|
||||||
|
labels.add(maxPos)
|
||||||
|
return [...labels].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtle background fill for chapter time-bands on the position graph. */
|
||||||
|
export function chapterBandFill(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'safety_car':
|
||||||
|
return 'rgba(255, 153, 0, 0.14)'
|
||||||
|
case 'virtual_safety_car':
|
||||||
|
return 'rgba(255, 204, 0, 0.12)'
|
||||||
|
case 'red_flag':
|
||||||
|
return 'rgba(230, 36, 41, 0.12)'
|
||||||
|
case 'pit_phase':
|
||||||
|
return 'rgba(96, 165, 250, 0.08)'
|
||||||
|
case 'decisive_swing':
|
||||||
|
return 'rgba(34, 197, 94, 0.08)'
|
||||||
|
case 'finish':
|
||||||
|
return 'rgba(255, 204, 0, 0.06)'
|
||||||
|
default:
|
||||||
|
return 'rgba(255, 255, 255, 0.03)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum vertical spacing between colliding labels (SVG units). */
|
||||||
|
export function deCollideYPositions(
|
||||||
|
items: ReadonlyArray<{ key: string | number; y: number }>,
|
||||||
|
minGap = 12,
|
||||||
|
): Map<string | number, number> {
|
||||||
|
if (items.length === 0) return new Map()
|
||||||
|
const sorted = [...items].sort((a, b) => a.y - b.y)
|
||||||
|
const adjusted = sorted.map((item) => ({ ...item }))
|
||||||
|
for (let i = 1; i < adjusted.length; i++) {
|
||||||
|
const prev = adjusted[i - 1]
|
||||||
|
const curr = adjusted[i]
|
||||||
|
if (curr.y - prev.y < minGap) {
|
||||||
|
curr.y = prev.y + minGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Map(adjusted.map((item) => [item.key, item.y]))
|
||||||
|
}
|
||||||
202
frontend/src/lib/compare.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
|
||||||
|
|
||||||
|
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
|
||||||
|
import type { DeltaSeries } from '../lib/delta'
|
||||||
|
import type {
|
||||||
|
CarDataSample,
|
||||||
|
ComparisonLap,
|
||||||
|
Driver,
|
||||||
|
EnrichedResult,
|
||||||
|
LapsComparisonResponse,
|
||||||
|
} from '../types'
|
||||||
|
import { compareFinishPosition, teamColor } from '../utils'
|
||||||
|
|
||||||
|
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
|
||||||
|
const order = new Map(
|
||||||
|
[...results]
|
||||||
|
.filter((r) => r.position > 0)
|
||||||
|
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||||
|
.map((r, i) => [r.driver_number, i]),
|
||||||
|
)
|
||||||
|
return [...drivers].sort((a, b) => {
|
||||||
|
const ao = order.get(a.driver_number) ?? 999
|
||||||
|
const bo = order.get(b.driver_number) ?? 999
|
||||||
|
if (ao !== bo) return ao - bo
|
||||||
|
return a.driver_number - b.driver_number
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default compare pair: top two classified finishers, else first two session drivers. */
|
||||||
|
export function defaultCompareDriverNumbers(
|
||||||
|
results: EnrichedResult[],
|
||||||
|
drivers: Driver[],
|
||||||
|
): [number, number] | null {
|
||||||
|
const classified = [...results]
|
||||||
|
.filter((r) => r.position > 0)
|
||||||
|
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||||
|
|
||||||
|
if (classified.length >= 2) {
|
||||||
|
return [classified[0].driver_number, classified[1].driver_number]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drivers.length >= 2) {
|
||||||
|
const sorted = sortDriversByResults(drivers, results)
|
||||||
|
return [sorted[0].driver_number, sorted[1].driver_number]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.length === 1 && drivers.length >= 1) {
|
||||||
|
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
|
||||||
|
if (other) return [classified[0].driver_number, other.driver_number]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
|
||||||
|
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
|
||||||
|
if (laps.length === 0) return []
|
||||||
|
|
||||||
|
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
|
||||||
|
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
|
||||||
|
|
||||||
|
for (const lap of laps) {
|
||||||
|
const idx = lap.lap_number - 1
|
||||||
|
if (idx < 0) continue
|
||||||
|
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
|
||||||
|
times[idx] = null
|
||||||
|
} else {
|
||||||
|
times[idx] = lap.lap_duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return times
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
|
||||||
|
let best: ComparisonLap | null = null
|
||||||
|
for (const lap of laps) {
|
||||||
|
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
|
||||||
|
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
|
||||||
|
best = lap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep car-data samples whose timestamps fall within a lap window. */
|
||||||
|
export function filterCarDataToLap(
|
||||||
|
samples: CarDataSample[],
|
||||||
|
lap: ComparisonLap,
|
||||||
|
): CarDataSample[] {
|
||||||
|
if (!lap.date_start || samples.length === 0) return samples
|
||||||
|
|
||||||
|
const start = new Date(lap.date_start).getTime()
|
||||||
|
if (!Number.isFinite(start)) return samples
|
||||||
|
|
||||||
|
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
|
||||||
|
|
||||||
|
return samples.filter((s) => {
|
||||||
|
const t = new Date(s.date).getTime()
|
||||||
|
return Number.isFinite(t) && t >= start && t <= end
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function carDataToTraceSeries(
|
||||||
|
samples: CarDataSample[],
|
||||||
|
label: string,
|
||||||
|
color: string,
|
||||||
|
): TelemetryTraceSeries {
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
samples: samples.map((s) => ({
|
||||||
|
speed: s.speed,
|
||||||
|
throttle: s.throttle,
|
||||||
|
brake: s.brake,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBestLapTraceSeries(
|
||||||
|
carData: CarDataSample[],
|
||||||
|
laps: ComparisonLap[],
|
||||||
|
label: string,
|
||||||
|
color: string,
|
||||||
|
): TelemetryTraceSeries | null {
|
||||||
|
const best = findBestLap(laps)
|
||||||
|
if (!best) return null
|
||||||
|
|
||||||
|
const filtered = filterCarDataToLap(carData, best)
|
||||||
|
if (filtered.length === 0) return null
|
||||||
|
|
||||||
|
return carDataToTraceSeries(filtered, label, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverMeta(
|
||||||
|
driverNumber: number,
|
||||||
|
comparison: LapsComparisonResponse | undefined,
|
||||||
|
drivers: Driver[],
|
||||||
|
): { label: string; color: string; laps: ComparisonLap[] } {
|
||||||
|
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
|
||||||
|
const session = drivers.find((d) => d.driver_number === driverNumber)
|
||||||
|
return {
|
||||||
|
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
|
||||||
|
color: teamColor(comp?.team_colour || session?.team_colour),
|
||||||
|
laps: comp?.laps ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function comparisonToDeltaSeries(
|
||||||
|
comparison: LapsComparisonResponse,
|
||||||
|
driverNumbers: [number, number],
|
||||||
|
drivers: Driver[],
|
||||||
|
): DeltaSeries[] {
|
||||||
|
return driverNumbers.map((dn) => {
|
||||||
|
const meta = driverMeta(dn, comparison, drivers)
|
||||||
|
return {
|
||||||
|
label: meta.label,
|
||||||
|
color: meta.color,
|
||||||
|
lapTimes: lapsToLapTimes(meta.laps),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPitLapsCaption(
|
||||||
|
pitLaps: Record<string, number[]>,
|
||||||
|
driverNumbers: [number, number],
|
||||||
|
comparison: LapsComparisonResponse | undefined,
|
||||||
|
drivers: Driver[],
|
||||||
|
): string | null {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
for (const dn of driverNumbers) {
|
||||||
|
const laps = pitLaps[String(dn)]
|
||||||
|
if (!laps?.length) continue
|
||||||
|
const meta = driverMeta(dn, comparison, drivers)
|
||||||
|
parts.push(`${meta.label}: L${laps.join(', L')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareDriverOptions(
|
||||||
|
drivers: Driver[],
|
||||||
|
results: EnrichedResult[],
|
||||||
|
): Driver[] {
|
||||||
|
if (drivers.length > 0) {
|
||||||
|
return sortDriversByResults(drivers, results)
|
||||||
|
}
|
||||||
|
return results.map((r) => ({
|
||||||
|
driver_number: r.driver_number,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
full_name: r.full_name,
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: r.full_name,
|
||||||
|
session_key: r.session_key,
|
||||||
|
meeting_key: r.meeting_key,
|
||||||
|
}))
|
||||||
|
}
|
||||||
127
frontend/src/lib/delta.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Cumulative lap-time delta math for driver comparison charts.
|
||||||
|
// Pure functions only — no React — so everything is unit-testable.
|
||||||
|
|
||||||
|
export interface DeltaSeries {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
/** Lap duration in seconds; null = no time (pit/out lap). */
|
||||||
|
lapTimes: (number | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverDeltaResult {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
/** Cumulative delta vs reference (seconds); null when that lap has no time. */
|
||||||
|
deltas: (number | null)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */
|
||||||
|
export function formatDeltaSeconds(delta: number): string {
|
||||||
|
const sign = delta >= 0 ? '+' : ''
|
||||||
|
return `${sign}${delta.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cumulative lap time aligned to reference-valid laps only.
|
||||||
|
* Laps where the reference is null are skipped for every series so later deltas
|
||||||
|
* do not compare against a frozen baseline while challengers keep accumulating.
|
||||||
|
*/
|
||||||
|
function buildAlignedCumulative(
|
||||||
|
lapTimes: ReadonlyArray<number | null>,
|
||||||
|
referenceLapTimes: ReadonlyArray<number | null>,
|
||||||
|
): number[] {
|
||||||
|
const cumulative: number[] = []
|
||||||
|
let running = 0
|
||||||
|
const length = Math.max(lapTimes.length, referenceLapTimes.length)
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
if (referenceLapTimes[i] == null) {
|
||||||
|
cumulative.push(running)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const lap = lapTimes[i]
|
||||||
|
if (lap != null) {
|
||||||
|
running += lap
|
||||||
|
}
|
||||||
|
cumulative.push(running)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cumulative
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReference(
|
||||||
|
series: ReadonlyArray<DeltaSeries>,
|
||||||
|
referenceLabel?: string,
|
||||||
|
): DeltaSeries | null {
|
||||||
|
if (series.length === 0) return null
|
||||||
|
if (referenceLabel) {
|
||||||
|
return series.find((s) => s.label === referenceLabel) ?? series[0]
|
||||||
|
}
|
||||||
|
return series[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute per-lap cumulative time delta for each non-reference driver.
|
||||||
|
* Positive = behind reference; negative = ahead.
|
||||||
|
* Deltas are only emitted where the reference lap is valid; reference-null laps
|
||||||
|
* gap every series. Challenger-null laps gap only that driver's line.
|
||||||
|
*/
|
||||||
|
export function computeCumulativeDeltas(
|
||||||
|
series: ReadonlyArray<DeltaSeries>,
|
||||||
|
referenceLabel?: string,
|
||||||
|
): DriverDeltaResult[] {
|
||||||
|
const reference = resolveReference(series, referenceLabel)
|
||||||
|
if (!reference) return []
|
||||||
|
|
||||||
|
const refLapTimes = reference.lapTimes
|
||||||
|
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
|
||||||
|
|
||||||
|
return series
|
||||||
|
.filter((s) => s.label !== reference.label)
|
||||||
|
.map((driver) => {
|
||||||
|
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
|
||||||
|
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
|
||||||
|
const deltas: (number | null)[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < lapCount; i++) {
|
||||||
|
if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
|
||||||
|
deltas.push(null)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deltas.push(driverCumulative[i] - refCumulative[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: driver.label,
|
||||||
|
color: driver.color,
|
||||||
|
deltas,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */
|
||||||
|
export function deltaPolylineSegments(
|
||||||
|
deltas: ReadonlyArray<number | null>,
|
||||||
|
toPoint: (lapIndex: number, delta: number) => string,
|
||||||
|
): string[] {
|
||||||
|
const segments: string[] = []
|
||||||
|
let current: string[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < deltas.length; i++) {
|
||||||
|
const value = deltas[i]
|
||||||
|
if (value === null) {
|
||||||
|
if (current.length > 0) {
|
||||||
|
segments.push(current.join(' '))
|
||||||
|
current = []
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current.push(toPoint(i, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
segments.push(current.join(' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
293
frontend/src/lib/digest.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { meetingEndTime, meetingStartTime, mostRecentPastMeeting, nextUpcomingMeeting } from './schedule'
|
||||||
|
import type { ChampHubDriver, ChampHubTeam, Meeting, NewsItem } from '../types'
|
||||||
|
|
||||||
|
export interface GPWindow {
|
||||||
|
meeting_key: number
|
||||||
|
meeting_name: string
|
||||||
|
/** Inclusive lower bound: end of previous GP, or open for season opener. */
|
||||||
|
start: Date | null
|
||||||
|
/** Exclusive upper bound: start of next GP, or open after the final round. */
|
||||||
|
end: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestTag {
|
||||||
|
kind: 'driver' | 'team'
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaggedNewsItem = NewsItem & { tags: DigestTag[] }
|
||||||
|
|
||||||
|
export const RECENT_BUCKET_KEY = '__recent__'
|
||||||
|
|
||||||
|
interface MatchCandidate {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
tag: DigestTag
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagPattern {
|
||||||
|
pattern: string
|
||||||
|
tag: DigestTag
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMeetings(meetings: Meeting[]): Meeting[] {
|
||||||
|
return [...meetings].sort((a, b) => {
|
||||||
|
const left = meetingStartTime(a)?.getTime() ?? 0
|
||||||
|
const right = meetingStartTime(b)?.getTime() ?? 0
|
||||||
|
if (left !== right) return left - right
|
||||||
|
return a.meeting_name.localeCompare(b.meeting_name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive inter-race windows [prev GP end → next GP start] for each round. */
|
||||||
|
export function gpWindows(meetings: Meeting[], _now: Date): GPWindow[] {
|
||||||
|
const sorted = sortMeetings(meetings)
|
||||||
|
return sorted.map((meeting, index) => ({
|
||||||
|
meeting_key: meeting.meeting_key,
|
||||||
|
meeting_name: meeting.meeting_name,
|
||||||
|
start: index > 0 ? meetingEndTime(sorted[index - 1]) : null,
|
||||||
|
end: index < sorted.length - 1 ? meetingStartTime(sorted[index + 1]) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemPublishedAt(item: NewsItem): Date | null {
|
||||||
|
if (!item.published_at) return null
|
||||||
|
const parsed = Date.parse(item.published_at)
|
||||||
|
return Number.isNaN(parsed) ? null : new Date(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemInWindow(published: Date, window: GPWindow): boolean {
|
||||||
|
if (window.start && published < window.start) return false
|
||||||
|
if (window.end && published >= window.end) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowForDate(windows: GPWindow[], published: Date): GPWindow | null {
|
||||||
|
const matches = windows.filter((window) => itemInWindow(published, window))
|
||||||
|
if (matches.length === 0) return null
|
||||||
|
return matches.reduce((best, window) => {
|
||||||
|
const bestStart = best.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||||
|
const windowStart = window.start?.getTime() ?? Number.NEGATIVE_INFINITY
|
||||||
|
return windowStart >= bestStart ? window : best
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupedDigest {
|
||||||
|
windows: Array<{ window: GPWindow; items: NewsItem[] }>
|
||||||
|
recent: NewsItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket news items into GP windows; undated items land in `recent`. */
|
||||||
|
export function groupByWindow(items: NewsItem[], windows: GPWindow[]): GroupedDigest {
|
||||||
|
const buckets = new Map<number, NewsItem[]>()
|
||||||
|
const recent: NewsItem[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const published = itemPublishedAt(item)
|
||||||
|
if (!published) {
|
||||||
|
recent.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const match = windowForDate(windows, published)
|
||||||
|
if (!match) {
|
||||||
|
recent.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const list = buckets.get(match.meeting_key) ?? []
|
||||||
|
list.push(item)
|
||||||
|
buckets.set(match.meeting_key, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = windows
|
||||||
|
.map((window) => ({
|
||||||
|
window,
|
||||||
|
items: buckets.get(window.meeting_key) ?? [],
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.items.length > 0)
|
||||||
|
|
||||||
|
return { windows: grouped, recent }
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverLastName(fullName: string): string | null {
|
||||||
|
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
return parts[parts.length - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagPatterns(drivers: ChampHubDriver[], teams: ChampHubTeam[]): TagPattern[] {
|
||||||
|
const patterns: TagPattern[] = []
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
if (!team.team_name.trim()) continue
|
||||||
|
patterns.push({
|
||||||
|
pattern: team.team_name.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
kind: 'team',
|
||||||
|
key: `team:${team.team_name}`,
|
||||||
|
label: team.team_name,
|
||||||
|
colour: team.team_colour,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const driver of drivers) {
|
||||||
|
const colour = driver.team_colour
|
||||||
|
const base = {
|
||||||
|
kind: 'driver' as const,
|
||||||
|
colour,
|
||||||
|
}
|
||||||
|
if (driver.full_name.trim()) {
|
||||||
|
patterns.push({
|
||||||
|
pattern: driver.full_name.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
...base,
|
||||||
|
key: `driver:${driver.name_acronym}`,
|
||||||
|
label: driver.name_acronym,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const lastName = driverLastName(driver.full_name)
|
||||||
|
if (lastName) {
|
||||||
|
patterns.push({
|
||||||
|
pattern: lastName.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
...base,
|
||||||
|
key: `driver:${driver.name_acronym}`,
|
||||||
|
label: driver.name_acronym,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (driver.name_acronym.trim()) {
|
||||||
|
patterns.push({
|
||||||
|
pattern: driver.name_acronym.toLowerCase(),
|
||||||
|
tag: {
|
||||||
|
...base,
|
||||||
|
key: `driver:${driver.name_acronym}`,
|
||||||
|
label: driver.name_acronym,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns.sort((a, b) => b.pattern.length - a.pattern.length)
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(a: MatchCandidate, b: MatchCandidate): boolean {
|
||||||
|
return a.start < b.end && b.start < a.end
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag items via case-insensitive substring match on title + summary.
|
||||||
|
* Longest pattern wins on overlapping spans (v1 string match — no NLP).
|
||||||
|
*
|
||||||
|
* Known limitations: common-word last names, team names inside other words,
|
||||||
|
* and driver TLAs that appear in unrelated acronyms can false-positive.
|
||||||
|
*/
|
||||||
|
export function tagItems(
|
||||||
|
items: NewsItem[],
|
||||||
|
drivers: ChampHubDriver[],
|
||||||
|
teams: ChampHubTeam[],
|
||||||
|
): TaggedNewsItem[] {
|
||||||
|
const patterns = buildTagPatterns(drivers, teams)
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const haystack = `${item.title} ${item.summary ?? ''}`.toLowerCase()
|
||||||
|
const accepted: MatchCandidate[] = []
|
||||||
|
|
||||||
|
for (const { pattern, tag } of patterns) {
|
||||||
|
if (!pattern) continue
|
||||||
|
let from = 0
|
||||||
|
while (from <= haystack.length - pattern.length) {
|
||||||
|
const index = haystack.indexOf(pattern, from)
|
||||||
|
if (index === -1) break
|
||||||
|
const candidate: MatchCandidate = {
|
||||||
|
start: index,
|
||||||
|
end: index + pattern.length,
|
||||||
|
tag,
|
||||||
|
}
|
||||||
|
if (!accepted.some((match) => overlaps(match, candidate))) {
|
||||||
|
accepted.push(candidate)
|
||||||
|
}
|
||||||
|
from = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsByKey = new Map<string, DigestTag>()
|
||||||
|
for (const match of accepted) {
|
||||||
|
tagsByKey.set(match.tag.key, match.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
tags: [...tagsByKey.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeDigestWindow(
|
||||||
|
windows: GPWindow[],
|
||||||
|
meetings: Meeting[],
|
||||||
|
now: Date,
|
||||||
|
): GPWindow | null {
|
||||||
|
const next = nextUpcomingMeeting(meetings, now)
|
||||||
|
if (next) {
|
||||||
|
return windows.find((window) => window.meeting_key === next.meeting_key) ?? null
|
||||||
|
}
|
||||||
|
const recent = mostRecentPastMeeting(meetings, now)
|
||||||
|
if (recent) {
|
||||||
|
return windows.find((window) => window.meeting_key === recent.meeting_key) ?? null
|
||||||
|
}
|
||||||
|
return windows[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sinceLastLabel(meetings: Meeting[], now: Date): string {
|
||||||
|
const last = mostRecentPastMeeting(meetings, now)
|
||||||
|
if (last) return last.meeting_name
|
||||||
|
const next = nextUpcomingMeeting(meetings, now)
|
||||||
|
if (next) return `Before ${next.meeting_name}`
|
||||||
|
return 'Pre-season'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemsForWindow(items: TaggedNewsItem[], window: GPWindow | null): TaggedNewsItem[] {
|
||||||
|
if (!window) return []
|
||||||
|
return items.filter((item) => {
|
||||||
|
const published = itemPublishedAt(item)
|
||||||
|
return published != null && itemInWindow(published, window)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topTags(items: TaggedNewsItem[], limit = 5): DigestTag[] {
|
||||||
|
const counts = new Map<string, { tag: DigestTag; count: number }>()
|
||||||
|
for (const item of items) {
|
||||||
|
for (const tag of item.tags) {
|
||||||
|
const current = counts.get(tag.key)
|
||||||
|
if (current) current.count += 1
|
||||||
|
else counts.set(tag.key, { tag, count: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.values()]
|
||||||
|
.sort((a, b) => b.count - a.count || a.tag.label.localeCompare(b.tag.label))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((entry) => entry.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByTag(items: TaggedNewsItem[], tagKey: string | null): TaggedNewsItem[] {
|
||||||
|
if (!tagKey) return items
|
||||||
|
return items.filter((item) => item.tags.some((tag) => tag.key === tagKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagColour(colour: string): string {
|
||||||
|
if (!colour) return 'var(--text-3)'
|
||||||
|
return colour.startsWith('#') ? colour : `#${colour}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortWindowBucketsNewestFirst<T extends { window: GPWindow }>(buckets: T[]): T[] {
|
||||||
|
return [...buckets].sort((a, b) => {
|
||||||
|
const left = a.window.start?.getTime() ?? 0
|
||||||
|
const right = b.window.start?.getTime() ?? 0
|
||||||
|
return right - left
|
||||||
|
})
|
||||||
|
}
|
||||||
69
frontend/src/lib/driverProfile.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { DriverSummaryRound } from '../types'
|
||||||
|
|
||||||
|
/** One round mapped for the grid-vs-finish chart and results table. */
|
||||||
|
export interface RoundDelta {
|
||||||
|
round: number // 1-based round index within the completed season
|
||||||
|
label: string // short GP label, e.g. "Bahrain"
|
||||||
|
grid: number | null
|
||||||
|
finish: number | null
|
||||||
|
/** grid − finish: positive = places gained on Sunday, null when unknowable. */
|
||||||
|
delta: number | null
|
||||||
|
points: number
|
||||||
|
status: 'classified' | 'dnf' | 'dns' | 'dsq' | 'absent'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip the "Grand Prix" boilerplate so round labels fit chart axes. */
|
||||||
|
export function shortGpLabel(meetingName: string): string {
|
||||||
|
const short = meetingName
|
||||||
|
.replace(/\s*grand prix\s*/i, ' ')
|
||||||
|
.replace(/\s+gp\s*$/i, ' ')
|
||||||
|
.trim()
|
||||||
|
return short || meetingName
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundStatus(r: DriverSummaryRound): RoundDelta['status'] {
|
||||||
|
if (r.dns) return 'dns'
|
||||||
|
if (r.dsq) return 'dsq'
|
||||||
|
if (r.dnf) return 'dnf'
|
||||||
|
if (r.race_position <= 0 && r.grid_position <= 0) return 'absent'
|
||||||
|
return 'classified'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map summary rounds to grid-vs-finish deltas. Positions of 0 mean "no data"
|
||||||
|
* (driver absent, pit-lane start, …) and become null; a delta is only computed
|
||||||
|
* when both ends are known and the driver actually started the race.
|
||||||
|
*/
|
||||||
|
export function gridFinishDeltas(rounds: DriverSummaryRound[]): RoundDelta[] {
|
||||||
|
return rounds.map((r, i) => {
|
||||||
|
const status = roundStatus(r)
|
||||||
|
const grid = r.grid_position > 0 ? r.grid_position : null
|
||||||
|
const finish = r.race_position > 0 ? r.race_position : null
|
||||||
|
const delta =
|
||||||
|
grid != null && finish != null && status !== 'dns' && status !== 'absent'
|
||||||
|
? grid - finish
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
round: i + 1,
|
||||||
|
label: shortGpLabel(r.meeting_name),
|
||||||
|
grid,
|
||||||
|
finish,
|
||||||
|
delta,
|
||||||
|
points: r.points,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'P4' for known positions, '—' when unknown. */
|
||||||
|
export function formatPosition(pos: number | null): string {
|
||||||
|
return pos != null && pos > 0 ? `P${pos}` : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '+3' (gained), '−2' (lost), '=' (held), '—' (unknown). */
|
||||||
|
export function formatDelta(delta: number | null): string {
|
||||||
|
if (delta == null) return '—'
|
||||||
|
if (delta > 0) return `+${delta}`
|
||||||
|
if (delta < 0) return `−${Math.abs(delta)}`
|
||||||
|
return '='
|
||||||
|
}
|
||||||
318
frontend/src/lib/events.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
// "What just happened" event synthesis for the live page.
|
||||||
|
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||||
|
// Mirrors the gapHistory/battles precedent: successive LiveStreamData snapshots
|
||||||
|
// in, typed events out; the page owns the buffer, this module owns the diffing.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LiveDriverData,
|
||||||
|
LiveRCMessage,
|
||||||
|
LiveSessionMeta,
|
||||||
|
LiveStreamData,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export const MAX_EVENTS = 30
|
||||||
|
|
||||||
|
export type LiveEventKind =
|
||||||
|
| 'overtake'
|
||||||
|
| 'position-gain'
|
||||||
|
| 'position-loss'
|
||||||
|
| 'pit-in'
|
||||||
|
| 'pit-out'
|
||||||
|
| 'personal-best'
|
||||||
|
| 'fastest-lap'
|
||||||
|
| 'retirement'
|
||||||
|
| 'track-status'
|
||||||
|
| 'race-control'
|
||||||
|
|
||||||
|
export interface LiveEvent {
|
||||||
|
id: string
|
||||||
|
kind: LiveEventKind
|
||||||
|
lap: number
|
||||||
|
timestamp: string
|
||||||
|
headline: string
|
||||||
|
detail?: string
|
||||||
|
racingNumbers: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of the running session, used to reset the event buffer when a new
|
||||||
|
* session starts. LiveSessionMeta carries no numeric session key, so the
|
||||||
|
* meeting + session name/type triple stands in for one.
|
||||||
|
*/
|
||||||
|
export function sessionSignature(session: LiveSessionMeta | null | undefined): string {
|
||||||
|
if (!session) return ''
|
||||||
|
return [session.MeetingName, session.SessionName, session.SessionType].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend freshly diffed events onto the newest-first buffer, dropping any
|
||||||
|
* whose id is already present (re-renders and replayed diffs must not
|
||||||
|
* duplicate) and evicting the oldest events beyond `cap`.
|
||||||
|
*/
|
||||||
|
export function appendEvents(
|
||||||
|
buffer: ReadonlyArray<LiveEvent>,
|
||||||
|
incoming: ReadonlyArray<LiveEvent>,
|
||||||
|
cap = MAX_EVENTS,
|
||||||
|
): LiveEvent[] {
|
||||||
|
const seen = new Set(buffer.map((event) => event.id))
|
||||||
|
const fresh = incoming.filter((event) => {
|
||||||
|
if (seen.has(event.id)) return false
|
||||||
|
seen.add(event.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (fresh.length === 0) return [...buffer]
|
||||||
|
// Within one diff the array is oldest-first; reverse so the last-produced
|
||||||
|
// event ends up at the top of the newest-first rail.
|
||||||
|
return [...fresh.reverse(), ...buffer].slice(0, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_STATUS_HEADLINES: Record<string, string> = {
|
||||||
|
'1': 'Green flag — track clear',
|
||||||
|
'2': 'Yellow flag',
|
||||||
|
'4': 'Safety Car deployed',
|
||||||
|
'5': 'Red flag — session stopped',
|
||||||
|
'6': 'Virtual Safety Car deployed',
|
||||||
|
'7': 'Virtual Safety Car ending',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RC_CATEGORIES = new Set(['flag', 'safetycar', 'drs'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff two successive live snapshots into a chronological list of synthesized
|
||||||
|
* events. Callers are expected to feed genuinely consecutive snapshots of the
|
||||||
|
* same session; diffing across a session boundary is the caller's job to
|
||||||
|
* prevent (see sessionSignature).
|
||||||
|
*/
|
||||||
|
export function diffSnapshots(prev: LiveStreamData, next: LiveStreamData): LiveEvent[] {
|
||||||
|
if (!prev || !next || prev === next) return []
|
||||||
|
|
||||||
|
const events: LiveEvent[] = []
|
||||||
|
const lap = next.CurrentLap || 0
|
||||||
|
const timestamp = next.Clock || ''
|
||||||
|
const code = (racingNumber: string): string =>
|
||||||
|
next.DriverInfo?.[racingNumber]?.Tla ||
|
||||||
|
prev.DriverInfo?.[racingNumber]?.Tla ||
|
||||||
|
`#${racingNumber}`
|
||||||
|
|
||||||
|
diffTrackStatus(prev, next, lap, timestamp, events)
|
||||||
|
diffRaceControl(prev, next, events)
|
||||||
|
|
||||||
|
const prevDrivers = prev.Drivers ?? {}
|
||||||
|
const nextDrivers = next.Drivers ?? {}
|
||||||
|
const numbers = Object.keys(nextDrivers).filter((number) => prevDrivers[number])
|
||||||
|
|
||||||
|
// Racing numbers whose position loss is already explained by an overtake
|
||||||
|
// event, so we don't also report a generic "drops to Pn".
|
||||||
|
const overtakenNumbers = new Set<string>()
|
||||||
|
|
||||||
|
for (const number of numbers) {
|
||||||
|
const before = prevDrivers[number]
|
||||||
|
const after = nextDrivers[number]
|
||||||
|
|
||||||
|
if (!before.Retired && after.Retired) {
|
||||||
|
events.push({
|
||||||
|
id: `ret:${number}`,
|
||||||
|
kind: 'retirement',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} retires`,
|
||||||
|
detail: before.Position > 0 ? `Was running P${before.Position}` : undefined,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!before.InPit && after.InPit && !after.Retired) {
|
||||||
|
const fromPosition = before.Position > 0 ? ` from P${before.Position}` : ''
|
||||||
|
events.push({
|
||||||
|
id: `pit-in:${number}:l${lap}`,
|
||||||
|
kind: 'pit-in',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} pits${fromPosition}`,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before.InPit && !after.InPit && !after.Retired) {
|
||||||
|
const compound = next.Tyres?.[number]?.Compound || ''
|
||||||
|
const stops = Math.max(0, (next.Stints?.[number]?.length ?? 0) - 1)
|
||||||
|
const parts = [compound, stops > 0 ? `${ordinal(stops)} stop` : ''].filter(Boolean)
|
||||||
|
const rejoined = after.Position > 0 ? ` P${after.Position}` : ''
|
||||||
|
events.push({
|
||||||
|
id: `pit-out:${number}:l${lap}`,
|
||||||
|
kind: 'pit-out',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} rejoins${rejoined}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
diffBestLap(number, before, after, code, lap, timestamp, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position gains, with overtake attribution via position swaps.
|
||||||
|
for (const number of numbers) {
|
||||||
|
const before = prevDrivers[number]
|
||||||
|
const after = nextDrivers[number]
|
||||||
|
if (before.Position <= 0 || after.Position <= 0) continue
|
||||||
|
if (after.Position >= before.Position) continue
|
||||||
|
if (after.InPit || after.Retired) continue
|
||||||
|
|
||||||
|
const overtaken = numbers.find((other) => {
|
||||||
|
if (other === number) return false
|
||||||
|
const otherBefore = prevDrivers[other]
|
||||||
|
const otherAfter = nextDrivers[other]
|
||||||
|
return (
|
||||||
|
otherBefore.Position === after.Position &&
|
||||||
|
otherAfter.Position > otherBefore.Position &&
|
||||||
|
!otherAfter.InPit &&
|
||||||
|
!otherAfter.Retired
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (overtaken) {
|
||||||
|
overtakenNumbers.add(overtaken)
|
||||||
|
events.push({
|
||||||
|
id: `overtake:${number}:${overtaken}:p${after.Position}:l${lap}`,
|
||||||
|
kind: 'overtake',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} overtakes ${code(overtaken)} for P${after.Position}`,
|
||||||
|
detail: before.Position - after.Position > 1 ? `Up from P${before.Position}` : undefined,
|
||||||
|
racingNumbers: [number, overtaken],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
events.push({
|
||||||
|
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
|
||||||
|
kind: 'position-gain',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} moves up to P${after.Position} (from P${before.Position})`,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position losses not already explained by an overtake or a pit visit.
|
||||||
|
for (const number of numbers) {
|
||||||
|
const before = prevDrivers[number]
|
||||||
|
const after = nextDrivers[number]
|
||||||
|
if (before.Position <= 0 || after.Position <= 0) continue
|
||||||
|
if (after.Position <= before.Position) continue
|
||||||
|
if (overtakenNumbers.has(number)) continue
|
||||||
|
if (after.InPit || after.PitOut || after.Retired || before.InPit) continue
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: `pos:${number}:${before.Position}-${after.Position}:l${lap}`,
|
||||||
|
kind: 'position-loss',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} drops to P${after.Position} (from P${before.Position})`,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffTrackStatus(
|
||||||
|
prev: LiveStreamData,
|
||||||
|
next: LiveStreamData,
|
||||||
|
lap: number,
|
||||||
|
timestamp: string,
|
||||||
|
events: LiveEvent[],
|
||||||
|
): void {
|
||||||
|
if (!prev.TrackStatus || !next.TrackStatus) return
|
||||||
|
if (prev.TrackStatus === next.TrackStatus) return
|
||||||
|
events.push({
|
||||||
|
id: `track:${next.TrackStatus}:l${lap}`,
|
||||||
|
kind: 'track-status',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: TRACK_STATUS_HEADLINES[next.TrackStatus] ?? `Track status ${next.TrackStatus}`,
|
||||||
|
racingNumbers: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffRaceControl(prev: LiveStreamData, next: LiveStreamData, events: LiveEvent[]): void {
|
||||||
|
const prevMessages = prev.RCMessages ?? []
|
||||||
|
const seen = new Set(prevMessages.map(rcMessageKey))
|
||||||
|
for (const message of next.RCMessages ?? []) {
|
||||||
|
if (!message.Message || seen.has(rcMessageKey(message))) continue
|
||||||
|
if (!RC_CATEGORIES.has((message.Category || '').toLowerCase())) continue
|
||||||
|
events.push({
|
||||||
|
id: `rc:${rcMessageKey(message)}`,
|
||||||
|
kind: 'race-control',
|
||||||
|
lap: message.Lap || 0,
|
||||||
|
timestamp: message.Time || '',
|
||||||
|
headline: humanizeRaceControl(message.Message),
|
||||||
|
racingNumbers: carNumbersIn(message.Message),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffBestLap(
|
||||||
|
number: string,
|
||||||
|
before: LiveDriverData,
|
||||||
|
after: LiveDriverData,
|
||||||
|
code: (racingNumber: string) => string,
|
||||||
|
lap: number,
|
||||||
|
timestamp: string,
|
||||||
|
events: LiveEvent[],
|
||||||
|
): void {
|
||||||
|
if (!after.LastLapTime) return
|
||||||
|
const isNewLap = after.LastLapTime !== before.LastLapTime
|
||||||
|
|
||||||
|
if (after.LastLapOB && (isNewLap || !before.LastLapOB)) {
|
||||||
|
events.push({
|
||||||
|
id: `ob:${number}:${after.LastLapTime}`,
|
||||||
|
kind: 'fastest-lap',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} sets the fastest lap — ${after.LastLapTime}`,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (after.LastLapPB && !after.LastLapOB && (isNewLap || !before.LastLapPB)) {
|
||||||
|
events.push({
|
||||||
|
id: `pb:${number}:${after.LastLapTime}`,
|
||||||
|
kind: 'personal-best',
|
||||||
|
lap,
|
||||||
|
timestamp,
|
||||||
|
headline: `${code(number)} sets a personal best — ${after.LastLapTime}`,
|
||||||
|
racingNumbers: [number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rcMessageKey(message: LiveRCMessage): string {
|
||||||
|
return `${message.Time}:${message.Message}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "SAFETY CAR DEPLOYED" -> "Safety car deployed", keeping known acronyms upper-cased. */
|
||||||
|
export function humanizeRaceControl(message: string): string {
|
||||||
|
const lower = message.trim().toLowerCase()
|
||||||
|
if (!lower) return ''
|
||||||
|
const sentence = lower.charAt(0).toUpperCase() + lower.slice(1)
|
||||||
|
return sentence.replace(/\b(drs|vsc|sc|fia)\b/gi, (acronym) => acronym.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function carNumbersIn(message: string): string[] {
|
||||||
|
const numbers: string[] = []
|
||||||
|
for (const match of message.matchAll(/CAR (\d+)/gi)) {
|
||||||
|
if (!numbers.includes(match[1])) numbers.push(match[1])
|
||||||
|
}
|
||||||
|
return numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n: number): string {
|
||||||
|
const rem10 = n % 10
|
||||||
|
const rem100 = n % 100
|
||||||
|
if (rem10 === 1 && rem100 !== 11) return `${n}st`
|
||||||
|
if (rem10 === 2 && rem100 !== 12) return `${n}nd`
|
||||||
|
if (rem10 === 3 && rem100 !== 13) return `${n}rd`
|
||||||
|
return `${n}th`
|
||||||
|
}
|
||||||
@@ -47,7 +47,9 @@ export function countryDecal(meeting: Meeting | undefined | null): string {
|
|||||||
return name.slice(0, 3).toUpperCase() || '—'
|
return name.slice(0, 3).toUpperCase() || '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countryFlag(meeting: Meeting | undefined | null): string {
|
export function countryFlag(
|
||||||
|
meeting: Pick<Meeting, 'country_code' | 'country_flag'> | undefined | null,
|
||||||
|
): string {
|
||||||
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
if (meeting?.country_flag && !/^https?:\/\//i.test(meeting.country_flag)) return meeting.country_flag
|
||||||
const code = meeting?.country_code?.toUpperCase()
|
const code = meeting?.country_code?.toUpperCase()
|
||||||
const iso2 = code ? CODE3_TO_2[code] : ''
|
const iso2 = code ? CODE3_TO_2[code] : ''
|
||||||
|
|||||||
47
frontend/src/lib/h2h.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ChampHubDriver } from '../types'
|
||||||
|
|
||||||
|
export interface TeammatePair {
|
||||||
|
teamName: string
|
||||||
|
teamColour: string
|
||||||
|
driverA: ChampHubDriver
|
||||||
|
driverB: ChampHubDriver
|
||||||
|
/** Drivers on the team beyond the top two (shown as +N). */
|
||||||
|
extraCount: number
|
||||||
|
/** Absolute win gap — smaller means a closer battle. */
|
||||||
|
closeness: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pair teammates per team for H2H display; skip single-driver teams. */
|
||||||
|
export function teammatePairs(drivers: ReadonlyArray<ChampHubDriver>): TeammatePair[] {
|
||||||
|
const byTeam = new Map<string, ChampHubDriver[]>()
|
||||||
|
for (const d of drivers) {
|
||||||
|
const list = byTeam.get(d.team_name) ?? []
|
||||||
|
list.push(d)
|
||||||
|
byTeam.set(d.team_name, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairs: TeammatePair[] = []
|
||||||
|
for (const [teamName, members] of byTeam) {
|
||||||
|
if (members.length < 2) continue
|
||||||
|
|
||||||
|
const sorted = [...members].sort((a, b) => b.points - a.points)
|
||||||
|
const driverA = sorted[0]
|
||||||
|
const driverB = sorted[1]
|
||||||
|
const winsA = driverA.teammate_wins
|
||||||
|
const winsB = driverB.teammate_wins
|
||||||
|
|
||||||
|
pairs.push({
|
||||||
|
teamName,
|
||||||
|
teamColour: driverA.team_colour,
|
||||||
|
driverA,
|
||||||
|
driverB,
|
||||||
|
extraCount: members.length - 2,
|
||||||
|
closeness: Math.abs(winsA - winsB),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs.sort((a, b) => {
|
||||||
|
if (a.closeness !== b.closeness) return a.closeness - b.closeness
|
||||||
|
return a.teamName.localeCompare(b.teamName)
|
||||||
|
})
|
||||||
|
}
|
||||||
146
frontend/src/lib/meaning.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Pure interpretation helpers for pairing numbers with their "so-what".
|
||||||
|
// Thresholds are exported consts so they are cheap to tune in one place.
|
||||||
|
|
||||||
|
import { PIT_LOSS_SECONDS } from './tyredeg'
|
||||||
|
|
||||||
|
/** Gaps under this (seconds) are DRS attack range. */
|
||||||
|
export const INTERVAL_DRS_MAX_SECONDS = 1.0
|
||||||
|
|
||||||
|
/** Lower bound of the undercut window (seconds); contiguous with DRS range. */
|
||||||
|
export const INTERVAL_UNDERCUT_MIN_SECONDS = INTERVAL_DRS_MAX_SECONDS
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound of the undercut window (seconds). Kept well below typical pit
|
||||||
|
* loss ({@link PIT_LOSS_SECONDS}s) — only a few seconds matter for strategy.
|
||||||
|
*/
|
||||||
|
export const INTERVAL_UNDERCUT_MAX_SECONDS = Math.min(3.0, PIT_LOSS_SECONDS / 7)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough per-compound cliff lap estimates (dry compounds). Wet/intermediate
|
||||||
|
* values are conservative — deg varies wildly with conditions.
|
||||||
|
*/
|
||||||
|
export const TYRE_CLIFF_LAPS: Readonly<Record<string, number>> = {
|
||||||
|
SOFT: 18,
|
||||||
|
MEDIUM: 28,
|
||||||
|
HARD: 38,
|
||||||
|
INTERMEDIATE: 20,
|
||||||
|
WET: 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default cliff when compound is unknown. */
|
||||||
|
export const TYRE_CLIFF_DEFAULT_LAPS = 25
|
||||||
|
|
||||||
|
/** Championship max points per race (winner). */
|
||||||
|
export const MAX_POINTS_PER_ROUND = 25
|
||||||
|
|
||||||
|
export interface MeaningAnnotation {
|
||||||
|
caption: string
|
||||||
|
title: string
|
||||||
|
tone?: 'good' | 'bad' | 'neutral' | 'warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cliffLaps(compound: string | null | undefined): number {
|
||||||
|
if (!compound) return TYRE_CLIFF_DEFAULT_LAPS
|
||||||
|
return TYRE_CLIFF_LAPS[compound.toUpperCase()] ?? TYRE_CLIFF_DEFAULT_LAPS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval / gap-to-ahead meaning for the live timing tower.
|
||||||
|
* Returns null for leader gaps, out-of-range values, or unparsable input.
|
||||||
|
*/
|
||||||
|
export function intervalMeaning(seconds: number | null | undefined): MeaningAnnotation | null {
|
||||||
|
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null
|
||||||
|
|
||||||
|
if (seconds < INTERVAL_DRS_MAX_SECONDS) {
|
||||||
|
return {
|
||||||
|
caption: 'DRS range',
|
||||||
|
title: `Within ${INTERVAL_DRS_MAX_SECONDS}s — DRS enabled next straight`,
|
||||||
|
tone: 'good',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds >= INTERVAL_UNDERCUT_MIN_SECONDS && seconds <= INTERVAL_UNDERCUT_MAX_SECONDS) {
|
||||||
|
return {
|
||||||
|
caption: 'undercut window',
|
||||||
|
title: `${INTERVAL_UNDERCUT_MIN_SECONDS}–${INTERVAL_UNDERCUT_MAX_SECONDS}s — pit now could gain a position (vs ~${PIT_LOSS_SECONDS}s stop)`,
|
||||||
|
tone: 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tyre-age meaning for deg / stint panels.
|
||||||
|
*/
|
||||||
|
export function tyreAgeMeaning(
|
||||||
|
compound: string | null | undefined,
|
||||||
|
age: number | null | undefined,
|
||||||
|
): MeaningAnnotation | null {
|
||||||
|
if (age == null || !Number.isFinite(age) || age < 0) return null
|
||||||
|
|
||||||
|
const cliff = cliffLaps(compound)
|
||||||
|
const freshEnd = Math.ceil(cliff * 0.25)
|
||||||
|
const midEnd = Math.ceil(cliff * 0.65)
|
||||||
|
|
||||||
|
if (age <= freshEnd) {
|
||||||
|
return {
|
||||||
|
caption: 'fresh',
|
||||||
|
title: `${age} lap${age === 1 ? '' : 's'} on ${compound ?? 'tyre'} — early stint grip`,
|
||||||
|
tone: 'good',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (age <= midEnd) {
|
||||||
|
return {
|
||||||
|
caption: 'mid-life',
|
||||||
|
title: `${age} laps — tyre in its working window before cliff (~${cliff} laps)`,
|
||||||
|
tone: 'neutral',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lapsToCliff = cliff - age
|
||||||
|
if (lapsToCliff <= 0) {
|
||||||
|
return {
|
||||||
|
caption: 'past cliff',
|
||||||
|
title: `${age} laps — beyond typical ${compound ?? 'tyre'} cliff (~${cliff} laps)`,
|
||||||
|
tone: 'bad',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
caption: `~${lapsToCliff} laps to cliff`,
|
||||||
|
title: `${age} of ~${cliff} laps before deg cliff on ${compound ?? 'tyre'}`,
|
||||||
|
tone: 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points gap to the driver directly ahead — catchable-or-not v1.
|
||||||
|
*/
|
||||||
|
export function pointsGapMeaning(
|
||||||
|
gapToAhead: number | null | undefined,
|
||||||
|
roundsLeft: number,
|
||||||
|
driverAhead?: string | null,
|
||||||
|
): MeaningAnnotation | null {
|
||||||
|
if (gapToAhead == null || !Number.isFinite(gapToAhead) || gapToAhead <= 0) return null
|
||||||
|
if (!Number.isFinite(roundsLeft) || roundsLeft <= 0) return null
|
||||||
|
|
||||||
|
const maxCatchable = roundsLeft * MAX_POINTS_PER_ROUND
|
||||||
|
const ahead = driverAhead?.trim() || 'ahead'
|
||||||
|
|
||||||
|
if (gapToAhead > maxCatchable) {
|
||||||
|
return {
|
||||||
|
caption: 'out of reach',
|
||||||
|
title: `+${gapToAhead} pts with ${roundsLeft} round${roundsLeft === 1 ? '' : 's'} left (max ${maxCatchable} available)`,
|
||||||
|
tone: 'bad',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const perRound = Math.ceil(gapToAhead / roundsLeft)
|
||||||
|
return {
|
||||||
|
caption: `~${perRound} pts/round`,
|
||||||
|
title: `Needs ~${perRound} pts per round on ${ahead} to catch (${gapToAhead} pts in ${roundsLeft} round${roundsLeft === 1 ? '' : 's'})`,
|
||||||
|
tone: perRound <= 10 ? 'good' : 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
123
frontend/src/lib/preview.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ChampionshipHub, EnrichedGrid, EnrichedResult, Meeting, Session } from '../types'
|
||||||
|
import {
|
||||||
|
currentAndNextSession,
|
||||||
|
nextUpcomingMeeting,
|
||||||
|
sessionStartTime,
|
||||||
|
sortSessionsByStart,
|
||||||
|
} from './schedule'
|
||||||
|
|
||||||
|
export const RACE_WIN_POINTS = 25
|
||||||
|
export const SPRINT_WIN_POINTS = 8
|
||||||
|
export const SPRINT_WEEKEND_MAX_POINTS = RACE_WIN_POINTS + SPRINT_WIN_POINTS
|
||||||
|
|
||||||
|
export function pickPreviewMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||||
|
const active = meetings.filter((m) => !m.is_cancelled)
|
||||||
|
return nextUpcomingMeeting(active, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPriorYearMeetingByCircuit(
|
||||||
|
priorYearMeetings: Meeting[],
|
||||||
|
circuitKey: number | undefined,
|
||||||
|
): Meeting | null {
|
||||||
|
if (!circuitKey) return null
|
||||||
|
return priorYearMeetings.find((m) => m.circuit_key === circuitKey && !m.is_cancelled) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRaceSession(sessions: Session[]): Session | null {
|
||||||
|
const sorted = sortSessionsByStart(sessions)
|
||||||
|
return (
|
||||||
|
sorted.find((s) => {
|
||||||
|
const type = s.session_type?.toLowerCase() ?? ''
|
||||||
|
return type.includes('race') && !type.includes('sprint')
|
||||||
|
}) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSprintWeekend(sessions: Session[]): boolean {
|
||||||
|
return sessions.some((s) => {
|
||||||
|
const label = `${s.session_type} ${s.session_name}`.toLowerCase()
|
||||||
|
return /\bsprint\b/.test(label) && !label.includes('sprint qualifying') && !label.includes('sprint shootout')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodiumPlace {
|
||||||
|
position: number
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolePosition {
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_name: string
|
||||||
|
team_colour: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPodium(results: EnrichedResult[]): PodiumPlace[] {
|
||||||
|
return results
|
||||||
|
.filter((r) => r.position >= 1 && r.position <= 3 && !r.dns && !r.dsq)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((r) => ({
|
||||||
|
position: r.position,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
full_name: r.full_name,
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPole(grid: EnrichedGrid[]): PolePosition | null {
|
||||||
|
const pole = grid.find((g) => g.position === 1)
|
||||||
|
if (!pole) return null
|
||||||
|
return {
|
||||||
|
name_acronym: pole.name_acronym,
|
||||||
|
full_name: pole.full_name,
|
||||||
|
team_name: pole.team_name,
|
||||||
|
team_colour: pole.team_colour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitleFightDriver {
|
||||||
|
name_acronym: string
|
||||||
|
full_name: string
|
||||||
|
team_colour: string
|
||||||
|
points: number
|
||||||
|
position: number
|
||||||
|
gapToLeader: number
|
||||||
|
gapAfterRaceWin: number
|
||||||
|
gapAfterSprintWeekendMax: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTitleFightContext(hub: ChampionshipHub): TitleFightDriver[] {
|
||||||
|
if (hub.drivers.length === 0) return []
|
||||||
|
const leader = hub.drivers[0]
|
||||||
|
return hub.drivers.slice(0, 3).map((d) => ({
|
||||||
|
name_acronym: d.name_acronym,
|
||||||
|
full_name: d.full_name,
|
||||||
|
team_colour: d.team_colour,
|
||||||
|
points: d.points,
|
||||||
|
position: d.position,
|
||||||
|
gapToLeader: leader.points - d.points,
|
||||||
|
gapAfterRaceWin: leader.points - (d.points + RACE_WIN_POINTS),
|
||||||
|
gapAfterSprintWeekendMax: leader.points - (d.points + SPRINT_WEEKEND_MAX_POINTS),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countdownTargetSession(sessions: Session[], now: Date): Session | null {
|
||||||
|
const sorted = sortSessionsByStart(sessions)
|
||||||
|
const { next } = currentAndNextSession(sorted, now)
|
||||||
|
if (next) return next
|
||||||
|
for (const session of sorted) {
|
||||||
|
const start = sessionStartTime(session)
|
||||||
|
if (start && start > now) return session
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPointsGap(gap: number): string {
|
||||||
|
if (gap === 0) return 'LEADER'
|
||||||
|
const sign = gap > 0 ? '+' : ''
|
||||||
|
return `${sign}${Number.isInteger(gap) ? gap : gap.toFixed(1)}`
|
||||||
|
}
|
||||||
22
frontend/src/lib/radio.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { LiveRadioCapture, LiveSessionMeta } from '../types'
|
||||||
|
|
||||||
|
export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/'
|
||||||
|
|
||||||
|
export function radioClipUrl(
|
||||||
|
session: Pick<LiveSessionMeta, 'Path'> | null | undefined,
|
||||||
|
capture: Pick<LiveRadioCapture, 'Path'> | null | undefined,
|
||||||
|
): string {
|
||||||
|
const sessionPath = session?.Path?.trim()
|
||||||
|
const capturePath = capture?.Path?.trim()
|
||||||
|
if (!sessionPath || !capturePath) return ''
|
||||||
|
if (/^https?:\/\//i.test(capturePath)) return capturePath
|
||||||
|
|
||||||
|
const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '')
|
||||||
|
const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '')
|
||||||
|
const normalizedCapture = capturePath.replace(/^\/+/g, '')
|
||||||
|
return `${base}/${normalizedSession}/${normalizedCapture}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function radioCaptureKey(capture: LiveRadioCapture): string {
|
||||||
|
return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}`
|
||||||
|
}
|
||||||
75
frontend/src/lib/replay.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { ReplayCarPosition, ReplayFrame, TrackBounds } from '../types'
|
||||||
|
import { canvasToSvg, normalizeRawPoint } from './trackmap'
|
||||||
|
|
||||||
|
export interface ReplayFramePair {
|
||||||
|
previous: ReplayFrame | null
|
||||||
|
next: ReplayFrame | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookupReplayFramePair(
|
||||||
|
frames: ReadonlyArray<ReplayFrame>,
|
||||||
|
tMs: number,
|
||||||
|
): ReplayFramePair {
|
||||||
|
if (frames.length === 0) return { previous: null, next: null }
|
||||||
|
if (tMs <= frames[0].t) return { previous: frames[0], next: frames[0] }
|
||||||
|
const last = frames[frames.length - 1]
|
||||||
|
if (tMs >= last.t) return { previous: last, next: last }
|
||||||
|
|
||||||
|
let lo = 0
|
||||||
|
let hi = frames.length - 1
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = Math.floor((lo + hi) / 2)
|
||||||
|
const frame = frames[mid]
|
||||||
|
if (frame.t === tMs) return { previous: frame, next: frame }
|
||||||
|
if (frame.t < tMs) {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previous: frames[Math.max(0, hi)], next: frames[Math.min(frames.length - 1, lo)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolateReplayCars(
|
||||||
|
frames: ReadonlyArray<ReplayFrame>,
|
||||||
|
tMs: number,
|
||||||
|
): Record<string, ReplayCarPosition> {
|
||||||
|
const { previous, next } = lookupReplayFramePair(frames, tMs)
|
||||||
|
if (!previous && !next) return {}
|
||||||
|
if (!previous) return next?.cars ?? {}
|
||||||
|
if (!next || previous.t === next.t) return previous.cars
|
||||||
|
|
||||||
|
const span = next.t - previous.t
|
||||||
|
const fraction = span <= 0 ? 0 : clamp01((tMs - previous.t) / span)
|
||||||
|
const cars: Record<string, ReplayCarPosition> = {}
|
||||||
|
const numbers = new Set([...Object.keys(previous.cars), ...Object.keys(next.cars)])
|
||||||
|
|
||||||
|
for (const number of numbers) {
|
||||||
|
const before = previous.cars[number]
|
||||||
|
const after = next.cars[number]
|
||||||
|
if (before && after) {
|
||||||
|
cars[number] = {
|
||||||
|
x: before.x + (after.x - before.x) * fraction,
|
||||||
|
y: before.y + (after.y - before.y) * fraction,
|
||||||
|
}
|
||||||
|
} else if (before) {
|
||||||
|
cars[number] = before
|
||||||
|
} else if (after) {
|
||||||
|
cars[number] = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cars
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayCarToSvg(
|
||||||
|
car: ReplayCarPosition,
|
||||||
|
bounds: TrackBounds,
|
||||||
|
): ReplayCarPosition {
|
||||||
|
return canvasToSvg(normalizeRawPoint({ x: car.x, y: car.y }, bounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
return Math.min(1, Math.max(0, value))
|
||||||
|
}
|
||||||
14
frontend/src/lib/replayMap.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReplayFramesResponse, TrackOutline } from '../types'
|
||||||
|
|
||||||
|
/** Whether replay map can render (probe uses existing frames + outline queries). */
|
||||||
|
export function isReplayMapAvailable(
|
||||||
|
replay: ReplayFramesResponse | null | undefined,
|
||||||
|
outline: TrackOutline | null | undefined,
|
||||||
|
hasError: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (hasError) return false
|
||||||
|
const frames = replay?.frames ?? []
|
||||||
|
if (frames.length < 2) return false
|
||||||
|
if (!outline?.points?.length) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
61
frontend/src/lib/rivalry.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Pure helpers for the championship rivalry compare view.
|
||||||
|
|
||||||
|
/** One round that counted toward the head-to-head tally. */
|
||||||
|
export interface H2HRound {
|
||||||
|
round: number // 1-based season round
|
||||||
|
label: string
|
||||||
|
posA: number
|
||||||
|
posB: number
|
||||||
|
winner: 'a' | 'b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface H2HTally {
|
||||||
|
a: number
|
||||||
|
b: number
|
||||||
|
/** Rounds counted in the tally, in season order. */
|
||||||
|
rounds: H2HRound[]
|
||||||
|
/** Rounds skipped because either driver had no finishing position. */
|
||||||
|
skipped: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-round points gap (a − b) across the rounds both cumulative series
|
||||||
|
* cover. Positive values mean driver A is ahead.
|
||||||
|
*/
|
||||||
|
export function gapSeries(a: number[], b: number[]): number[] {
|
||||||
|
const n = Math.min(a.length, b.length)
|
||||||
|
const out: number[] = []
|
||||||
|
for (let i = 0; i < n; i++) out.push(a[i] - b[i])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Race head-to-head: per round, the lower finishing position wins. Rounds
|
||||||
|
* where either driver has no position (0 / missing) are skipped from the
|
||||||
|
* tally, as are equal positions (defensive — races can't tie).
|
||||||
|
*/
|
||||||
|
export function h2hTally(posA: number[], posB: number[], labels: string[]): H2HTally {
|
||||||
|
const n = Math.max(posA.length, posB.length)
|
||||||
|
const rounds: H2HRound[] = []
|
||||||
|
let a = 0
|
||||||
|
let b = 0
|
||||||
|
let skipped = 0
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const pa = posA[i] ?? 0
|
||||||
|
const pb = posB[i] ?? 0
|
||||||
|
if (pa <= 0 || pb <= 0 || pa === pb) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const winner = pa < pb ? 'a' : 'b'
|
||||||
|
if (winner === 'a') a++
|
||||||
|
else b++
|
||||||
|
rounds.push({ round: i + 1, label: labels[i] ?? `R${i + 1}`, posA: pa, posB: pb, winner })
|
||||||
|
}
|
||||||
|
return { a, b, rounds, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last n counted rounds of a tally, for the mini strip. */
|
||||||
|
export function lastRounds(tally: H2HTally, n: number): H2HRound[] {
|
||||||
|
return tally.rounds.slice(-n)
|
||||||
|
}
|
||||||
271
frontend/src/lib/tyredeg.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// Tyre degradation + pit-window estimation for the live timing page.
|
||||||
|
// Pure functions only — no React, no side effects — so everything is unit-testable.
|
||||||
|
//
|
||||||
|
// Lap-time samples are accumulated client-side from successive SSE snapshots
|
||||||
|
// (mirroring the gapHistory/battles precedent); nothing here talks to the server.
|
||||||
|
|
||||||
|
import type { LiveTimingRow } from './live'
|
||||||
|
import { driverCode } from './live'
|
||||||
|
import { parseIntervalSeconds } from './gapHistory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough typical pit-lane time loss (entry + stop + exit vs a flying lap), in
|
||||||
|
* seconds. Per-track values are deliberately out of scope for v1.
|
||||||
|
*/
|
||||||
|
export const PIT_LOSS_SECONDS = 22
|
||||||
|
|
||||||
|
/** Minimum clean laps in the current stint before a slope is trustworthy. */
|
||||||
|
export const MIN_CLEAN_LAPS = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laps more than this many seconds off the stint median are treated as
|
||||||
|
* outliers (traffic, spins, safety car) and excluded from the fit.
|
||||||
|
*/
|
||||||
|
export const OUTLIER_DELTA_SECONDS = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slope classification thresholds (seconds per lap):
|
||||||
|
* slope <= -DEG_SLOPE_THRESHOLD -> 'improving' (track evolution, fuel burn dominating)
|
||||||
|
* slope >= +DEG_SLOPE_THRESHOLD -> 'degrading' (tyre wear dominating)
|
||||||
|
* otherwise -> 'stable'
|
||||||
|
*/
|
||||||
|
export const DEG_SLOPE_THRESHOLD = 0.05
|
||||||
|
|
||||||
|
/** Safety cap so a very long stint cannot grow the buffer unbounded. */
|
||||||
|
export const MAX_STINT_SAMPLES = 80
|
||||||
|
|
||||||
|
export type DegTrend = 'improving' | 'stable' | 'degrading'
|
||||||
|
|
||||||
|
export interface StintSample {
|
||||||
|
lap: number
|
||||||
|
seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverStintHistory {
|
||||||
|
compound: string
|
||||||
|
tyreAge: number
|
||||||
|
lastLapNumber: number
|
||||||
|
lastLapTime: string
|
||||||
|
/** The next completed lap is an out-lap (fresh stint) and must be discarded. */
|
||||||
|
skipNextLap: boolean
|
||||||
|
samples: StintSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-driver current-stint lap history, keyed by racing number. */
|
||||||
|
export type StintHistoryMap = Record<string, DriverStintHistory>
|
||||||
|
|
||||||
|
export interface StintLapInput {
|
||||||
|
racingNumber: string
|
||||||
|
lapNumber: number
|
||||||
|
lastLapTime: string
|
||||||
|
compound: string
|
||||||
|
tyreAge: number
|
||||||
|
inPit: boolean
|
||||||
|
pitOut: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an F1 live-timing lap time string ("1:23.456", "83.456") into seconds.
|
||||||
|
* Same conventions as parseIntervalSeconds, but a lap time is never signed and
|
||||||
|
* never a lapped/leader marker; zero or negative values are rejected.
|
||||||
|
*/
|
||||||
|
export function parseLapTimeSeconds(raw: string | null | undefined): number | null {
|
||||||
|
const value = parseIntervalSeconds(raw)
|
||||||
|
if (value === null || value <= 0) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stintInputFromRow(row: LiveTimingRow): StintLapInput {
|
||||||
|
return {
|
||||||
|
racingNumber: row.RacingNumber,
|
||||||
|
lapNumber: row.Driver.NumberOfLaps || 0,
|
||||||
|
lastLapTime: row.Driver.LastLapTime || '',
|
||||||
|
compound: row.Tyre?.Compound || '',
|
||||||
|
tyreAge: row.Tyre?.Age ?? 0,
|
||||||
|
inPit: Boolean(row.Driver.InPit),
|
||||||
|
pitOut: Boolean(row.Driver.PitOut),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one snapshot's worth of lap samples for each driver's current stint.
|
||||||
|
* Returns a new map (input is not mutated); drivers missing from `inputs` are
|
||||||
|
* pruned.
|
||||||
|
*
|
||||||
|
* A lap counts as completed when LastLapTime changes to a new non-empty value
|
||||||
|
* (NumberOfLaps can tick before the lap time arrives, so the time string is
|
||||||
|
* the trigger; a sample for the same lap number is replaced, not duplicated).
|
||||||
|
* The stint buffer resets when the compound changes or the tyre age drops
|
||||||
|
* (new set fitted), and laps completed in the pit lane (in-lap), on pit exit
|
||||||
|
* (out-lap), or immediately after a reset are excluded.
|
||||||
|
*/
|
||||||
|
export function recordStintSamples(
|
||||||
|
history: StintHistoryMap,
|
||||||
|
inputs: ReadonlyArray<StintLapInput>,
|
||||||
|
): StintHistoryMap {
|
||||||
|
const next: StintHistoryMap = {}
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (!input.racingNumber) continue
|
||||||
|
|
||||||
|
const prior = history[input.racingNumber]
|
||||||
|
if (!prior) {
|
||||||
|
next[input.racingNumber] = {
|
||||||
|
compound: input.compound,
|
||||||
|
tyreAge: input.tyreAge,
|
||||||
|
lastLapNumber: input.lapNumber,
|
||||||
|
lastLapTime: input.lastLapTime,
|
||||||
|
skipNextLap: false,
|
||||||
|
samples: [],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let samples = prior.samples
|
||||||
|
let skipNextLap = prior.skipNextLap
|
||||||
|
|
||||||
|
const compoundChanged = Boolean(input.compound) && Boolean(prior.compound) && input.compound !== prior.compound
|
||||||
|
const freshSet = input.tyreAge < prior.tyreAge
|
||||||
|
if (compoundChanged || freshSet || input.inPit) {
|
||||||
|
// Stint over (or a new one starting): drop the old laps and flag the
|
||||||
|
// upcoming out-lap for exclusion.
|
||||||
|
samples = []
|
||||||
|
skipNextLap = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const lapCompleted = Boolean(input.lastLapTime) && input.lastLapTime !== prior.lastLapTime
|
||||||
|
if (lapCompleted) {
|
||||||
|
const seconds = parseLapTimeSeconds(input.lastLapTime)
|
||||||
|
const dirty = input.inPit || input.pitOut || skipNextLap
|
||||||
|
if (seconds !== null && !dirty) {
|
||||||
|
const last = samples[samples.length - 1]
|
||||||
|
if (last && last.lap === input.lapNumber) {
|
||||||
|
samples = [...samples.slice(0, -1), { lap: input.lapNumber, seconds }]
|
||||||
|
} else {
|
||||||
|
samples = [...samples, { lap: input.lapNumber, seconds }]
|
||||||
|
if (samples.length > MAX_STINT_SAMPLES) {
|
||||||
|
samples = samples.slice(samples.length - MAX_STINT_SAMPLES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!input.inPit) skipNextLap = false
|
||||||
|
}
|
||||||
|
|
||||||
|
next[input.racingNumber] = {
|
||||||
|
compound: input.compound || prior.compound,
|
||||||
|
tyreAge: input.tyreAge,
|
||||||
|
lastLapNumber: input.lapNumber,
|
||||||
|
lastLapTime: input.lastLapTime || prior.lastLapTime,
|
||||||
|
skipNextLap,
|
||||||
|
samples,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DegModel {
|
||||||
|
/** Least-squares slope of lap time vs lap number, in seconds per lap. */
|
||||||
|
slope: number
|
||||||
|
trend: DegTrend
|
||||||
|
/** Clean (outlier-filtered) samples the fit ran over, in lap order. */
|
||||||
|
samples: StintSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop laps more than OUTLIER_DELTA_SECONDS off the stint median. */
|
||||||
|
export function cleanStintSamples(
|
||||||
|
samples: ReadonlyArray<StintSample>,
|
||||||
|
maxDelta = OUTLIER_DELTA_SECONDS,
|
||||||
|
): StintSample[] {
|
||||||
|
if (samples.length === 0) return []
|
||||||
|
const sorted = samples.map((sample) => sample.seconds).sort((a, b) => a - b)
|
||||||
|
const mid = Math.floor(sorted.length / 2)
|
||||||
|
const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||||
|
return samples.filter((sample) => Math.abs(sample.seconds - median) <= maxDelta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit a linear degradation model over the stint's clean laps.
|
||||||
|
* Returns null with fewer than MIN_CLEAN_LAPS clean samples — the caller
|
||||||
|
* should show a "warming up" placeholder rather than a junk slope.
|
||||||
|
*/
|
||||||
|
export function degradationModel(samples: ReadonlyArray<StintSample>): DegModel | null {
|
||||||
|
const clean = cleanStintSamples(samples)
|
||||||
|
if (clean.length < MIN_CLEAN_LAPS) return null
|
||||||
|
|
||||||
|
const n = clean.length
|
||||||
|
const meanLap = clean.reduce((sum, s) => sum + s.lap, 0) / n
|
||||||
|
const meanSec = clean.reduce((sum, s) => sum + s.seconds, 0) / n
|
||||||
|
|
||||||
|
let numerator = 0
|
||||||
|
let denominator = 0
|
||||||
|
for (const sample of clean) {
|
||||||
|
numerator += (sample.lap - meanLap) * (sample.seconds - meanSec)
|
||||||
|
denominator += (sample.lap - meanLap) ** 2
|
||||||
|
}
|
||||||
|
if (denominator === 0) return null
|
||||||
|
|
||||||
|
const slope = numerator / denominator
|
||||||
|
const trend: DegTrend =
|
||||||
|
slope <= -DEG_SLOPE_THRESHOLD ? 'improving' : slope >= DEG_SLOPE_THRESHOLD ? 'degrading' : 'stable'
|
||||||
|
|
||||||
|
return { slope, trend, samples: clean }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "+0.08s/lap" / "-0.12s/lap" display form. */
|
||||||
|
export function formatSlope(slope: number): string {
|
||||||
|
const sign = slope >= 0 ? '+' : '−'
|
||||||
|
return `${sign}${Math.abs(slope).toFixed(2)}s/lap`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PitRejoinEstimate {
|
||||||
|
rejoinPosition: number
|
||||||
|
/** Cars behind that would get past during the stop. */
|
||||||
|
positionsLost: number
|
||||||
|
/** Driver code directly ahead after rejoining, if any. */
|
||||||
|
aheadCode: string | null
|
||||||
|
/** Driver code directly behind after rejoining, if any. */
|
||||||
|
behindCode: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate where a driver rejoins after a pit stop costing `pitLoss` seconds.
|
||||||
|
*
|
||||||
|
* Walks the cars behind the driver, accumulating their Interval (gap to car
|
||||||
|
* ahead) values: every car whose cumulative gap to the driver is under the
|
||||||
|
* pit loss gets past. An unparsable interval (lapped marker like "1L") ends
|
||||||
|
* the walk — those cars are at least a lap down and stay behind. Cars in the
|
||||||
|
* pits or retired are excluded from the ladder.
|
||||||
|
*/
|
||||||
|
export function estimatePitRejoin(
|
||||||
|
rows: ReadonlyArray<LiveTimingRow>,
|
||||||
|
racingNumber: string,
|
||||||
|
pitLoss = PIT_LOSS_SECONDS,
|
||||||
|
): PitRejoinEstimate | null {
|
||||||
|
const ladder = rows
|
||||||
|
.filter((row) => row.Position > 0 && !row.Driver.Retired && !row.Driver.InPit)
|
||||||
|
.sort((a, b) => a.Position - b.Position)
|
||||||
|
|
||||||
|
const index = ladder.findIndex((row) => row.RacingNumber === racingNumber)
|
||||||
|
if (index === -1) return null
|
||||||
|
|
||||||
|
let cumulative = 0
|
||||||
|
let positionsLost = 0
|
||||||
|
for (let i = index + 1; i < ladder.length; i++) {
|
||||||
|
const gap = parseIntervalSeconds(ladder[i].Driver.Interval)
|
||||||
|
if (gap === null) break
|
||||||
|
cumulative += Math.max(0, gap)
|
||||||
|
if (cumulative >= pitLoss) break
|
||||||
|
positionsLost += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const ahead = positionsLost > 0 ? ladder[index + positionsLost] : ladder[index - 1]
|
||||||
|
const behind = ladder[index + positionsLost + 1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
rejoinPosition: ladder[index].Position + positionsLost,
|
||||||
|
positionsLost,
|
||||||
|
aheadCode: ahead ? driverCode(ahead) : null,
|
||||||
|
behindCode: behind ? driverCode(behind) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
181
frontend/src/lib/weekendContext.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import type {
|
||||||
|
ChampionshipHub,
|
||||||
|
ContextSession,
|
||||||
|
EnrichedResult,
|
||||||
|
Meeting,
|
||||||
|
NewsItem,
|
||||||
|
Session,
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendChampionshipMover,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendPodiumEntry,
|
||||||
|
WeekendViewState,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveViewState maps the canonical `temporal_state` onto the rendered Weekend
|
||||||
|
* view. It is a total function: every valid canonical `temporal_state` resolves
|
||||||
|
* to a concrete view, so a well-formed payload never falls through to a
|
||||||
|
* limited/empty placeholder. Loading and error are hook-level states that are
|
||||||
|
* not part of the #72 contract.
|
||||||
|
*/
|
||||||
|
export function resolveViewState(context: WeekendContext): WeekendViewState {
|
||||||
|
switch (context.temporal_state) {
|
||||||
|
case 'no_season':
|
||||||
|
return 'no_season'
|
||||||
|
case 'between_weekends':
|
||||||
|
return 'between_weekends'
|
||||||
|
case 'pre_session':
|
||||||
|
return 'pre_session'
|
||||||
|
case 'session_live':
|
||||||
|
return 'session_live'
|
||||||
|
case 'session_settling':
|
||||||
|
return 'session_settling'
|
||||||
|
case 'between_sessions':
|
||||||
|
return 'between_sessions'
|
||||||
|
case 'post_weekend':
|
||||||
|
return 'post_weekend'
|
||||||
|
case 'season_complete':
|
||||||
|
return 'season_complete'
|
||||||
|
default:
|
||||||
|
// Unknown/absent temporal_state is an invalid payload — treat as no_season
|
||||||
|
// rather than inventing a state. The hook still shows an explicit surface.
|
||||||
|
return 'no_season'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingIdentity {
|
||||||
|
meeting_key: number
|
||||||
|
meeting_name: string
|
||||||
|
short_name: string
|
||||||
|
country_code: string
|
||||||
|
country_flag: string
|
||||||
|
circuit_short_name: string
|
||||||
|
circuit_key?: number
|
||||||
|
date_start?: string
|
||||||
|
date_end?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presentation identity for a meeting, tolerant of partial/absent fields. */
|
||||||
|
export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined {
|
||||||
|
if (!meeting) return undefined
|
||||||
|
return {
|
||||||
|
meeting_key: meeting.meeting_key,
|
||||||
|
meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix',
|
||||||
|
short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''),
|
||||||
|
country_code: meeting.country_code,
|
||||||
|
country_flag: meeting.country_flag,
|
||||||
|
circuit_short_name: meeting.circuit_short_name || meeting.location || '',
|
||||||
|
circuit_key: meeting.circuit_key,
|
||||||
|
date_start: meeting.date_start || undefined,
|
||||||
|
date_end: meeting.date_end || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session identity paired with its resolved meeting for a canonical ContextSession. */
|
||||||
|
export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined {
|
||||||
|
return ref?.meeting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionOf(ref: ContextSession | undefined): Session | undefined {
|
||||||
|
return ref?.session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analysisSessionKey resolves the session a user should open to read analysis for
|
||||||
|
* a completed event. It prefers the canonical default analysis session, falling
|
||||||
|
* back to the previous completed session. Returns undefined when nothing analysable
|
||||||
|
* is available (e.g. an archived result with no local analysis).
|
||||||
|
*/
|
||||||
|
export function analysisSessionKey(context: WeekendContext): number | undefined {
|
||||||
|
const key =
|
||||||
|
context.default_analysis_session?.session.session_key ??
|
||||||
|
context.previous_completed_session?.session.session_key
|
||||||
|
return key && key > 0 ? key : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a ContextSession has local analysis worth linking to. */
|
||||||
|
export function hasLocalAnalysis(ref: ContextSession | undefined): boolean {
|
||||||
|
const status = ref?.availability.local_analysis
|
||||||
|
return status === 'complete' || status === 'partial'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Countdown target for a session reference — its scheduled start, if known. */
|
||||||
|
export function sessionStart(ref: ContextSession | undefined): string | undefined {
|
||||||
|
const start = ref?.session.date_start
|
||||||
|
return start ? start : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
|
||||||
|
return [...results]
|
||||||
|
.filter((r) => r.position >= 1)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((r) => ({
|
||||||
|
position: r.position,
|
||||||
|
driver_number: r.driver_number,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
gap: formatResultGap(r),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResultGap(r: EnrichedResult): string {
|
||||||
|
if (r.dnf) return 'DNF'
|
||||||
|
if (r.dns) return 'DNS'
|
||||||
|
if (r.dsq) return 'DSQ'
|
||||||
|
if (r.position === 1) {
|
||||||
|
if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration)
|
||||||
|
return 'Winner'
|
||||||
|
}
|
||||||
|
const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader
|
||||||
|
if (typeof gap === 'number') return `+${gap.toFixed(3)}`
|
||||||
|
if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`
|
||||||
|
return `${m}:${s.toFixed(3).padStart(6, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* championshipImpact derives the top-of-standings movers from the championship
|
||||||
|
* hub. This is supplementary data layered onto the canonical context, not part of
|
||||||
|
* the #72 contract.
|
||||||
|
*/
|
||||||
|
export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
|
||||||
|
if (!hub || hub.drivers.length === 0) return undefined
|
||||||
|
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
|
||||||
|
const cumulative = d.cumulative ?? []
|
||||||
|
const delta =
|
||||||
|
cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined
|
||||||
|
return {
|
||||||
|
position: d.position,
|
||||||
|
driver_number: d.driver_number,
|
||||||
|
name_acronym: d.name_acronym,
|
||||||
|
team_colour: d.team_colour,
|
||||||
|
points: d.points,
|
||||||
|
delta,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined
|
||||||
|
return { leaders, note }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most three briefing items, mapped from the news feed. */
|
||||||
|
export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] {
|
||||||
|
return news.slice(0, 3).map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
source: item.source,
|
||||||
|
published_at: item.published_at,
|
||||||
|
image_url: item.og_image_url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { fetchNews, fetchNewsArticle, markNewsRead } from '../api'
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchNews,
|
||||||
|
fetchNewsArticle,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
markNewsRead,
|
||||||
|
} from '../api'
|
||||||
|
import {
|
||||||
|
activeDigestWindow,
|
||||||
|
filterByTag,
|
||||||
|
groupByWindow,
|
||||||
|
gpWindows,
|
||||||
|
itemsForWindow,
|
||||||
|
sinceLastLabel,
|
||||||
|
sortWindowBucketsNewestFirst,
|
||||||
|
tagColour,
|
||||||
|
tagItems,
|
||||||
|
topTags,
|
||||||
|
type DigestTag,
|
||||||
|
type TaggedNewsItem,
|
||||||
|
} from '../lib/digest'
|
||||||
import { stripHtml, timeAgo } from '../utils'
|
import { stripHtml, timeAgo } from '../utils'
|
||||||
import type { ArticleContent, NewsItem } from '../types'
|
import type { ArticleContent, NewsItem } from '../types'
|
||||||
|
import '../styles/digest.css'
|
||||||
|
|
||||||
type Category = 'all' | 'official' | 'news' | 'video'
|
type Category = 'all' | 'official' | 'news' | 'video'
|
||||||
|
|
||||||
const PAGE_SIZE = 16
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<Category, string> = {
|
const CATEGORY_LABELS: Record<Category, string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
official: 'Official',
|
official: 'Official',
|
||||||
@@ -71,6 +91,31 @@ function CategoryTabs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TagChip({
|
||||||
|
tag,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
className = 'digest-tag-chip',
|
||||||
|
}: {
|
||||||
|
tag: DigestTag
|
||||||
|
active?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${className}${active ? ' active' : ''}`}
|
||||||
|
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="digest-tag-dot" aria-hidden="true" />
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function OGImage({ url, title }: { url?: string; title: string }) {
|
function OGImage({ url, title }: { url?: string; title: string }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
const initial = (title[0] ?? '?').toUpperCase()
|
const initial = (title[0] ?? '?').toUpperCase()
|
||||||
@@ -98,11 +143,15 @@ function OGImage({ url, title }: { url?: string; title: string }) {
|
|||||||
function BriefingCard({
|
function BriefingCard({
|
||||||
item,
|
item,
|
||||||
isActive,
|
isActive,
|
||||||
|
activeTag,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onTagClick,
|
||||||
}: {
|
}: {
|
||||||
item: NewsItem
|
item: TaggedNewsItem
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
activeTag: string | null
|
||||||
onSelect: (item: NewsItem) => void
|
onSelect: (item: NewsItem) => void
|
||||||
|
onTagClick: (tag: DigestTag) => void
|
||||||
}) {
|
}) {
|
||||||
const isRead = !!item.read_at
|
const isRead = !!item.read_at
|
||||||
const isVideo = categoryOf(item) === 'video'
|
const isVideo = categoryOf(item) === 'video'
|
||||||
@@ -129,6 +178,25 @@ function BriefingCard({
|
|||||||
{stripHtml(item.og_description || item.summary || '')}
|
{stripHtml(item.og_description || item.summary || '')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="bp-card-tags">
|
||||||
|
{item.tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.key}
|
||||||
|
type="button"
|
||||||
|
className={`bp-card-tag${activeTag === tag.key ? ' active' : ''}`}
|
||||||
|
style={{ '--tag-colour': tagColour(tag.colour) } as React.CSSProperties}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onTagClick(tag)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="digest-tag-dot" aria-hidden="true" />
|
||||||
|
{tag.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -146,7 +214,6 @@ function ReaderPanel({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose()
|
||||||
@@ -155,7 +222,6 @@ function ReaderPanel({
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
// Fetch article when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
setArticle(null)
|
setArticle(null)
|
||||||
@@ -175,9 +241,14 @@ function ReaderPanel({
|
|||||||
.catch((err) => { setError(String(err)); setLoading(false) })
|
.catch((err) => { setError(String(err)); setLoading(false) })
|
||||||
}, [item?.url, item ? categoryOf(item) : ''])
|
}, [item?.url, item ? categoryOf(item) : ''])
|
||||||
|
|
||||||
// Scroll panel to top when item changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
panelRef.current?.scrollTo({ top: 0 })
|
const panel = panelRef.current
|
||||||
|
if (!panel) return
|
||||||
|
if (typeof panel.scrollTo === 'function') {
|
||||||
|
panel.scrollTo({ top: 0 })
|
||||||
|
} else {
|
||||||
|
panel.scrollTop = 0
|
||||||
|
}
|
||||||
}, [item?.url])
|
}, [item?.url])
|
||||||
|
|
||||||
const isOpen = item !== null
|
const isOpen = item !== null
|
||||||
@@ -297,7 +368,6 @@ function ReaderPanel({
|
|||||||
{article && !loading && article.content && (
|
{article && !loading && article.content && (
|
||||||
<div
|
<div
|
||||||
className="bp-reader-body"
|
className="bp-reader-body"
|
||||||
// readability strips scripts/iframes; sources are all known news outlets
|
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{ __html: article.content }}
|
dangerouslySetInnerHTML={{ __html: article.content }}
|
||||||
/>
|
/>
|
||||||
@@ -332,8 +402,9 @@ function ReaderPanel({
|
|||||||
export function BriefingPage() {
|
export function BriefingPage() {
|
||||||
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
const [activeCategory, setActiveCategory] = useState<Category>('all')
|
||||||
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
const [selectedItem, setSelectedItem] = useState<NewsItem | null>(null)
|
||||||
const [page, setPage] = useState(1)
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
const { data: allNews = [], isLoading, isError } = useQuery({
|
const { data: allNews = [], isLoading, isError } = useQuery({
|
||||||
queryKey: ['news'],
|
queryKey: ['news'],
|
||||||
@@ -341,13 +412,74 @@ export function BriefingPage() {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const filtered = allNews.filter(
|
const seasonsQuery = useQuery({
|
||||||
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', latestSeason],
|
||||||
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hubQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const meetings = meetingsQuery.data ?? []
|
||||||
|
const hub = hubQuery.data
|
||||||
|
|
||||||
|
const taggedNews = useMemo(
|
||||||
|
() => tagItems(allNews, hub?.drivers ?? [], hub?.teams ?? []),
|
||||||
|
[allNews, hub],
|
||||||
)
|
)
|
||||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
||||||
const safePage = Math.min(page, pageCount)
|
const categoryFiltered = useMemo(
|
||||||
const pageStart = (safePage - 1) * PAGE_SIZE
|
() => taggedNews.filter(
|
||||||
const pagedItems = filtered.slice(pageStart, pageStart + PAGE_SIZE)
|
(item) => activeCategory === 'all' || categoryOf(item) === activeCategory,
|
||||||
|
),
|
||||||
|
[taggedNews, activeCategory],
|
||||||
|
)
|
||||||
|
|
||||||
|
const tagFiltered = useMemo(
|
||||||
|
() => filterByTag(categoryFiltered, activeTag),
|
||||||
|
[categoryFiltered, activeTag],
|
||||||
|
)
|
||||||
|
|
||||||
|
const windows = useMemo(() => gpWindows(meetings, now), [meetings, now])
|
||||||
|
const grouped = useMemo(() => groupByWindow(tagFiltered, windows), [tagFiltered, windows])
|
||||||
|
const windowSections = useMemo(
|
||||||
|
() => sortWindowBucketsNewestFirst(grouped.windows),
|
||||||
|
[grouped.windows],
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeWindow = useMemo(
|
||||||
|
() => activeDigestWindow(windows, meetings, now),
|
||||||
|
[windows, meetings, now],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sinceLastItems = useMemo(
|
||||||
|
() => itemsForWindow(tagFiltered, activeWindow),
|
||||||
|
[tagFiltered, activeWindow],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sinceLastTags = useMemo(() => topTags(sinceLastItems), [sinceLastItems])
|
||||||
|
const sinceLabel = sinceLastLabel(meetings, now)
|
||||||
|
|
||||||
|
const taggedByUrl = useMemo(() => {
|
||||||
|
const map = new Map<string, TaggedNewsItem>()
|
||||||
|
for (const item of tagFiltered) map.set(item.url, item)
|
||||||
|
return map
|
||||||
|
}, [tagFiltered])
|
||||||
|
|
||||||
|
const recentTagged = useMemo(() => {
|
||||||
|
const urls = new Set(grouped.recent.map((item) => item.url))
|
||||||
|
return tagFiltered.filter((item) => urls.has(item.url))
|
||||||
|
}, [grouped.recent, tagFiltered])
|
||||||
|
|
||||||
const counts: Record<Category, number> = {
|
const counts: Record<Category, number> = {
|
||||||
all: allNews.length,
|
all: allNews.length,
|
||||||
@@ -374,22 +506,14 @@ export function BriefingPage() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => setSelectedItem(null), [])
|
const handleClose = useCallback(() => setSelectedItem(null), [])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTagClick = useCallback((tag: DigestTag) => {
|
||||||
setPage((current) => Math.min(current, pageCount))
|
setActiveTag((current) => (current === tag.key ? null : tag.key))
|
||||||
}, [pageCount])
|
setSelectedItem(null)
|
||||||
|
}, [])
|
||||||
const handlePageChange = useCallback(
|
|
||||||
(nextPage: number) => {
|
|
||||||
setPage(Math.min(Math.max(nextPage, 1), pageCount))
|
|
||||||
setSelectedItem(null)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
},
|
|
||||||
[pageCount],
|
|
||||||
)
|
|
||||||
|
|
||||||
const unreadCount = allNews.filter((i) => !i.read_at).length
|
const unreadCount = allNews.filter((i) => !i.read_at).length
|
||||||
const pageEnd = Math.min(pageStart + pagedItems.length, filtered.length)
|
const hasDigest = meetings.length > 0
|
||||||
const showPagination = filtered.length > PAGE_SIZE
|
const showEmpty = tagFiltered.length === 0 && grouped.recent.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bp-page" data-testid="briefing-page">
|
<div className="bp-page" data-testid="briefing-page">
|
||||||
@@ -408,74 +532,106 @@ export function BriefingPage() {
|
|||||||
<CategoryTabs
|
<CategoryTabs
|
||||||
active={activeCategory}
|
active={activeCategory}
|
||||||
counts={counts}
|
counts={counts}
|
||||||
onChange={(c) => { setActiveCategory(c); setSelectedItem(null); setPage(1) }}
|
onChange={(c) => {
|
||||||
|
setActiveCategory(c)
|
||||||
|
setSelectedItem(null)
|
||||||
|
setActiveTag(null)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{hasDigest && (
|
||||||
|
<div className="digest-sticky" data-testid="digest-sticky-header">
|
||||||
|
<div className="digest-sticky-head">
|
||||||
|
<h2 className="digest-sticky-title">Since {sinceLabel}</h2>
|
||||||
|
<span className="digest-sticky-count">
|
||||||
|
{sinceLastItems.length} item{sinceLastItems.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(sinceLastTags.length > 0 || activeTag) && (
|
||||||
|
<div className="digest-sticky-tags">
|
||||||
|
{sinceLastTags.map((tag) => (
|
||||||
|
<TagChip
|
||||||
|
key={tag.key}
|
||||||
|
tag={tag}
|
||||||
|
active={activeTag === tag.key}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeTag && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="digest-filter-clear"
|
||||||
|
onClick={() => setActiveTag(null)}
|
||||||
|
data-testid="digest-filter-clear"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
<div className="bp-empty">
|
<div className="bp-empty">
|
||||||
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
No {activeCategory !== 'all' ? activeCategory : ''} items available.
|
||||||
Run <code>box-box --ingest-news</code> to refresh feeds.
|
{activeTag ? ' Try clearing the tag filter.' : ''}
|
||||||
|
{!activeTag && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Run <code>box-box --ingest-news</code> to refresh feeds.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{showPagination && (
|
{windowSections.map(({ window, items }) => (
|
||||||
<div className="bp-pagination bp-pagination-top">
|
<section
|
||||||
<span className="bp-pagination-meta mono">
|
key={window.meeting_key}
|
||||||
{pageStart + 1}-{pageEnd} of {filtered.length}
|
className="digest-section"
|
||||||
</span>
|
data-testid={`digest-window-${window.meeting_key}`}
|
||||||
<div className="bp-pagination-actions">
|
>
|
||||||
<button
|
<div className="digest-section-head">
|
||||||
className="bp-page-btn"
|
<h3 className="digest-section-title">{window.meeting_name}</h3>
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
<span className="digest-section-count">{items.length}</span>
|
||||||
disabled={safePage === 1}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="bp-page-current mono">
|
|
||||||
Page {safePage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage + 1)}
|
|
||||||
disabled={safePage === pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
)}
|
{items.map((item) => {
|
||||||
|
const tagged = taggedByUrl.get(item.url) ?? { ...item, tags: [] }
|
||||||
|
return (
|
||||||
|
<BriefingCard
|
||||||
|
key={item.url}
|
||||||
|
item={tagged}
|
||||||
|
isActive={selectedItem?.url === item.url}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
{recentTagged.length > 0 && (
|
||||||
{pagedItems.map((item) => (
|
<section className="digest-section digest-recent-section" data-testid="digest-recent">
|
||||||
<BriefingCard
|
<div className="digest-section-head">
|
||||||
key={item.url}
|
<h3 className="digest-section-title">Recent</h3>
|
||||||
item={item}
|
<span className="digest-section-count">{recentTagged.length}</span>
|
||||||
isActive={selectedItem?.url === item.url}
|
</div>
|
||||||
onSelect={handleSelect}
|
<div className={`bp-grid${selectedItem ? ' bp-grid-narrow' : ''}`}>
|
||||||
/>
|
{recentTagged.map((item) => (
|
||||||
))}
|
<BriefingCard
|
||||||
</div>
|
key={item.url}
|
||||||
|
item={item}
|
||||||
{showPagination && (
|
isActive={selectedItem?.url === item.url}
|
||||||
<div className="bp-pagination bp-pagination-bottom">
|
activeTag={activeTag}
|
||||||
<button
|
onSelect={handleSelect}
|
||||||
className="bp-page-btn"
|
onTagClick={handleTagClick}
|
||||||
onClick={() => handlePageChange(safePage - 1)}
|
/>
|
||||||
disabled={safePage === 1}
|
))}
|
||||||
>
|
</div>
|
||||||
Previous
|
</section>
|
||||||
</button>
|
|
||||||
<span className="bp-page-current mono">
|
|
||||||
Page {safePage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="bp-page-btn"
|
|
||||||
onClick={() => handlePageChange(safePage + 1)}
|
|
||||||
disabled={safePage === pageCount}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
import { fetchChampionshipHub, fetchSeasons } from '../api'
|
||||||
import { teamColor } from '../utils'
|
import { teamColor } from '../utils'
|
||||||
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
import type { ChampHubDriver, ChampionshipHub } from '../types'
|
||||||
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
import { ChampionshipSimulator } from '../components/ChampionshipSimulator'
|
||||||
|
import { RivalryCompare } from '../components/RivalryCompare'
|
||||||
|
import { Meaning } from '../components/Meaning'
|
||||||
|
import { TeammateH2H } from '../components/TeammateH2H'
|
||||||
|
import { teammatePairs } from '../lib/h2h'
|
||||||
|
import { pointsGapMeaning } from '../lib/meaning'
|
||||||
|
|
||||||
type View = 'drivers' | 'constructors' | 'progression' | 'simulator'
|
type View = 'drivers' | 'constructors' | 'progression' | 'rivalry' | 'simulator'
|
||||||
|
|
||||||
const GOLD = '#ffd700'
|
const GOLD = '#ffd700'
|
||||||
const SILVER = '#c0c0c0'
|
const SILVER = '#c0c0c0'
|
||||||
@@ -138,6 +144,8 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
color: teamColor(d.team_colour),
|
color: teamColor(d.team_colour),
|
||||||
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
|
gapLeader: i === 0 ? 'LEADER' : `+${fmtPts(gapLeaderNum)}`,
|
||||||
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
|
gapAhead: gapAheadNum == null ? '—' : `+${fmtPts(gapAheadNum)}`,
|
||||||
|
gapAheadNum,
|
||||||
|
driverAhead: i === 0 ? null : drivers[i - 1].name_acronym,
|
||||||
spark: sparkPoints(d.form),
|
spark: sparkPoints(d.form),
|
||||||
h2h: `${d.teammate_wins}–${d.teammate_losses}`,
|
h2h: `${d.teammate_wins}–${d.teammate_losses}`,
|
||||||
h2hWin: d.teammate_wins >= d.teammate_losses,
|
h2hWin: d.teammate_wins >= d.teammate_losses,
|
||||||
@@ -209,6 +217,14 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
>
|
>
|
||||||
Progression
|
Progression
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`champ-tab${view === 'rivalry' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setView('rivalry')}
|
||||||
|
data-testid="champ-tab-rivalry"
|
||||||
|
>
|
||||||
|
Rivalry
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
className={`champ-tab${view === 'simulator' ? ' is-active' : ''}`}
|
||||||
@@ -233,10 +249,18 @@ function ChampionshipBody({ hub, view, setView }: BodyProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'drivers' && (
|
{view === 'drivers' && (
|
||||||
<DriversView enriched={enriched} leaderPoints={leader.points} titleMath={titleMath} />
|
<DriversView
|
||||||
|
enriched={enriched}
|
||||||
|
leaderPoints={leader.points}
|
||||||
|
titleMath={titleMath}
|
||||||
|
roundsLeft={hub.rounds_left}
|
||||||
|
drivers={drivers}
|
||||||
|
season={hub.season}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
{view === 'constructors' && <ConstructorsView hub={hub} />}
|
||||||
{view === 'progression' && <ProgressionView hub={hub} />}
|
{view === 'progression' && <ProgressionView hub={hub} />}
|
||||||
|
{view === 'rivalry' && <RivalryCompare hub={hub} />}
|
||||||
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
{view === 'simulator' && <ChampionshipSimulator hub={hub} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -248,6 +272,8 @@ interface EnrichedDriver {
|
|||||||
color: string
|
color: string
|
||||||
gapLeader: string
|
gapLeader: string
|
||||||
gapAhead: string
|
gapAhead: string
|
||||||
|
gapAheadNum: number | null
|
||||||
|
driverAhead: string | null
|
||||||
spark: string
|
spark: string
|
||||||
h2h: string
|
h2h: string
|
||||||
h2hWin: boolean
|
h2hWin: boolean
|
||||||
@@ -259,12 +285,20 @@ function DriversView({
|
|||||||
enriched,
|
enriched,
|
||||||
leaderPoints,
|
leaderPoints,
|
||||||
titleMath,
|
titleMath,
|
||||||
|
roundsLeft,
|
||||||
|
drivers,
|
||||||
|
season,
|
||||||
}: {
|
}: {
|
||||||
enriched: EnrichedDriver[]
|
enriched: EnrichedDriver[]
|
||||||
leaderPoints: number
|
leaderPoints: number
|
||||||
titleMath: string
|
titleMath: string
|
||||||
|
roundsLeft: number
|
||||||
|
drivers: ChampHubDriver[]
|
||||||
|
season: number
|
||||||
}) {
|
}) {
|
||||||
const podium = enriched.slice(0, 3)
|
const podium = enriched.slice(0, 3)
|
||||||
|
const h2hPairs = useMemo(() => teammatePairs(drivers), [drivers])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="champ-view-drivers">
|
<div data-testid="champ-view-drivers">
|
||||||
<div className="champ-podium">
|
<div className="champ-podium">
|
||||||
@@ -323,6 +357,29 @@ function DriversView({
|
|||||||
<span className="champ-titlemath-text">{titleMath}</span>
|
<span className="champ-titlemath-text">{titleMath}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{h2hPairs.length > 0 && (
|
||||||
|
<section className="h2h-section" data-testid="champ-teammate-battles">
|
||||||
|
<div className="h2h-section-head">
|
||||||
|
<h2 className="h2h-section-title">Teammate battles</h2>
|
||||||
|
<span className="h2h-section-sub">race finishes · closest first</span>
|
||||||
|
</div>
|
||||||
|
<div className="h2h-list">
|
||||||
|
{h2hPairs.map((pair) => (
|
||||||
|
<TeammateH2H
|
||||||
|
key={pair.teamName}
|
||||||
|
teamName={pair.teamName}
|
||||||
|
teamColour={pair.teamColour}
|
||||||
|
driverATla={pair.driverA.name_acronym}
|
||||||
|
driverBTla={pair.driverB.name_acronym}
|
||||||
|
winsA={pair.driverA.teammate_wins}
|
||||||
|
winsB={pair.driverB.teammate_wins}
|
||||||
|
extraNote={pair.extraCount > 0 ? `+${pair.extraCount}` : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="champ-scroll">
|
<div className="champ-scroll">
|
||||||
<table className="champ-table champ-table-drivers">
|
<table className="champ-table champ-table-drivers">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -347,17 +404,36 @@ function DriversView({
|
|||||||
P{e.pos}
|
P{e.pos}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="champ-drv">
|
<Link
|
||||||
|
to="/drivers/$driverNumber"
|
||||||
|
params={{ driverNumber: String(e.d.driver_number) }}
|
||||||
|
search={{ year: season }}
|
||||||
|
className="champ-drv"
|
||||||
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||||
|
data-testid={`champ-driver-link-${e.d.driver_number}`}
|
||||||
|
>
|
||||||
<span className="champ-drv-bar" style={{ background: e.color }} />
|
<span className="champ-drv-bar" style={{ background: e.color }} />
|
||||||
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
<span className="champ-drv-code mono">{e.d.name_acronym}</span>
|
||||||
<span className="champ-drv-name">{e.d.full_name}</span>
|
<span className="champ-drv-name">{e.d.full_name}</span>
|
||||||
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
<span className="champ-drv-num mono">#{e.d.driver_number}</span>
|
||||||
</div>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="champ-td-team">{e.d.team_name}</td>
|
<td className="champ-td-team">{e.d.team_name}</td>
|
||||||
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
<td className="r mono champ-td-pts">{fmtPts(e.d.points)}</td>
|
||||||
<td className="r mono champ-td-muted">{e.gapLeader}</td>
|
<td className="r mono champ-td-muted">{e.gapLeader}</td>
|
||||||
<td className="r mono champ-td-dim">{e.gapAhead}</td>
|
<td className="r mono champ-td-dim">
|
||||||
|
{(() => {
|
||||||
|
const gapAnnotation = pointsGapMeaning(e.gapAheadNum, roundsLeft, e.driverAhead)
|
||||||
|
return (
|
||||||
|
<Meaning
|
||||||
|
value={e.gapAhead}
|
||||||
|
meaning={gapAnnotation?.caption}
|
||||||
|
title={gapAnnotation?.title}
|
||||||
|
tone={gapAnnotation?.tone}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
<td className="c mono" style={{ color: e.d.wins > 0 ? 'var(--text)' : 'var(--text-3)' }}>
|
||||||
{e.d.wins}
|
{e.d.wins}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,451 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
fetchChampionshipHub,
|
|
||||||
fetchLiveState,
|
|
||||||
fetchLocalMeetings,
|
|
||||||
fetchRaceHub,
|
|
||||||
fetchSeasonMeetings,
|
|
||||||
fetchSeasons,
|
|
||||||
fetchSessions,
|
|
||||||
fetchWeekend,
|
|
||||||
} from '../api'
|
|
||||||
import { CommandCenterHero } from '../components/CommandCenterHero'
|
|
||||||
import { PaddockBriefing } from '../components/PaddockBriefing'
|
|
||||||
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
|
||||||
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
|
||||||
import { classifySessionStatus, heroState } from '../lib/hero'
|
|
||||||
import {
|
|
||||||
currentAndNextSession,
|
|
||||||
focusMeetingKind,
|
|
||||||
formatSessionScheduleTime,
|
|
||||||
meetingHasStarted,
|
|
||||||
mostRecentPastMeeting,
|
|
||||||
nextUpcomingMeeting,
|
|
||||||
pickFocusMeeting,
|
|
||||||
sortSessionsByStart,
|
|
||||||
} from '../lib/schedule'
|
|
||||||
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
|
|
||||||
import { Trophy } from 'lucide-react'
|
|
||||||
|
|
||||||
const missingDatasets = Object.fromEntries(
|
|
||||||
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
|
|
||||||
) as WeekendSession['datasets']
|
|
||||||
|
|
||||||
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
|
||||||
if (meeting.meeting_key === focusKey) return 'focus'
|
|
||||||
if (meetingHasStarted(meeting, now)) return 'past'
|
|
||||||
return 'future'
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
|
||||||
if (!weekend) return undefined
|
|
||||||
const local = weekend.sessions.filter((s) => s.source === 'local')
|
|
||||||
const partial = weekend.sessions.filter((s) => s.source === 'partial')
|
|
||||||
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
|
|
||||||
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
|
||||||
if (race) return race
|
|
||||||
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
|
||||||
if (qual) return qual
|
|
||||||
return pool[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandCenterPage() {
|
|
||||||
const [now, setNow] = useState(() => Date.now())
|
|
||||||
|
|
||||||
const seasonsQuery = useQuery({
|
|
||||||
queryKey: ['seasons'],
|
|
||||||
queryFn: fetchSeasons,
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
|
||||||
queryKey: ['meetings', latestSeason],
|
|
||||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const seasonMeetingsQuery = useQuery({
|
|
||||||
queryKey: ['season-meetings', latestSeason],
|
|
||||||
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const localMeetings = meetingsQuery.data ?? []
|
|
||||||
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
|
||||||
const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
|
|
||||||
|
|
||||||
const weekendQueries = useQueries({
|
|
||||||
queries: localMeetings.map((meeting) => ({
|
|
||||||
queryKey: ['weekend', meeting.meeting_key],
|
|
||||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
|
||||||
enabled: localMeetings.length > 0,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const liveQuery = useQuery({
|
|
||||||
queryKey: ['live-state'],
|
|
||||||
queryFn: fetchLiveState,
|
|
||||||
staleTime: 5_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const championshipQuery = useQuery({
|
|
||||||
queryKey: ['championship-hub', latestSeason],
|
|
||||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
|
||||||
enabled: latestSeason != null,
|
|
||||||
})
|
|
||||||
const champHub = championshipQuery.data
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const nowDate = useMemo(() => new Date(now), [now])
|
|
||||||
|
|
||||||
const weekendsByKey = useMemo(() => {
|
|
||||||
const map = new Map<number, Weekend>()
|
|
||||||
localMeetings.forEach((meeting, i) => {
|
|
||||||
const data = weekendQueries[i]?.data
|
|
||||||
if (data) map.set(meeting.meeting_key, data)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
}, [localMeetings, weekendQueries])
|
|
||||||
|
|
||||||
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
|
|
||||||
const meetingStats = countWeekendStats(weekendList)
|
|
||||||
const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
|
|
||||||
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
|
|
||||||
const openF1SessionsQuery = useQuery({
|
|
||||||
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
|
|
||||||
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
|
|
||||||
enabled: focusMeeting != null && focusWeekend == null,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
const openF1WeekendSessions: WeekendSession[] = useMemo(
|
|
||||||
() =>
|
|
||||||
(openF1SessionsQuery.data ?? []).map((session) => ({
|
|
||||||
session,
|
|
||||||
source: 'none',
|
|
||||||
datasets: missingDatasets,
|
|
||||||
})),
|
|
||||||
[openF1SessionsQuery.data],
|
|
||||||
)
|
|
||||||
const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions
|
|
||||||
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
|
|
||||||
const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
|
|
||||||
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
|
|
||||||
|
|
||||||
const analysisSession = pickAnalysisSession(focusWeekend)
|
|
||||||
const actionSession =
|
|
||||||
analysisSession?.session ??
|
|
||||||
currentSession ??
|
|
||||||
nextSession ??
|
|
||||||
focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ??
|
|
||||||
focusWeekendSessions[0]?.session
|
|
||||||
const analysisSessionKey = actionSession?.session_key
|
|
||||||
|
|
||||||
const liveActive = liveQuery.data?.is_live === true
|
|
||||||
const heroStateKind = heroState({
|
|
||||||
now: nowDate,
|
|
||||||
liveActive,
|
|
||||||
currentSession,
|
|
||||||
focusKind,
|
|
||||||
})
|
|
||||||
|
|
||||||
const lastPastMeeting = useMemo(
|
|
||||||
() => mostRecentPastMeeting(focusMeetings, nowDate),
|
|
||||||
[focusMeetings, nowDate],
|
|
||||||
)
|
|
||||||
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
|
||||||
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
|
||||||
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
|
||||||
|
|
||||||
const lastRaceHubQuery = useQuery({
|
|
||||||
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
|
||||||
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
|
|
||||||
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextMeetingForHero = useMemo(() => {
|
|
||||||
if (heroStateKind !== 'between') return null
|
|
||||||
return nextUpcomingMeeting(focusMeetings, nowDate)
|
|
||||||
}, [heroStateKind, focusMeetings, nowDate])
|
|
||||||
|
|
||||||
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
|
||||||
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
|
||||||
return <div className="page loading-state">loading command center…</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seasonsQuery.isError) {
|
|
||||||
return (
|
|
||||||
<div className="page error-box">
|
|
||||||
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const seasons = seasonsQuery.data ?? []
|
|
||||||
|
|
||||||
if (seasons.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="cc-page cc-empty" data-testid="command-center-empty">
|
|
||||||
<div className="cc-empty-band">
|
|
||||||
<span className="cc-empty-eyebrow mono">box-box · command center</span>
|
|
||||||
<h1 className="cc-empty-title">No local data yet</h1>
|
|
||||||
<p className="cc-empty-sub">
|
|
||||||
Ingest a race weekend from the CLI to populate this screen with live status, next-session
|
|
||||||
countdowns, and analysis links.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="cc-empty-actions">
|
|
||||||
<Link to="/live" className="cc-action cc-action-live">
|
|
||||||
<span className="cc-action-label">Live Timing</span>
|
|
||||||
<span className="cc-action-meta">Standby</span>
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin" className="cc-action">
|
|
||||||
<span className="cc-action-label">Admin · Data Health</span>
|
|
||||||
<span className="cc-action-meta">Ingestion guidance</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const accent = countryAccent(focusMeeting ?? null)
|
|
||||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cc-page" data-testid="command-center" style={accentStyle}>
|
|
||||||
<div className="cc-topbar">
|
|
||||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
|
||||||
<span className="cc-topbar-meta mono">
|
|
||||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
|
||||||
</span>
|
|
||||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
|
||||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
|
||||||
{liveActive ? 'Live session active' : 'No live session'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!focusMeeting && (
|
|
||||||
<div className="missing-notice">
|
|
||||||
No meetings ingested for {latestSeason}. Run{' '}
|
|
||||||
<code>box-box --ingest-year {latestSeason}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{focusMeeting && focusKind && (
|
|
||||||
<CommandCenterHero
|
|
||||||
state={heroStateKind}
|
|
||||||
now={nowDate}
|
|
||||||
accent={accent}
|
|
||||||
liveActive={liveActive}
|
|
||||||
liveData={liveQuery.data?.data}
|
|
||||||
focusMeeting={focusMeeting}
|
|
||||||
focusKind={focusKind}
|
|
||||||
sessions={focusWeekendSessions}
|
|
||||||
currentSession={currentSession}
|
|
||||||
nextSession={nextSession}
|
|
||||||
analysisSessionKey={analysisSessionKey}
|
|
||||||
analysisSessionName={actionSession?.session_name}
|
|
||||||
lastRaceName={lastRaceName}
|
|
||||||
lastRacePodium={lastRacePodium}
|
|
||||||
lastRaceSessionKey={lastRaceSessionKey}
|
|
||||||
nextMeeting={nextMeetingForHero}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="cc-dashboard-grid">
|
|
||||||
<div className="cc-dashboard-main">
|
|
||||||
<div
|
|
||||||
className="cc-ambient-aura"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={focusMeeting ? { '--aura-accent': countryAccent(focusMeeting) } as React.CSSProperties : undefined}
|
|
||||||
/>
|
|
||||||
<div className="cc-dashboard-panel">
|
|
||||||
{seasonMeetings.length > 0 && (
|
|
||||||
<section className="cc-season-calendar" data-testid="cc-season-calendar">
|
|
||||||
<div className="sec-header">
|
|
||||||
<span className="sec-title">Season Calendar</span>
|
|
||||||
<span className="sec-meta mono">
|
|
||||||
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="cc-calendar-grid" role="list">
|
|
||||||
{seasonMeetings.map((meeting, index) => {
|
|
||||||
const weekend = weekendsByKey.get(meeting.meeting_key)
|
|
||||||
const target = pickAnalysisSession(weekend)
|
|
||||||
const targetKey = target?.session.session_key ?? weekend?.default_session_key
|
|
||||||
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
|
|
||||||
const cardAccent = countryAccent(meeting)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={meeting.meeting_key}
|
|
||||||
to="/race-hub"
|
|
||||||
search={targetKey ? { session_key: targetKey } : {}}
|
|
||||||
className={`cc-calendar-card cc-calendar-${status}`}
|
|
||||||
data-testid={`cc-calendar-${meeting.meeting_key}`}
|
|
||||||
role="listitem"
|
|
||||||
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
|
||||||
<div className="cc-calendar-top mono">
|
|
||||||
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
|
||||||
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div className="cc-calendar-id">
|
|
||||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
|
||||||
</div>
|
|
||||||
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
|
|
||||||
<div className="cc-calendar-copy">
|
|
||||||
<div className="cc-calendar-name">{meeting.meeting_name}</div>
|
|
||||||
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
|
|
||||||
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{seasonMeetingsQuery.isError && (
|
|
||||||
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="cc-dashboard-sidebar">
|
|
||||||
{champHub && (
|
|
||||||
<section className="cc-champ-snapshot glass-panel" data-testid="cc-champ-snapshot" style={{ padding: 'var(--s5)', borderRadius: '12px' }}>
|
|
||||||
<div className="cc-champ-header">
|
|
||||||
<span>Championship Leaders</span>
|
|
||||||
<Link to="/championship" className="cc-champ-link">Full Standings →</Link>
|
|
||||||
</div>
|
|
||||||
<div className="cc-champ-group-label">Drivers</div>
|
|
||||||
<div className="cc-champ-list">
|
|
||||||
{champHub.drivers.slice(0, 3).map((d) => (
|
|
||||||
<div key={d.driver_number} className="cc-champ-row">
|
|
||||||
<div className="cc-champ-left">
|
|
||||||
<div className="cc-champ-color" style={{ color: `#${d.team_colour}`, background: 'currentColor' }} />
|
|
||||||
<span className="cc-champ-name">{d.name_acronym}</span>
|
|
||||||
</div>
|
|
||||||
<div className="cc-champ-form" title="Last 5 races">
|
|
||||||
<FormSparkline form={d.form} color={`#${d.team_colour}`} />
|
|
||||||
</div>
|
|
||||||
<div className="cc-champ-right">
|
|
||||||
<span className="cc-champ-gap">
|
|
||||||
{d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`}
|
|
||||||
</span>
|
|
||||||
<span className="cc-champ-pts">{d.points}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="cc-champ-group-label">Constructors</div>
|
|
||||||
<div className="cc-champ-list">
|
|
||||||
{champHub.teams.slice(0, 3).map((t, i) => (
|
|
||||||
<div key={t.team_name} className="cc-champ-row">
|
|
||||||
<div className="cc-champ-left">
|
|
||||||
<div className="cc-champ-color" style={{ color: `#${t.team_colour}`, background: 'currentColor' }} />
|
|
||||||
<span className="cc-champ-name">{t.team_name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="cc-champ-form" style={{ opacity: 0.4, fontSize: '11px', display: 'flex', gap: '4px', alignItems: 'center' }} title={`${t.wins} Wins`}>
|
|
||||||
<Trophy size={12} /> {t.wins}
|
|
||||||
</div>
|
|
||||||
<div className="cc-champ-right">
|
|
||||||
<span className="cc-champ-gap">
|
|
||||||
{i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`}
|
|
||||||
</span>
|
|
||||||
<span className="cc-champ-pts">{t.points}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{focusWeekendSessions.length > 0 && (
|
|
||||||
<section className="cc-schedule" data-testid="cc-schedule">
|
|
||||||
<div className="sec-header">
|
|
||||||
<span className="sec-title">Weekend Schedule</span>
|
|
||||||
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
|
|
||||||
</div>
|
|
||||||
<div className="cc-session-strip" role="list">
|
|
||||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
|
||||||
const status = classifySessionStatus(session, nowDate)
|
|
||||||
const isNext = nextSession?.session_key === session.session_key
|
|
||||||
const isCurrent = currentSession?.session_key === session.session_key
|
|
||||||
const isLive = isCurrent && liveActive
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={session.session_key}
|
|
||||||
to="/race-hub"
|
|
||||||
search={{ session_key: session.session_key }}
|
|
||||||
className={`cc-session-card ui-card interactive cc-status-${status}${isNext ? ' is-next' : ''}${
|
|
||||||
isLive ? ' is-live glass-panel' : ''
|
|
||||||
}`}
|
|
||||||
data-testid={`cc-session-${session.session_key}`}
|
|
||||||
role="listitem"
|
|
||||||
>
|
|
||||||
<div className="cc-session-card-head">
|
|
||||||
<span className="cc-session-abbrev mono">
|
|
||||||
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
|
||||||
</span>
|
|
||||||
<span className={`cc-session-status cc-status-pill-${status}`}>
|
|
||||||
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="cc-session-name">{session.session_name}</div>
|
|
||||||
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
|
||||||
<div className="cc-session-cov mono">
|
|
||||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
|
||||||
{formatCoverageHint(datasets)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PaddockBriefing />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormSparkline({ form, color }: { form: number[], color: string }) {
|
|
||||||
if (!form || form.length === 0) return null
|
|
||||||
const max = Math.max(...form, 26) // 26 is standard max for a race win
|
|
||||||
const width = 45
|
|
||||||
const height = 14
|
|
||||||
const step = width / Math.max(form.length - 1, 1)
|
|
||||||
|
|
||||||
const points = form.map((val, i) => {
|
|
||||||
const x = i * step
|
|
||||||
const y = height - (val / max) * height
|
|
||||||
return `${x},${y}`
|
|
||||||
}).join(' ')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg width={width} height={height} style={{ overflow: 'visible' }}>
|
|
||||||
<polyline
|
|
||||||
fill="none"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
points={points}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
369
frontend/src/pages/DriverProfilePage.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { fetchDriverSummary, fetchSeasons } from '../api'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import { countryFlag } from '../lib/gpIdentity'
|
||||||
|
import {
|
||||||
|
formatDelta,
|
||||||
|
formatPosition,
|
||||||
|
gridFinishDeltas,
|
||||||
|
type RoundDelta,
|
||||||
|
} from '../lib/driverProfile'
|
||||||
|
import type { DriverSummary } from '../types'
|
||||||
|
import '../styles/driver-profile.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
driverNumber: number
|
||||||
|
year?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPts(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverProfilePage({ driverNumber, year }: Props) {
|
||||||
|
// Without an explicit ?year=, default to the latest ingested season; if the
|
||||||
|
// seasons list is empty/unavailable the backend falls back to the current year.
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
enabled: year == null,
|
||||||
|
})
|
||||||
|
const resolvedYear = year ?? seasonsQuery.data?.[0]
|
||||||
|
const seasonsSettled = year != null || !seasonsQuery.isLoading
|
||||||
|
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ['driver-summary', driverNumber, resolvedYear ?? 'latest'],
|
||||||
|
queryFn: () => fetchDriverSummary(driverNumber, resolvedYear),
|
||||||
|
enabled: seasonsSettled && driverNumber > 0,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (driverNumber <= 0) {
|
||||||
|
return <div className="page error-box">Invalid driver number</div>
|
||||||
|
}
|
||||||
|
if (!seasonsSettled || summaryQuery.isLoading) {
|
||||||
|
return <div className="page loading-state">loading driver profile…</div>
|
||||||
|
}
|
||||||
|
if (summaryQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page error-box">
|
||||||
|
{summaryQuery.error instanceof Error
|
||||||
|
? summaryQuery.error.message
|
||||||
|
: 'Failed to load driver profile'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const summary = summaryQuery.data
|
||||||
|
if (!summary) {
|
||||||
|
return <div className="page error-box">No driver data</div>
|
||||||
|
}
|
||||||
|
return <DriverProfileBody summary={summary} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriverProfileBody({ summary }: { summary: DriverSummary }) {
|
||||||
|
const color = teamColor(summary.team_colour)
|
||||||
|
const deltas = gridFinishDeltas(summary.rounds)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dp-page" data-testid="driver-profile">
|
||||||
|
<header className="dp-header" data-testid="dp-header" style={{ borderLeftColor: color }}>
|
||||||
|
<div className="dp-identity">
|
||||||
|
<div className="dp-title-row">
|
||||||
|
<span className="dp-code mono">{summary.name_acronym || `#${summary.driver_number}`}</span>
|
||||||
|
<span className="dp-num mono" style={{ color }}>
|
||||||
|
#{summary.driver_number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="dp-name">{summary.full_name || `Driver ${summary.driver_number}`}</h1>
|
||||||
|
<div className="dp-team">
|
||||||
|
<span className="dp-team-swatch" style={{ background: color }} />
|
||||||
|
{summary.team_name || 'Unknown team'}
|
||||||
|
<span className="dp-season mono">{summary.season}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat-rail">
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.position > 0 ? `P${summary.position}` : '—'}</div>
|
||||||
|
<div className="dp-stat-label">Championship</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{fmtPts(summary.points)}</div>
|
||||||
|
<div className="dp-stat-label">Points</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.wins}</div>
|
||||||
|
<div className="dp-stat-label">Wins</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.podiums}</div>
|
||||||
|
<div className="dp-stat-label">Podiums</div>
|
||||||
|
</div>
|
||||||
|
<div className="dp-stat">
|
||||||
|
<div className="dp-stat-value mono">{summary.poles}</div>
|
||||||
|
<div className="dp-stat-label">Poles</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link to="/championship" className="dp-back mono">
|
||||||
|
← Championship
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="dp-section" data-testid="dp-form">
|
||||||
|
<h2 className="dp-section-title mono">Season form</h2>
|
||||||
|
{summary.cumulative.length > 0 ? (
|
||||||
|
<div className="dp-form-grid">
|
||||||
|
<CumulativeLine
|
||||||
|
cumulative={summary.cumulative}
|
||||||
|
labels={summary.round_labels}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
<FormStrip form={summary.form} color={color} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dp-empty">No completed rounds yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dp-section" data-testid="dp-quali-race">
|
||||||
|
<h2 className="dp-section-title mono">Quali vs race</h2>
|
||||||
|
{deltas.some((d) => d.delta != null) ? (
|
||||||
|
<GridVsRaceChart deltas={deltas} color={color} />
|
||||||
|
) : (
|
||||||
|
<div className="dp-empty">No grid-vs-finish data yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dp-section" data-testid="dp-rounds">
|
||||||
|
<h2 className="dp-section-title mono">Track by track</h2>
|
||||||
|
{summary.rounds.length > 0 ? (
|
||||||
|
<RoundsTable summary={summary} deltas={deltas} />
|
||||||
|
) : (
|
||||||
|
<div className="dp-empty">No completed rounds yet.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINE_W = 640
|
||||||
|
const LINE_H = 180
|
||||||
|
const LINE_PL = 44
|
||||||
|
const LINE_PR = 16
|
||||||
|
const LINE_PT = 12
|
||||||
|
const LINE_PB = 24
|
||||||
|
|
||||||
|
function CumulativeLine({
|
||||||
|
cumulative,
|
||||||
|
labels,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
cumulative: number[]
|
||||||
|
labels: string[]
|
||||||
|
color: string
|
||||||
|
}) {
|
||||||
|
const n = cumulative.length
|
||||||
|
const maxY = Math.max(25, ...cumulative)
|
||||||
|
const plotW = LINE_W - LINE_PL - LINE_PR
|
||||||
|
const plotH = LINE_H - LINE_PT - LINE_PB
|
||||||
|
const x = (i: number) => (n <= 1 ? LINE_PL + plotW / 2 : LINE_PL + (i * plotW) / (n - 1))
|
||||||
|
const y = (v: number) => LINE_PT + plotH - (v / maxY) * plotH
|
||||||
|
const points = cumulative.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||||
|
|
||||||
|
const tickStep = Math.max(1, Math.ceil(n / 8))
|
||||||
|
const ticks: { x: number; label: string }[] = []
|
||||||
|
for (let i = 0; i < n; i += tickStep) {
|
||||||
|
ticks.push({ x: x(i), label: labels[i] ?? `R${i + 1}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="dp-chart">
|
||||||
|
<figcaption className="dp-chart-caption mono">Cumulative points</figcaption>
|
||||||
|
<svg viewBox={`0 0 ${LINE_W} ${LINE_H}`} className="dp-chart-svg" role="img" aria-label="Cumulative points per round">
|
||||||
|
{[0, 0.5, 1].map((f) => {
|
||||||
|
const v = Math.round(maxY * f)
|
||||||
|
return (
|
||||||
|
<g key={f}>
|
||||||
|
<line x1={LINE_PL} y1={y(v)} x2={LINE_W - LINE_PR} y2={y(v)} className="dp-gridline" />
|
||||||
|
<text x={LINE_PL - 6} y={y(v) + 3} textAnchor="end" className="dp-axis">
|
||||||
|
{v}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{ticks.map((t) => (
|
||||||
|
<text key={t.label} x={t.x} y={LINE_H - 8} textAnchor="middle" className="dp-axis">
|
||||||
|
{t.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={x(n - 1)} cy={y(cumulative[n - 1])} r="3.2" fill={color} />
|
||||||
|
</svg>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormStrip({ form, color }: { form: number[]; color: string }) {
|
||||||
|
const max = Math.max(25, ...form)
|
||||||
|
return (
|
||||||
|
<div className="dp-form-strip">
|
||||||
|
<div className="dp-chart-caption mono">Last {form.length} races</div>
|
||||||
|
<div className="dp-form-bars">
|
||||||
|
{form.map((pts, i) => (
|
||||||
|
<div className="dp-form-bar" key={i} title={`${fmtPts(pts)} pts`}>
|
||||||
|
<div
|
||||||
|
className="dp-form-bar-fill"
|
||||||
|
style={{ height: `${Math.max(4, (pts / max) * 100)}%`, background: color }}
|
||||||
|
/>
|
||||||
|
<span className="dp-form-bar-val mono">{fmtPts(pts)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DELTA_H = 190
|
||||||
|
const DELTA_PT = 14
|
||||||
|
const DELTA_PB = 30
|
||||||
|
|
||||||
|
function GridVsRaceChart({ deltas, color }: { deltas: RoundDelta[]; color: string }) {
|
||||||
|
const maxAbs = Math.max(1, ...deltas.map((d) => Math.abs(d.delta ?? 0)))
|
||||||
|
const colW = 34
|
||||||
|
const w = Math.max(240, deltas.length * colW)
|
||||||
|
const plotH = DELTA_H - DELTA_PT - DELTA_PB
|
||||||
|
const zeroY = DELTA_PT + plotH / 2
|
||||||
|
const scale = plotH / 2 / maxAbs
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="dp-chart">
|
||||||
|
<figcaption className="dp-chart-caption mono">
|
||||||
|
Positions gained (▲) / lost (▼) from grid to flag, per round
|
||||||
|
</figcaption>
|
||||||
|
<div className="dp-chart-scroll">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${w} ${DELTA_H}`}
|
||||||
|
width={w}
|
||||||
|
height={DELTA_H}
|
||||||
|
className="dp-delta-svg"
|
||||||
|
role="img"
|
||||||
|
aria-label="Positions gained or lost per round"
|
||||||
|
>
|
||||||
|
<line x1={0} y1={zeroY} x2={w} y2={zeroY} className="dp-zeroline" />
|
||||||
|
{deltas.map((d, i) => {
|
||||||
|
const cx = i * colW + colW / 2
|
||||||
|
if (d.delta == null) {
|
||||||
|
return (
|
||||||
|
<g key={d.round}>
|
||||||
|
<text x={cx} y={zeroY - 6} textAnchor="middle" className="dp-axis">
|
||||||
|
·
|
||||||
|
</text>
|
||||||
|
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||||||
|
R{d.round}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const h = Math.abs(d.delta) * scale
|
||||||
|
const yTop = d.delta >= 0 ? zeroY - h : zeroY
|
||||||
|
return (
|
||||||
|
<g key={d.round}>
|
||||||
|
<title>{`${d.label}: ${formatPosition(d.grid)} → ${formatPosition(d.finish)} (${formatDelta(d.delta)})`}</title>
|
||||||
|
<rect
|
||||||
|
x={cx - 8}
|
||||||
|
y={yTop}
|
||||||
|
width={16}
|
||||||
|
height={Math.max(1.5, h)}
|
||||||
|
rx={2}
|
||||||
|
fill={color}
|
||||||
|
opacity={d.delta >= 0 ? 0.95 : 0.45}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={d.delta >= 0 ? yTop - 4 : yTop + h + 11}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="dp-delta-val mono"
|
||||||
|
>
|
||||||
|
{formatDelta(d.delta)}
|
||||||
|
</text>
|
||||||
|
<text x={cx} y={DELTA_H - 8} textAnchor="middle" className="dp-axis">
|
||||||
|
R{d.round}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusNote(d: RoundDelta): string {
|
||||||
|
if (d.status === 'dnf') return 'DNF'
|
||||||
|
if (d.status === 'dns') return 'DNS'
|
||||||
|
if (d.status === 'dsq') return 'DSQ'
|
||||||
|
if (d.status === 'absent') return '—'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundsTable({ summary, deltas }: { summary: DriverSummary; deltas: RoundDelta[] }) {
|
||||||
|
return (
|
||||||
|
<div className="dp-scroll">
|
||||||
|
<table className="dp-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="l">Rnd</th>
|
||||||
|
<th className="l">Grand Prix</th>
|
||||||
|
<th className="r">Grid</th>
|
||||||
|
<th className="r">Race</th>
|
||||||
|
<th className="r">Δ</th>
|
||||||
|
<th className="r">Pts</th>
|
||||||
|
<th className="l"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{summary.rounds.map((r, i) => {
|
||||||
|
const d = deltas[i]
|
||||||
|
return (
|
||||||
|
<tr key={r.meeting_key}>
|
||||||
|
<td className="mono dp-td-dim">R{i + 1}</td>
|
||||||
|
<td>
|
||||||
|
<span className="dp-flag" aria-hidden="true">
|
||||||
|
{countryFlag({ country_code: r.country_code, country_flag: '' })}
|
||||||
|
</span>
|
||||||
|
{r.meeting_name}
|
||||||
|
</td>
|
||||||
|
<td className="r mono">{formatPosition(d.grid)}</td>
|
||||||
|
<td className="r mono">{formatPosition(d.finish)}</td>
|
||||||
|
<td
|
||||||
|
className="r mono"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
d.delta == null
|
||||||
|
? 'var(--text-3)'
|
||||||
|
: d.delta > 0
|
||||||
|
? 'var(--green)'
|
||||||
|
: d.delta < 0
|
||||||
|
? 'var(--red)'
|
||||||
|
: 'var(--text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatDelta(d.delta)}
|
||||||
|
</td>
|
||||||
|
<td className="r mono">{fmtPts(r.points)}</td>
|
||||||
|
<td className="dp-td-dim mono">{statusNote(d)}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
frontend/src/pages/ExplorePage.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchLocalMeetings,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchWeekend,
|
||||||
|
} from '../api'
|
||||||
|
import { countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import { meetingHasStarted } from '../lib/schedule'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import type { Weekend } from '../types'
|
||||||
|
import '../styles/weekend.css'
|
||||||
|
|
||||||
|
function analysisKey(weekend: Weekend | undefined): number | undefined {
|
||||||
|
if (!weekend) return undefined
|
||||||
|
const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||||
|
return race?.session.session_key ?? weekend.default_session_key
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExplorePage() {
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||||
|
const season = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const localMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', season, 'local'],
|
||||||
|
queryFn: () => fetchLocalMeetings(season!),
|
||||||
|
enabled: season != null,
|
||||||
|
})
|
||||||
|
const seasonMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['season-meetings', season],
|
||||||
|
queryFn: () => fetchSeasonMeetings(season!),
|
||||||
|
enabled: season != null,
|
||||||
|
})
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', season],
|
||||||
|
queryFn: () => fetchChampionshipHub(season!),
|
||||||
|
enabled: season != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const localMeetings = localMeetingsQuery.data ?? []
|
||||||
|
const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
||||||
|
|
||||||
|
const weekendQueries = useQueries({
|
||||||
|
queries: localMeetings.map((m) => ({
|
||||||
|
queryKey: ['weekend', m.meeting_key],
|
||||||
|
queryFn: () => fetchWeekend(m.meeting_key),
|
||||||
|
enabled: localMeetings.length > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const weekendsByKey = useMemo(() => {
|
||||||
|
const map = new Map<number, Weekend>()
|
||||||
|
localMeetings.forEach((m, i) => {
|
||||||
|
const data = weekendQueries[i]?.data
|
||||||
|
if (data) map.set(m.meeting_key, data)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [localMeetings, weekendQueries])
|
||||||
|
|
||||||
|
const drivers = championshipQuery.data?.drivers ?? []
|
||||||
|
const activeMeetings = meetings.filter((m) => !m.is_cancelled)
|
||||||
|
|
||||||
|
if (seasonsQuery.isLoading) {
|
||||||
|
return <div className="wk-status" data-testid="explore-loading"><p className="wk-status-title">Loading…</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="wk-page wk-explore" data-testid="explore-page">
|
||||||
|
<div className="wk-eyebrow mono">Explore · {season ?? 'season'}</div>
|
||||||
|
|
||||||
|
<section className="wk-explore-section" aria-labelledby="explore-calendar-title">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title" id="explore-calendar-title">Season races</span>
|
||||||
|
<span className="wk-card-link mono">{activeMeetings.length} rounds</span>
|
||||||
|
</header>
|
||||||
|
{activeMeetings.length === 0 ? (
|
||||||
|
<p className="wk-status-sub" data-testid="explore-calendar-empty">No season calendar available yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="wk-explore-grid" role="list">
|
||||||
|
{activeMeetings.map((meeting, index) => {
|
||||||
|
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||||
|
const target = analysisKey(weekend)
|
||||||
|
const done = meetingHasStarted(meeting, now)
|
||||||
|
const card = (
|
||||||
|
<>
|
||||||
|
<div className="wk-explore-top mono">
|
||||||
|
<span>R{String(index + 1).padStart(2, '0')}</span>
|
||||||
|
<span className="wk-explore-flag">
|
||||||
|
{countryFlag(meeting) || meeting.country_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="wk-explore-name">{meeting.meeting_name}</div>
|
||||||
|
<div className="wk-explore-meta mono">{meeting.circuit_short_name || meeting.location}</div>
|
||||||
|
<div className="wk-explore-meta mono">{formatGpDateRange(meeting)}</div>
|
||||||
|
<span className={`wk-explore-state ${done ? 'is-done' : 'is-upcoming'}`}>
|
||||||
|
{done ? 'Completed' : 'Upcoming'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<li key={meeting.meeting_key} className="wk-explore-item" role="listitem">
|
||||||
|
{target ? (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: target }}
|
||||||
|
className="wk-explore-card is-link"
|
||||||
|
data-testid={`explore-race-${meeting.meeting_key}`}
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="wk-explore-card" data-testid={`explore-race-${meeting.meeting_key}`}>{card}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="wk-explore-section" aria-labelledby="explore-drivers-title">
|
||||||
|
<header className="wk-card-head">
|
||||||
|
<span className="wk-card-title" id="explore-drivers-title">Drivers</span>
|
||||||
|
<Link to="/championship" className="wk-card-link">Championship →</Link>
|
||||||
|
</header>
|
||||||
|
{drivers.length === 0 ? (
|
||||||
|
<p className="wk-status-sub" data-testid="explore-drivers-empty">Driver data not available yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="wk-explore-drivers" role="list">
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<li key={d.driver_number} role="listitem">
|
||||||
|
<Link
|
||||||
|
to="/drivers/$driverNumber"
|
||||||
|
params={{ driverNumber: String(d.driver_number) }}
|
||||||
|
search={season ? { year: season } : {}}
|
||||||
|
className="wk-driver-chip"
|
||||||
|
style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}
|
||||||
|
data-testid={`explore-driver-${d.driver_number}`}
|
||||||
|
>
|
||||||
|
<span className="wk-driver-pos mono">{d.position}</span>
|
||||||
|
<span className="wk-driver-code">{d.name_acronym}</span>
|
||||||
|
<span className="wk-driver-pts mono">{d.points}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchLiveState, fetchLiveTrackOutline } from '../api'
|
import { fetchLiveState } from '../api'
|
||||||
import type { LivePosition, LiveStreamData } from '../types'
|
import type { LivePosition, LiveStreamData } from '../types'
|
||||||
import {
|
import {
|
||||||
loadPinnedDrivers,
|
loadPinnedDrivers,
|
||||||
@@ -15,19 +15,27 @@ import type { VisibleSectorState } from '../lib/live'
|
|||||||
import type { GapHistoryMap } from '../lib/gapHistory'
|
import type { GapHistoryMap } from '../lib/gapHistory'
|
||||||
import { recordGapSamples } from '../lib/gapHistory'
|
import { recordGapSamples } from '../lib/gapHistory'
|
||||||
import { battleNumbers, detectBattles } from '../lib/battles'
|
import { battleNumbers, detectBattles } from '../lib/battles'
|
||||||
|
import type { LiveEvent } from '../lib/events'
|
||||||
|
import { appendEvents, diffSnapshots, sessionSignature } from '../lib/events'
|
||||||
import { SessionBanner } from '../components/live/SessionBanner'
|
import { SessionBanner } from '../components/live/SessionBanner'
|
||||||
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
import { TrackStatusBanner } from '../components/live/TrackStatusBanner'
|
||||||
import { TimingTower } from '../components/live/TimingTower'
|
import { TimingTower } from '../components/live/TimingTower'
|
||||||
import { BattleChips } from '../components/live/BattleChips'
|
import { BattleChips } from '../components/live/BattleChips'
|
||||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||||
import { TrackMap } from '../components/live/TrackMap'
|
import { EventRail } from '../components/live/EventRail'
|
||||||
import { Radio } from 'lucide-react'
|
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||||
|
import { TyreDegPanel } from '../components/live/TyreDegPanel'
|
||||||
|
import { Archive, Radio } from 'lucide-react'
|
||||||
|
|
||||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||||
|
|
||||||
export function LiveTimingPage() {
|
export function LiveTimingPage() {
|
||||||
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
|
const [activeSnapshot, setActiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||||
|
const [archiveSnapshot, setArchiveSnapshot] = useState<LiveStreamData | null>(null)
|
||||||
|
const [archivePositions, setArchivePositions] = useState<Record<string, LivePosition>>({})
|
||||||
|
const [archiveSnapshotAt, setArchiveSnapshotAt] = useState<string | null>(null)
|
||||||
|
const [archiveMode, setArchiveMode] = useState(false)
|
||||||
const [isLive, setIsLive] = useState(false)
|
const [isLive, setIsLive] = useState(false)
|
||||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||||
const [now, setNow] = useState(Date.now())
|
const [now, setNow] = useState(Date.now())
|
||||||
@@ -35,6 +43,13 @@ export function LiveTimingPage() {
|
|||||||
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
const [pinned, setPinned] = useState<string[]>(() => loadPinnedDrivers())
|
||||||
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
const [visibleSectors, setVisibleSectors] = useState<VisibleSectorState>({})
|
||||||
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
const [positions, setPositions] = useState<Record<string, LivePosition>>({})
|
||||||
|
const [events, setEvents] = useState<LiveEvent[]>([])
|
||||||
|
const prevSnapshotRef = useRef<LiveStreamData | null>(null)
|
||||||
|
const sessionSigRef = useRef('')
|
||||||
|
const isLiveRef = useRef(false)
|
||||||
|
const archiveModeRef = useRef(false)
|
||||||
|
const snapshot = isLive ? activeSnapshot : archiveMode ? archiveSnapshot : null
|
||||||
|
const hasArchive = Boolean(archiveSnapshot)
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ['live-state'],
|
queryKey: ['live-state'],
|
||||||
@@ -42,24 +57,31 @@ export function LiveTimingPage() {
|
|||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const trackOutlineQuery = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
'live-track-outline',
|
|
||||||
snapshot?.Session?.MeetingName ?? '',
|
|
||||||
snapshot?.Session?.CircuitName ?? '',
|
|
||||||
],
|
|
||||||
queryFn: () => fetchLiveTrackOutline(snapshot!.Session),
|
|
||||||
enabled: Boolean(snapshot?.Session?.MeetingName || snapshot?.Session?.CircuitName),
|
|
||||||
staleTime: Infinity,
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
setIsLive(data.is_live)
|
const nextLive = data.is_live && Boolean(data.data)
|
||||||
setSnapshot(data.data)
|
setIsLive(nextLive)
|
||||||
|
isLiveRef.current = nextLive
|
||||||
|
if (nextLive && data.data) {
|
||||||
|
setActiveSnapshot(data.data)
|
||||||
|
setArchiveMode(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveSnapshot(null)
|
||||||
|
setArchiveSnapshot(data.last_snapshot ?? null)
|
||||||
|
setArchivePositions(data.last_positions ?? {})
|
||||||
|
setArchiveSnapshotAt(data.last_snapshot_at ?? null)
|
||||||
|
setArchiveMode((current) => current && Boolean(data.last_snapshot))
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
archiveModeRef.current = archiveMode
|
||||||
|
if (archiveMode) {
|
||||||
|
setPositions(archivePositions)
|
||||||
|
}
|
||||||
|
}, [archiveMode, archivePositions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
@@ -82,8 +104,23 @@ export function LiveTimingPage() {
|
|||||||
events.addEventListener('snapshot', (event) => {
|
events.addEventListener('snapshot', (event) => {
|
||||||
const state = parseLiveStateEvent(event.data)
|
const state = parseLiveStateEvent(event.data)
|
||||||
if (!state || cancelled) return
|
if (!state || cancelled) return
|
||||||
setIsLive(state.is_live)
|
const nextLive = state.is_live && Boolean(state.data)
|
||||||
setSnapshot(state.data)
|
setIsLive(nextLive)
|
||||||
|
if (nextLive && state.data) {
|
||||||
|
if (!isLiveRef.current || archiveModeRef.current) {
|
||||||
|
setPositions({})
|
||||||
|
}
|
||||||
|
isLiveRef.current = true
|
||||||
|
setActiveSnapshot(state.data)
|
||||||
|
setArchiveMode(false)
|
||||||
|
} else {
|
||||||
|
isLiveRef.current = false
|
||||||
|
setActiveSnapshot(null)
|
||||||
|
setArchiveSnapshot(state.last_snapshot ?? null)
|
||||||
|
setArchivePositions(state.last_positions ?? {})
|
||||||
|
setArchiveSnapshotAt(state.last_snapshot_at ?? null)
|
||||||
|
setArchiveMode((current) => current && Boolean(state.last_snapshot))
|
||||||
|
}
|
||||||
setStreamStatus('connected')
|
setStreamStatus('connected')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,6 +149,23 @@ export function LiveTimingPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Synthesize "what just happened" events from successive snapshots; the
|
||||||
|
// buffer resets whenever a different session starts streaming.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshot) return
|
||||||
|
const signature = sessionSignature(snapshot.Session)
|
||||||
|
const prev = prevSnapshotRef.current
|
||||||
|
prevSnapshotRef.current = snapshot
|
||||||
|
if (signature !== sessionSigRef.current) {
|
||||||
|
sessionSigRef.current = signature
|
||||||
|
setEvents([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prev || prev === snapshot) return
|
||||||
|
const fresh = diffSnapshots(prev, snapshot)
|
||||||
|
if (fresh.length > 0) setEvents((current) => appendEvents(current, fresh))
|
||||||
|
}, [snapshot])
|
||||||
|
|
||||||
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
const rawRows = useMemo(() => sortLiveTimingRows(snapshot), [snapshot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,6 +203,19 @@ export function LiveTimingPage() {
|
|||||||
setPinned((prev) => togglePin(prev, racingNumber))
|
setPinned((prev) => togglePin(prev, racingNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewArchive = () => {
|
||||||
|
if (!archiveSnapshot) return
|
||||||
|
setIsLive(false)
|
||||||
|
setArchiveMode(true)
|
||||||
|
setPositions(archivePositions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveTimestamp = archiveSnapshotAt ? new Date(archiveSnapshotAt) : null
|
||||||
|
const archiveLabel =
|
||||||
|
archiveTimestamp && !Number.isNaN(archiveTimestamp.getTime())
|
||||||
|
? `Archived snapshot from ${archiveTimestamp.toLocaleString()}`
|
||||||
|
: 'Archived live timing snapshot'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page live-page" data-testid="live-page">
|
<div className="page live-page" data-testid="live-page">
|
||||||
{isError && (
|
{isError && (
|
||||||
@@ -157,12 +224,18 @@ export function LiveTimingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streamStatus === 'disconnected' && snapshot && (
|
{streamStatus === 'disconnected' && snapshot && !archiveMode && (
|
||||||
<div className="live-status-strip live-status-warn">
|
<div className="live-status-strip live-status-warn">
|
||||||
Stream disconnected — showing last received snapshot
|
Stream disconnected — showing last received snapshot
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{archiveMode && snapshot && (
|
||||||
|
<div className="live-status-strip live-status-archive" data-testid="live-archive-strip">
|
||||||
|
{archiveLabel} — live updates are paused for this archive view
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && !snapshot && (
|
{isLoading && !snapshot && (
|
||||||
<div className="loading-state">connecting to live timing…</div>
|
<div className="loading-state">connecting to live timing…</div>
|
||||||
)}
|
)}
|
||||||
@@ -177,22 +250,28 @@ export function LiveTimingPage() {
|
|||||||
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
<p className="empty-state-desc" style={{ color: 'var(--text-2)' }}>
|
||||||
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
The telemetry feed is currently offline. <br /><br /> Check the <a href="/" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Command Center</a> for the weekend schedule or explore historical data in the <a href="/race-hub" style={{ color: 'var(--red)', textDecoration: 'underline' }}>Race Hub</a>.
|
||||||
</p>
|
</p>
|
||||||
|
{hasArchive && (
|
||||||
|
<button type="button" className="live-archive-btn" onClick={handleViewArchive}>
|
||||||
|
<Archive size={15} />
|
||||||
|
View Last Session
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snapshot && (
|
{snapshot && (
|
||||||
<>
|
<>
|
||||||
<SessionBanner isLive={isLive} snapshot={snapshot} rows={rows} connection={streamStatus} now={now} />
|
<SessionBanner
|
||||||
|
isLive={isLive}
|
||||||
|
isArchive={archiveMode}
|
||||||
|
snapshot={snapshot}
|
||||||
|
rows={rows}
|
||||||
|
connection={streamStatus}
|
||||||
|
now={now}
|
||||||
|
/>
|
||||||
<TrackStatusBanner status={snapshot.TrackStatus} />
|
<TrackStatusBanner status={snapshot.TrackStatus} />
|
||||||
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
<PinnedDrivers rows={rows} history={gapHistory} pinned={pinned} onToggle={handleTogglePin} />
|
||||||
<TrackMap
|
<TyreDegPanel rows={rows} sessionType={snapshot.Session?.SessionType} pinned={pinned} />
|
||||||
outline={trackOutlineQuery.data}
|
|
||||||
positions={positions}
|
|
||||||
telemetry={snapshot.Telemetry}
|
|
||||||
drivers={snapshot.Drivers}
|
|
||||||
driverInfo={snapshot.DriverInfo}
|
|
||||||
loading={trackOutlineQuery.isLoading}
|
|
||||||
/>
|
|
||||||
<div className="live-columns">
|
<div className="live-columns">
|
||||||
<div className="live-tower-col">
|
<div className="live-tower-col">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
@@ -211,7 +290,13 @@ export function LiveTimingPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="live-rc-col">
|
<div className="live-rc-col">
|
||||||
|
<TeamRadioTicker
|
||||||
|
captures={snapshot.TeamRadio ?? []}
|
||||||
|
driverInfo={snapshot.DriverInfo}
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar'
|
|||||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||||
import { StrategyView } from '../components/StrategyView'
|
import { StrategyView } from '../components/StrategyView'
|
||||||
import { LapsView } from '../components/LapsView'
|
import { LapsView } from '../components/LapsView'
|
||||||
|
import { CompareView } from '../components/CompareView'
|
||||||
import { RaceControlView } from '../components/RaceControlView'
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
import { WeatherView } from '../components/WeatherView'
|
import { WeatherView } from '../components/WeatherView'
|
||||||
import { OverviewView } from '../components/OverviewView'
|
import { OverviewView } from '../components/OverviewView'
|
||||||
@@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'compare' && (
|
||||||
|
<div className="data-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Driver Compare</span>
|
||||||
|
</div>
|
||||||
|
<CompareView
|
||||||
|
sessionKey={sessionKey}
|
||||||
|
results={data.results}
|
||||||
|
drivers={data.drivers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'lap_data' && (
|
{activeTab === 'lap_data' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
|
|||||||
442
frontend/src/pages/RacePreviewPage.tsx
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
fetchChampionshipHub,
|
||||||
|
fetchMeetings,
|
||||||
|
fetchResults,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchSessions,
|
||||||
|
fetchStartingGrid,
|
||||||
|
fetchTrackOutline,
|
||||||
|
} from '../api'
|
||||||
|
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
import {
|
||||||
|
buildTitleFightContext,
|
||||||
|
countdownTargetSession,
|
||||||
|
extractPodium,
|
||||||
|
extractPole,
|
||||||
|
findPriorYearMeetingByCircuit,
|
||||||
|
findRaceSession,
|
||||||
|
formatPointsGap,
|
||||||
|
isSprintWeekend,
|
||||||
|
pickPreviewMeeting,
|
||||||
|
RACE_WIN_POINTS,
|
||||||
|
SPRINT_WEEKEND_MAX_POINTS,
|
||||||
|
} from '../lib/preview'
|
||||||
|
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
|
||||||
|
import { buildOutlinePath } from '../lib/trackmap'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import type { Meeting, Session, TrackOutline } from '../types'
|
||||||
|
import '../styles/preview.css'
|
||||||
|
|
||||||
|
function SectionState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
empty?: boolean
|
||||||
|
emptyMessage?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="preview-section-state">Loading…</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="preview-section-state error">
|
||||||
|
{error instanceof Error ? error.message : 'Failed to load'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (empty) {
|
||||||
|
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackOutlineCard({
|
||||||
|
outline,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
outline: TrackOutline | null | undefined
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="preview-card" data-testid="preview-track-card">
|
||||||
|
<h2 className="preview-card-title">Circuit</h2>
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={!outline || !outlinePath}
|
||||||
|
emptyMessage="Track outline unavailable for this circuit"
|
||||||
|
>
|
||||||
|
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
||||||
|
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
|
||||||
|
<path className="preview-track-shadow" d={outlinePath} />
|
||||||
|
<path className="preview-track-outline" d={outlinePath} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LastYearCard({
|
||||||
|
year,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
podium,
|
||||||
|
pole,
|
||||||
|
isFirstTime,
|
||||||
|
}: {
|
||||||
|
year: number | null
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
podium: ReturnType<typeof extractPodium>
|
||||||
|
pole: ReturnType<typeof extractPole>
|
||||||
|
isFirstTime: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="preview-card" data-testid="preview-last-year-card">
|
||||||
|
<h2 className="preview-card-title">Last year here</h2>
|
||||||
|
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={isFirstTime || podium.length === 0}
|
||||||
|
emptyMessage={
|
||||||
|
isFirstTime
|
||||||
|
? 'First time on the calendar — no prior race at this circuit'
|
||||||
|
: 'No race results available for the previous visit'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="preview-podium">
|
||||||
|
{podium.map((place) => (
|
||||||
|
<div key={place.position} className="preview-podium-row">
|
||||||
|
<span className="preview-podium-pos">P{place.position}</span>
|
||||||
|
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
|
||||||
|
{place.name_acronym}
|
||||||
|
</span>
|
||||||
|
<span className="preview-podium-team">{place.team_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pole && (
|
||||||
|
<p className="preview-pole">
|
||||||
|
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
|
||||||
|
({pole.team_name})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleFightCard({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
drivers,
|
||||||
|
sprintWeekend,
|
||||||
|
season,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
drivers: ReturnType<typeof buildTitleFightContext>
|
||||||
|
sprintWeekend: boolean
|
||||||
|
season: number | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
|
||||||
|
<h2 className="preview-card-title">Title fight context</h2>
|
||||||
|
{season != null && <p className="preview-card-sub">{season} drivers' championship · top 3</p>}
|
||||||
|
<SectionState
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
empty={drivers.length === 0}
|
||||||
|
emptyMessage="Championship standings not available"
|
||||||
|
>
|
||||||
|
<table className="preview-title-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th>Pts</th>
|
||||||
|
<th>Gap</th>
|
||||||
|
<th>If win (+{RACE_WIN_POINTS})</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{drivers.map((d) => (
|
||||||
|
<tr key={d.name_acronym}>
|
||||||
|
<td>
|
||||||
|
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
|
||||||
|
</td>
|
||||||
|
<td className="mono">{d.points}</td>
|
||||||
|
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
|
||||||
|
<td className="mono">
|
||||||
|
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{sprintWeekend && (
|
||||||
|
<p className="preview-sprint-note" data-testid="preview-sprint-note">
|
||||||
|
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
|
||||||
|
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewHeader({
|
||||||
|
meeting,
|
||||||
|
sessions,
|
||||||
|
countdownSession,
|
||||||
|
now,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
meeting: Meeting
|
||||||
|
sessions: Session[]
|
||||||
|
countdownSession: Session | null
|
||||||
|
now: Date
|
||||||
|
accent: string
|
||||||
|
}) {
|
||||||
|
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className="preview-header"
|
||||||
|
data-testid="preview-header"
|
||||||
|
style={{ ['--preview-accent' as string]: accent }}
|
||||||
|
>
|
||||||
|
<div className="preview-header-accent" />
|
||||||
|
<div className="preview-header-body">
|
||||||
|
<div className="preview-header-top">
|
||||||
|
<div>
|
||||||
|
<h1 className="preview-gp-name">
|
||||||
|
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
|
||||||
|
{meeting.meeting_name}
|
||||||
|
</h1>
|
||||||
|
<p className="preview-circuit">
|
||||||
|
{meeting.circuit_short_name}
|
||||||
|
{meeting.location ? ` · ${meeting.location}` : ''}
|
||||||
|
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{countdownTarget && (
|
||||||
|
<div className="preview-countdown" data-testid="preview-countdown">
|
||||||
|
<span className="preview-countdown-label">
|
||||||
|
{countdownSession?.session_name ?? 'Next session'}
|
||||||
|
</span>
|
||||||
|
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="preview-schedule" data-testid="preview-schedule">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.session_key} className="preview-schedule-item">
|
||||||
|
<span className="preview-schedule-name">{session.session_name}</span>
|
||||||
|
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RacePreviewPage() {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const nowDate = useMemo(() => new Date(now), [now])
|
||||||
|
|
||||||
|
const seasonsQuery = useQuery({
|
||||||
|
queryKey: ['seasons'],
|
||||||
|
queryFn: fetchSeasons,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||||
|
|
||||||
|
const meetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', latestSeason, 'auto'],
|
||||||
|
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewMeeting = useMemo(
|
||||||
|
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
||||||
|
[meetingsQuery.data, nowDate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionsQuery = useQuery({
|
||||||
|
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
||||||
|
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
||||||
|
enabled: previewMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = useMemo(
|
||||||
|
() => sortSessionsByStart(sessionsQuery.data ?? []),
|
||||||
|
[sessionsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
|
||||||
|
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
|
||||||
|
|
||||||
|
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
|
||||||
|
|
||||||
|
const priorMeetingsQuery = useQuery({
|
||||||
|
queryKey: ['meetings', priorYear, 'auto'],
|
||||||
|
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
||||||
|
enabled: priorYear != null && previewMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorMeeting = useMemo(
|
||||||
|
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
|
||||||
|
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
|
||||||
|
)
|
||||||
|
|
||||||
|
const priorSessionsQuery = useQuery({
|
||||||
|
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
||||||
|
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
||||||
|
enabled: priorMeeting != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorRaceSession = useMemo(
|
||||||
|
() => findRaceSession(priorSessionsQuery.data ?? []),
|
||||||
|
[priorSessionsQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const priorResultsQuery = useQuery({
|
||||||
|
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
||||||
|
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
||||||
|
enabled: priorRaceSession != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const priorGridQuery = useQuery({
|
||||||
|
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
||||||
|
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
||||||
|
enabled: priorRaceSession != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const trackOutlineQuery = useQuery({
|
||||||
|
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
||||||
|
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
||||||
|
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const championshipQuery = useQuery({
|
||||||
|
queryKey: ['championship-hub', latestSeason],
|
||||||
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||||
|
enabled: latestSeason != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
|
||||||
|
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
|
||||||
|
const titleFight = useMemo(
|
||||||
|
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
|
||||||
|
[championshipQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const accent = countryAccent(previewMeeting)
|
||||||
|
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
||||||
|
|
||||||
|
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
||||||
|
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isError || meetingsQuery.isError) {
|
||||||
|
const err = seasonsQuery.error ?? meetingsQuery.error
|
||||||
|
return (
|
||||||
|
<div className="page error-box" data-testid="preview-error">
|
||||||
|
{err instanceof Error ? err.message : 'Failed to load preview'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewMeeting) {
|
||||||
|
return (
|
||||||
|
<div className="preview-page" data-testid="preview-season-over">
|
||||||
|
<div className="preview-empty">
|
||||||
|
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
|
||||||
|
<h1 className="preview-empty-title">Season complete</h1>
|
||||||
|
<p className="preview-empty-sub">
|
||||||
|
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
|
||||||
|
published.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{titleFight.length > 0 && (
|
||||||
|
<TitleFightCard
|
||||||
|
loading={championshipQuery.isLoading}
|
||||||
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||||
|
drivers={titleFight}
|
||||||
|
sprintWeekend={false}
|
||||||
|
season={latestSeason}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="preview-page" data-testid="preview-page">
|
||||||
|
<PreviewHeader
|
||||||
|
meeting={previewMeeting}
|
||||||
|
sessions={sessions}
|
||||||
|
countdownSession={countdownSession}
|
||||||
|
now={nowDate}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="preview-grid">
|
||||||
|
<TrackOutlineCard
|
||||||
|
outline={trackOutlineQuery.data}
|
||||||
|
loading={trackOutlineQuery.isLoading}
|
||||||
|
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LastYearCard
|
||||||
|
year={priorMeeting?.year ?? priorYear}
|
||||||
|
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
||||||
|
error={
|
||||||
|
priorMeetingsQuery.isError
|
||||||
|
? (priorMeetingsQuery.error as Error)
|
||||||
|
: priorResultsQuery.isError
|
||||||
|
? (priorResultsQuery.error as Error)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
podium={podium}
|
||||||
|
pole={pole}
|
||||||
|
isFirstTime={isFirstTimeCircuit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TitleFightCard
|
||||||
|
loading={championshipQuery.isLoading}
|
||||||
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
||||||
|
drivers={titleFight}
|
||||||
|
sprintWeekend={sprintWeekend}
|
||||||
|
season={latestSeason}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
frontend/src/pages/WeekendPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useWeekendContext } from '../hooks/useWeekendContext'
|
||||||
|
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
|
||||||
|
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
|
||||||
|
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||||
|
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||||
|
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||||
|
import { resolveViewState } from '../lib/weekendContext'
|
||||||
|
import type {
|
||||||
|
WeekendBriefingItem,
|
||||||
|
WeekendChampionshipImpact,
|
||||||
|
WeekendContext,
|
||||||
|
WeekendViewState,
|
||||||
|
} from '../types'
|
||||||
|
import '../styles/weekend.css'
|
||||||
|
|
||||||
|
interface RenderArgs {
|
||||||
|
context: WeekendContext
|
||||||
|
now: Date
|
||||||
|
championship?: WeekendChampionshipImpact
|
||||||
|
briefing: WeekendBriefingItem[]
|
||||||
|
/** When true (the /preview alias), foreground the preparation surface. */
|
||||||
|
preview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderState(view: WeekendViewState, args: RenderArgs) {
|
||||||
|
const { context, now, championship, briefing, preview } = args
|
||||||
|
// The /preview alias always resolves to the preparation surface as long as
|
||||||
|
// there is a next event to prepare for, regardless of the temporal state. This
|
||||||
|
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
|
||||||
|
// redirecting straight back to the same between-races screen.
|
||||||
|
if (preview && context.next_meeting) {
|
||||||
|
return <PreSessionView context={context} now={now} />
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (view) {
|
||||||
|
case 'no_season':
|
||||||
|
return <WeekendLimited season={context.season} />
|
||||||
|
case 'between_weekends':
|
||||||
|
case 'post_weekend':
|
||||||
|
case 'season_complete':
|
||||||
|
return (
|
||||||
|
<BetweenRacesView
|
||||||
|
context={context}
|
||||||
|
now={now}
|
||||||
|
view={view}
|
||||||
|
championship={championship}
|
||||||
|
briefing={briefing}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'between_sessions':
|
||||||
|
case 'session_settling':
|
||||||
|
return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
|
||||||
|
case 'session_live':
|
||||||
|
return <LiveHandoffView context={context} />
|
||||||
|
case 'pre_session':
|
||||||
|
return <PreSessionView context={context} now={now} />
|
||||||
|
default:
|
||||||
|
return <WeekendLimited season={context.season} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeekendPage({ preview = false }: { preview?: boolean }) {
|
||||||
|
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
||||||
|
|
||||||
|
if (loadState === 'loading') {
|
||||||
|
return (
|
||||||
|
<main className="wk-page" data-testid="weekend-page" data-state="loading">
|
||||||
|
<WeekendLoading />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === 'error' || context == null) {
|
||||||
|
return (
|
||||||
|
<main className="wk-page" data-testid="weekend-page" data-state="error">
|
||||||
|
<WeekendError message={error?.message} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = resolveViewState(context)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="wk-page"
|
||||||
|
data-testid="weekend-page"
|
||||||
|
data-state={view}
|
||||||
|
data-temporal-state={context.temporal_state}
|
||||||
|
data-preview={preview ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{renderState(view, { context, now, championship, briefing, preview })}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||||
import { Nav } from './components/Nav'
|
import { Nav } from './components/Nav'
|
||||||
import { CommandCenterPage } from './pages/CommandCenterPage'
|
import { WeekendPage } from './pages/WeekendPage'
|
||||||
|
import { ExplorePage } from './pages/ExplorePage'
|
||||||
import { RaceHubPage } from './pages/RaceHubPage'
|
import { RaceHubPage } from './pages/RaceHubPage'
|
||||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
|
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||||
|
|
||||||
type RaceHubSearch = {
|
type RaceHubSearch = {
|
||||||
session_key?: number
|
session_key?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DriverProfileSearch = {
|
||||||
|
year?: number
|
||||||
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
<>
|
<>
|
||||||
@@ -20,10 +26,17 @@ const rootRoute = createRootRoute({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const commandCenterRoute = createRoute({
|
// Weekend is the adaptive home. `/` renders the current temporal state.
|
||||||
|
export const weekendRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: CommandCenterPage,
|
component: WeekendPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const exploreRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/explore',
|
||||||
|
component: ExplorePage,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const raceHubRoute = createRoute({
|
export const raceHubRoute = createRoute({
|
||||||
@@ -64,20 +77,49 @@ export const championshipRoute = createRoute({
|
|||||||
component: ChampionshipPage,
|
component: ChampionshipPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const driverProfileRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/drivers/$driverNumber',
|
||||||
|
validateSearch: (search: Record<string, unknown>): DriverProfileSearch => {
|
||||||
|
const year = Number(search.year)
|
||||||
|
return Number.isFinite(year) && year > 0 ? { year } : {}
|
||||||
|
},
|
||||||
|
component: function DriverProfileRoute() {
|
||||||
|
const { driverNumber } = driverProfileRoute.useParams()
|
||||||
|
const { year } = driverProfileRoute.useSearch()
|
||||||
|
return <DriverProfilePage driverNumber={Number(driverNumber) || 0} year={year} />
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const briefingRoute = createRoute({
|
export const briefingRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/briefing',
|
path: '/briefing',
|
||||||
component: BriefingPage,
|
component: BriefingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Preview folds into the Weekend home. /preview is a stable alias that renders the
|
||||||
|
// Weekend page in its preparation surface (PreSessionView) whenever there is a next
|
||||||
|
// event — so saved preview links and the "Prepare for …" CTA reach real preview
|
||||||
|
// content instead of redirecting back to the same between-races screen.
|
||||||
|
export const previewRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/preview',
|
||||||
|
component: function PreviewRoute() {
|
||||||
|
return <WeekendPage preview />
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
commandCenterRoute,
|
weekendRoute,
|
||||||
|
exploreRoute,
|
||||||
raceHubRoute,
|
raceHubRoute,
|
||||||
adminRoute,
|
adminRoute,
|
||||||
dataLibraryRoute,
|
dataLibraryRoute,
|
||||||
liveTimingRoute,
|
liveTimingRoute,
|
||||||
championshipRoute,
|
championshipRoute,
|
||||||
|
driverProfileRoute,
|
||||||
briefingRoute,
|
briefingRoute,
|
||||||
|
previewRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
@@ -142,6 +142,53 @@ a { color: inherit; text-decoration: none; }
|
|||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile bottom nav ── */
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 60px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
background: rgba(18, 18, 18, 0.92);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.bottom-nav-link {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.bottom-nav-link.active { color: var(--red); }
|
||||||
|
.bottom-nav-link:hover { color: var(--text-2); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* On mobile the bottom bar is the single primary navigation system. The top
|
||||||
|
bar's Primary links are hidden so the two are never both shown; the top bar
|
||||||
|
keeps only the logo and the Admin operator utility. */
|
||||||
|
.app-bottom-nav { display: flex; }
|
||||||
|
.app-nav .nav-links { display: none; }
|
||||||
|
.app-nav { justify-content: space-between; }
|
||||||
|
/* Keep page content clear of the fixed bottom bar. */
|
||||||
|
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Focus visibility (accessibility) ── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Page ── */
|
/* ── Page ── */
|
||||||
.page {
|
.page {
|
||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
@@ -808,6 +855,12 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-status-archive {
|
||||||
|
background: rgba(70, 140, 255, 0.08);
|
||||||
|
border: 1px solid rgba(70, 140, 255, 0.24);
|
||||||
|
color: #8bb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Live empty state ── */
|
/* ── Live empty state ── */
|
||||||
.live-empty-status {
|
.live-empty-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -815,6 +868,29 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin-bottom: var(--s4);
|
margin-bottom: var(--s4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-archive-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-top: var(--s5);
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-archive-btn:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.mono { font-family: var(--f-mono); }
|
.mono { font-family: var(--f-mono); }
|
||||||
|
|
||||||
.live-conn,
|
.live-conn,
|
||||||
|
|||||||
208
frontend/src/styles/chapters.css
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
.chapter-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-tour-btn {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--s1);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-tour-btn:hover,
|
||||||
|
.chapter-tour-btn.active {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll-wrap::before,
|
||||||
|
.chapter-strip-scroll-wrap::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: var(--s2);
|
||||||
|
width: 28px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll-wrap::before {
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(to right, var(--bg), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll-wrap::after {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, var(--bg), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s3);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--s2);
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card {
|
||||||
|
flex: 0 0 min(240px, 72vw);
|
||||||
|
min-height: 88px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--s2);
|
||||||
|
background: var(--surface);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card:hover {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card.active {
|
||||||
|
border-color: var(--accent, var(--red));
|
||||||
|
background: var(--surface-h);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card.tour-active {
|
||||||
|
border-color: var(--yellow);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-kind {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 var(--s2);
|
||||||
|
border-radius: var(--s1);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card.active .chapter-kind {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-kind--safety_car,
|
||||||
|
.chapter-kind--virtual_safety_car {
|
||||||
|
color: var(--orange, #f90);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-kind--red_flag {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-kind--decisive_swing {
|
||||||
|
color: var(--green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-kind--finish {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-lap-range {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-headline {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-empty-card {
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-strip-empty-card .empty-state-icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto var(--s3);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.chapter-strip-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
frontend/src/styles/compare-view.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
.compare-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pickers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s4);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker-label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker-select {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker-select:focus {
|
||||||
|
outline: 2px solid var(--gp-accent, var(--red));
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-section-title {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-section-meta {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pit-caption {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stale-notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--amber, #f59e0b);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
}
|
||||||
76
frontend/src/styles/delta-graph.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
.delta-graph {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-empty {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-zero-line {
|
||||||
|
stroke: var(--text-3);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-grid-line {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-axis-label {
|
||||||
|
fill: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-driver-line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-crosshair {
|
||||||
|
stroke: var(--text-2);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-hover-layer {
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip-bg {
|
||||||
|
fill: var(--bg-elevated, var(--bg));
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip-title {
|
||||||
|
fill: var(--text-1);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-graph-tooltip-row {
|
||||||
|
fill: var(--text-2);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
178
frontend/src/styles/digest.css
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/* ── Calendar-aware paddock digest ── */
|
||||||
|
|
||||||
|
.digest-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-sticky-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s, color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-chip:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-chip.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--tag-colour, var(--red));
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tag-colour, var(--red)) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-tag-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tag-colour, var(--text-3));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-filter-clear {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-filter-clear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section {
|
||||||
|
margin-bottom: var(--s6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-section-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-recent-section .digest-section-title {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-top: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tag:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bp-card-tag.active {
|
||||||
|
border-color: var(--tag-colour, var(--red));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-brief-digest-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-brief-digest-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-brief-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
}
|
||||||
289
frontend/src/styles/driver-profile.css
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/* Driver profile page (/drivers/$driverNumber) */
|
||||||
|
|
||||||
|
.dp-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- header --- */
|
||||||
|
|
||||||
|
.dp-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s5);
|
||||||
|
padding: var(--s5);
|
||||||
|
padding-left: var(--s6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 4px solid var(--red);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-code {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-num {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-name {
|
||||||
|
margin: var(--s2) 0;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-team {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-team-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-season {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-stat-rail {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-stat {
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-back {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--s4);
|
||||||
|
right: var(--s5);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-back:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- sections --- */
|
||||||
|
|
||||||
|
.dp-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-empty {
|
||||||
|
padding: var(--s5);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- charts --- */
|
||||||
|
|
||||||
|
.dp-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(200px, 1fr);
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dp-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-chart {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-chart-caption {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-chart-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-chart-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-gridline {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-zeroline {
|
||||||
|
stroke: var(--text-3);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-axis {
|
||||||
|
fill: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-delta-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-delta-val {
|
||||||
|
fill: var(--text-2);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- form strip --- */
|
||||||
|
|
||||||
|
.dp-form-strip {
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-form-bars {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--s3);
|
||||||
|
min-height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-form-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-form-bar-fill {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 34px;
|
||||||
|
border-radius: 2px 2px 0 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-form-bar-val {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- rounds table --- */
|
||||||
|
|
||||||
|
.dp-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-table th {
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-table td {
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-table th.l,
|
||||||
|
.dp-table td.l {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-table th.r,
|
||||||
|
.dp-table td.r {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-td-dim {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dp-flag {
|
||||||
|
margin-right: var(--s3);
|
||||||
|
}
|
||||||
67
frontend/src/styles/event-rail.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/* ── "What just happened" event rail (live page) ── */
|
||||||
|
|
||||||
|
.event-rail {
|
||||||
|
margin-top: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding-top: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
border-left: 3px solid var(--border-2);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-2);
|
||||||
|
padding-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-kind-fastest-lap .event-rail-icon { color: var(--purple); }
|
||||||
|
.event-kind-personal-best .event-rail-icon { color: var(--green); }
|
||||||
|
.event-kind-overtake .event-rail-icon,
|
||||||
|
.event-kind-position-gain .event-rail-icon { color: var(--green); }
|
||||||
|
.event-kind-position-loss .event-rail-icon { color: var(--yellow); }
|
||||||
|
.event-kind-retirement .event-rail-icon,
|
||||||
|
.event-kind-track-status .event-rail-icon { color: var(--red); }
|
||||||
|
.event-kind-pit-in .event-rail-icon,
|
||||||
|
.event-kind-pit-out .event-rail-icon { color: var(--blue); }
|
||||||
|
|
||||||
|
.event-rail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s1);
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail-headline {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail-lap {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
130
frontend/src/styles/h2h.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* Teammate head-to-head bars (issue #23). */
|
||||||
|
|
||||||
|
.h2h-section {
|
||||||
|
margin: 20px 0 24px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--surface-1, rgba(255, 255, 255, 0.03));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-section-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 140px) 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-team {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-team-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-extra {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-bar-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-tla {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-tla--left {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-tla--right {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-bar {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-bar-seg {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-bar-seg--muted {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-score {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.h2h-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h2h-bar-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
frontend/src/styles/meaning.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* Compact value + muted meaning caption (issue #18). */
|
||||||
|
|
||||||
|
.meaning {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: inherit;
|
||||||
|
gap: 1px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-value {
|
||||||
|
/* inherits table cell mono styling from parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption {
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--good {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--bad {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--warn {
|
||||||
|
color: var(--yellow, #e8c547);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meaning-caption--neutral {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table cells: right-align caption under numeric values */
|
||||||
|
td.r .meaning,
|
||||||
|
.champ-td-dim .meaning,
|
||||||
|
.champ-td-muted .meaning {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-row .meaning {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
309
frontend/src/styles/preview.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
.preview-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s6);
|
||||||
|
padding: var(--s6);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-height: 320px;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--s8);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-eyebrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty-sub {
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 36rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-accent {
|
||||||
|
width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--preview-accent, var(--gp-accent));
|
||||||
|
box-shadow: 0 0 12px var(--preview-accent, var(--gp-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-top {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-gp-name {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-gp-flag {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circuit {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown-value {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-schedule-time {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card-sub {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin: calc(-1 * var(--s2)) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-svg {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-outline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--preview-accent, var(--gp-accent));
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-track-shadow {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(0, 0, 0, 0.5);
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-pos {
|
||||||
|
width: 1.5rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-code {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-podium-team {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pole {
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pole strong {
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table th,
|
||||||
|
.preview-title-table td {
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-table td.mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-sprint-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s3);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-state.error {
|
||||||
|
color: var(--red, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.preview-page {
|
||||||
|
padding: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header-top {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-countdown {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
frontend/src/styles/race-story.css
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.race-story-empty-card {
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-story-empty-card .empty-state-icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto var(--s3);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-shell--split {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.8fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-chart-container {
|
||||||
|
--rs-chart-max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-chart-container--full {
|
||||||
|
--rs-chart-max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segmented-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: var(--s3);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
border-radius: var(--s1);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 var(--s4);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment:focus-visible {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: -2px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(230, 36, 41, 0.12);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment-divider {
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
background: var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-replay-map-slot {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-position-chart-svg {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: var(--rs-chart-max-width, 640px);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-chart-axis-label {
|
||||||
|
fill: var(--text-3);
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-chart-grid-line {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-chart-lap-tick {
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.rs-replay-shell--split {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segmented-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.rs-segmented-control {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-segment-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
frontend/src/styles/replay-map.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
.replay-map-panel {
|
||||||
|
min-height: 244px;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 244px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 244px;
|
||||||
|
display: block;
|
||||||
|
background: radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.04), transparent 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-outline-shadow,
|
||||||
|
.replay-map-outline {
|
||||||
|
fill: none;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-outline-shadow {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-outline {
|
||||||
|
stroke: var(--text-2);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car {
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car circle {
|
||||||
|
stroke: var(--bg);
|
||||||
|
stroke-width: 0.8;
|
||||||
|
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car text {
|
||||||
|
fill: #fff;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 2.6px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-anchor: middle;
|
||||||
|
pointer-events: none;
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: rgba(0, 0, 0, 0.75);
|
||||||
|
stroke-width: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car:not(.replay-car-pinned) text {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car:hover text,
|
||||||
|
.replay-car:focus-visible text,
|
||||||
|
.replay-car-pinned text {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-car-pinned circle {
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-empty-card {
|
||||||
|
min-height: 244px;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
padding: var(--s6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-map-empty-card .empty-state-icon {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto var(--s3);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
134
frontend/src/styles/rivalry.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
.rivalry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-pickers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s4);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-vs {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker-label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker-select {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-picker-select:focus {
|
||||||
|
outline: 2px solid var(--red);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4) var(--s5);
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-score {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-code {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-num {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-h2h-meta {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-strip-label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-right: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-chip-round {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-chip-winner {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rivalry-caption {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
101
frontend/src/styles/stint-timeline.css
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
.stint-timeline {
|
||||||
|
--stint-left: 48px;
|
||||||
|
--stint-right: 12px;
|
||||||
|
--stint-row-h: 28px;
|
||||||
|
--stint-bar-h: 14px;
|
||||||
|
--stint-axis-h: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__svg {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 640px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__bar {
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__bar.tyre-soft { fill: var(--tyre-soft); }
|
||||||
|
.stint-timeline__bar.tyre-medium { fill: var(--tyre-medium); }
|
||||||
|
.stint-timeline__bar.tyre-hard { fill: var(--tyre-hard); stroke: #555; stroke-width: 0.5; }
|
||||||
|
.stint-timeline__bar.tyre-inter { fill: var(--tyre-inter); }
|
||||||
|
.stint-timeline__bar.tyre-wet { fill: var(--tyre-wet); }
|
||||||
|
.stint-timeline__bar.tyre-unknown { fill: var(--surface-2); stroke: var(--border-2); stroke-width: 0.5; }
|
||||||
|
|
||||||
|
.stint-timeline__bar--new {
|
||||||
|
stroke: var(--text);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 2 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__pit-marker {
|
||||||
|
stroke: var(--text);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__axis-tick {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
fill: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__axis-line {
|
||||||
|
stroke: var(--border-2);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__legend {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--s4);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__legend-swatch.tyre-hard {
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__meta {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stint-timeline__empty {
|
||||||
|
padding: var(--s5);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border-2);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
}
|
||||||
82
frontend/src/styles/team-radio.css
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
.team-radio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-play {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-1);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-play:hover:not(:disabled),
|
||||||
|
.team-radio-play:focus-visible:not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.36);
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-play:disabled {
|
||||||
|
color: var(--text-3);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-driver {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border-left: 3px solid var(--text-3);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-radio-time {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
101
frontend/src/styles/telemetry-trace.css
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
.telemetry-trace {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-panel-bg {
|
||||||
|
fill: var(--surface);
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1;
|
||||||
|
rx: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-panel-title {
|
||||||
|
fill: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-axis-max {
|
||||||
|
fill: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-crosshair {
|
||||||
|
stroke: var(--text-2);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-readout {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface-h);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-readout-index {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-readout-driver {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-readout-label {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-trace-readout-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
84
frontend/src/styles/tyredeg.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/* Live tyre-degradation & pit-window panel (see components/live/TyreDegPanel.tsx) */
|
||||||
|
|
||||||
|
.live-tyredeg-panel {
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-toggle {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-chevron {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s2) var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-row .drv-bar { height: 16px; }
|
||||||
|
|
||||||
|
.tyredeg-pos {
|
||||||
|
min-width: 28px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-spark { display: block; }
|
||||||
|
.tyredeg-spark-empty { color: var(--text-3); }
|
||||||
|
|
||||||
|
.tyredeg-trend { display: inline-flex; align-items: center; }
|
||||||
|
.tyredeg-trend-improving { color: var(--green); }
|
||||||
|
.tyredeg-trend-stable { color: var(--text-3); }
|
||||||
|
.tyredeg-trend-degrading { color: #ff6b6b; }
|
||||||
|
|
||||||
|
.tyredeg-slope {
|
||||||
|
min-width: 78px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-warmup {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-rejoin {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s3);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tyredeg-rejoin-between {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tyredeg-rejoin-between { display: none; }
|
||||||
|
.tyredeg-slope { min-width: 0; }
|
||||||
|
}
|
||||||
378
frontend/src/styles/weekend.css
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/* ── Weekend adaptive home ── */
|
||||||
|
.wk-page {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--s6) var(--s6) 64px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-eyebrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-eyebrow::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--red), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
margin-bottom: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.wk-card-link {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wk-card-link:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.wk-flag { font-size: 22px; line-height: 1; }
|
||||||
|
|
||||||
|
/* ── CTAs ── */
|
||||||
|
.wk-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.wk-cta-primary { background: var(--red); color: #fff; }
|
||||||
|
.wk-cta-primary:hover { background: #ff3b3f; }
|
||||||
|
.wk-cta-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
}
|
||||||
|
.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); }
|
||||||
|
.wk-cta-wide { width: 100%; }
|
||||||
|
|
||||||
|
/* ── Between-races top grid ── */
|
||||||
|
.wk-top-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-mid-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-event-card {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-event-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; }
|
||||||
|
.wk-event-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.wk-event-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wk-tag-done { color: var(--text-2); }
|
||||||
|
.wk-tag-next { color: var(--red); }
|
||||||
|
|
||||||
|
.wk-event-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Podium ── */
|
||||||
|
.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
.wk-podium-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.wk-podium-pos { font-weight: 700; color: var(--text-2); }
|
||||||
|
.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wk-podium-gap { color: var(--text-2); font-size: 12px; }
|
||||||
|
.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); }
|
||||||
|
|
||||||
|
.wk-story-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s4);
|
||||||
|
background: rgba(230, 36, 41, 0.05);
|
||||||
|
border: 1px solid rgba(230, 36, 41, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); }
|
||||||
|
.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; }
|
||||||
|
|
||||||
|
/* ── Next event ── */
|
||||||
|
.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); }
|
||||||
|
.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; }
|
||||||
|
.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); }
|
||||||
|
.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; }
|
||||||
|
.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); }
|
||||||
|
.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); }
|
||||||
|
.wk-next-session { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); }
|
||||||
|
.wk-next-value { font-size: 15px; font-weight: 600; }
|
||||||
|
.wk-season-complete-copy { font-size: 13px; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ── Championship impact ── */
|
||||||
|
.wk-champ, .wk-briefing, .wk-season-nav {
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
.wk-champ-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||||
|
}
|
||||||
|
.wk-champ-pos { color: var(--text-3); }
|
||||||
|
.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||||
|
.wk-champ-pts { font-weight: 700; }
|
||||||
|
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
|
||||||
|
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* ── Season progress strip ── */
|
||||||
|
.wk-countdown-tbc { font-size: 14px; color: var(--text-3); }
|
||||||
|
.wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
.wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
|
||||||
|
.wk-season-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.wk-season-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: var(--red);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.wk-season-progress-label { font-size: 11px; color: var(--text-3); }
|
||||||
|
|
||||||
|
/* ── Briefing ── */
|
||||||
|
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; }
|
||||||
|
.wk-briefing-item { border-top: 1px solid var(--border); }
|
||||||
|
.wk-briefing-item:first-child { border-top: none; }
|
||||||
|
.wk-briefing-link {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s4) 0;
|
||||||
|
}
|
||||||
|
.wk-briefing-link:hover .wk-briefing-title { color: var(--red); }
|
||||||
|
.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; }
|
||||||
|
.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Between-sessions / live ── */
|
||||||
|
.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); }
|
||||||
|
.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; }
|
||||||
|
.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.wk-timeline {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s3);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: var(--s3);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.wk-timeline-node {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-width: 72px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); }
|
||||||
|
.wk-timeline-done .wk-timeline-dot { background: var(--green); }
|
||||||
|
.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); }
|
||||||
|
.wk-timeline-next { border-color: var(--red); }
|
||||||
|
.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); }
|
||||||
|
.wk-timeline-name { font-size: 12px; font-weight: 600; }
|
||||||
|
.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
|
||||||
|
.wk-timeline-done .wk-timeline-state { color: var(--green); }
|
||||||
|
.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); }
|
||||||
|
|
||||||
|
.wk-recap-card, .wk-upnext-card, .wk-live-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); }
|
||||||
|
.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; }
|
||||||
|
.wk-upnext-when { font-size: 12px; color: var(--text-3); }
|
||||||
|
.wk-upnext-card { border-color: rgba(230,36,41,0.3); }
|
||||||
|
|
||||||
|
.wk-live-card { border-color: rgba(230,36,41,0.35); }
|
||||||
|
.wk-live-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); }
|
||||||
|
.wk-live-lead { font-size: 14px; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ── Pre-session ── */
|
||||||
|
.wk-pre-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; }
|
||||||
|
.wk-pre-preview { min-width: 0; }
|
||||||
|
/* The folded preview page ships its own max-width page; reset it inside Weekend. */
|
||||||
|
.wk-pre-preview .preview-page { max-width: 100%; padding: 0; }
|
||||||
|
|
||||||
|
/* ── Status views ── */
|
||||||
|
.wk-status {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 64px auto;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); }
|
||||||
|
.wk-status-title { font-size: 22px; font-weight: 700; }
|
||||||
|
.wk-status-sub { font-size: 13px; color: var(--text-2); }
|
||||||
|
.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); }
|
||||||
|
.wk-status-error .wk-status-title { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; }
|
||||||
|
.wk-event-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
||||||
|
.wk-event-name { font-size: 20px; }
|
||||||
|
.wk-sessions-title { font-size: 22px; }
|
||||||
|
.wk-count-unit b { font-size: 32px; }
|
||||||
|
.wk-pre-head { flex-direction: column; align-items: flex-start; }
|
||||||
|
.wk-pre-countdown { margin-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Explore ── */
|
||||||
|
.wk-explore { display: flex; flex-direction: column; gap: var(--s6); }
|
||||||
|
.wk-explore-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: var(--s5);
|
||||||
|
}
|
||||||
|
.wk-explore-grid {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.wk-explore-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s2);
|
||||||
|
padding: var(--s4);
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--surface-h);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; }
|
||||||
|
.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); }
|
||||||
|
.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); }
|
||||||
|
.wk-explore-flag { font-size: 18px; }
|
||||||
|
.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wk-explore-meta { font-size: 11px; color: var(--text-3); }
|
||||||
|
.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); }
|
||||||
|
.wk-explore-state.is-done { color: var(--green); }
|
||||||
|
.wk-explore-state.is-upcoming { color: var(--text-3); }
|
||||||
|
|
||||||
|
.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); }
|
||||||
|
.wk-driver-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface-h);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
.wk-driver-chip:hover { border-color: var(--text-3); }
|
||||||
|
.wk-driver-pos { color: var(--text-3); font-size: 11px; }
|
||||||
|
.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||||
|
.wk-driver-pts { color: var(--text-2); font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── Reduced motion ── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; }
|
||||||
|
}
|
||||||
211
frontend/src/test/BriefingPage.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { BriefingPage } from '../pages/BriefingPage'
|
||||||
|
import type { ChampHubDriver, ChampHubTeam, ChampionshipHub, Meeting, NewsItem } from '../types'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchNews: vi.fn(),
|
||||||
|
fetchNewsArticle: vi.fn(),
|
||||||
|
markNewsRead: vi.fn(),
|
||||||
|
fetchSeasons: vi.fn(),
|
||||||
|
fetchSeasonMeetings: vi.fn(),
|
||||||
|
fetchChampionshipHub: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchNews,
|
||||||
|
fetchNewsArticle,
|
||||||
|
fetchSeasons,
|
||||||
|
fetchSeasonMeetings,
|
||||||
|
fetchChampionshipHub,
|
||||||
|
markNewsRead,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
const mockFetchNews = vi.mocked(fetchNews)
|
||||||
|
const mockFetchNewsArticle = vi.mocked(fetchNewsArticle)
|
||||||
|
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||||
|
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||||
|
const mockFetchHub = vi.mocked(fetchChampionshipHub)
|
||||||
|
const mockMarkNewsRead = vi.mocked(markNewsRead)
|
||||||
|
|
||||||
|
const bahrain: Meeting = {
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting_name: 'Bahrain',
|
||||||
|
meeting_official_name: 'Bahrain GP',
|
||||||
|
location: 'Sakhir',
|
||||||
|
country_name: 'Bahrain',
|
||||||
|
country_code: 'BRN',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Sakhir',
|
||||||
|
date_start: '2025-03-14T00:00:00+00:00',
|
||||||
|
date_end: '2025-03-16T23:59:59+00:00',
|
||||||
|
year: 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
const monaco: Meeting = {
|
||||||
|
meeting_key: 2,
|
||||||
|
meeting_name: 'Monaco',
|
||||||
|
meeting_official_name: 'Monaco GP',
|
||||||
|
location: 'Monaco',
|
||||||
|
country_name: 'Monaco',
|
||||||
|
country_code: 'MON',
|
||||||
|
country_flag: '',
|
||||||
|
circuit_short_name: 'Monaco',
|
||||||
|
date_start: '2025-05-23T00:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T23:59:59+00:00',
|
||||||
|
year: 2025,
|
||||||
|
}
|
||||||
|
|
||||||
|
const drivers: ChampHubDriver[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 100,
|
||||||
|
position: 1,
|
||||||
|
wins: 3,
|
||||||
|
podiums: 5,
|
||||||
|
poles: 2,
|
||||||
|
form: [25, 18, 25],
|
||||||
|
cumulative: [25, 43, 68, 100],
|
||||||
|
teammate_wins: 4,
|
||||||
|
teammate_losses: 1,
|
||||||
|
round_positions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 80,
|
||||||
|
position: 2,
|
||||||
|
wins: 1,
|
||||||
|
podiums: 4,
|
||||||
|
poles: 1,
|
||||||
|
form: [18, 25, 18],
|
||||||
|
cumulative: [18, 36, 54, 80],
|
||||||
|
teammate_wins: 3,
|
||||||
|
teammate_losses: 2,
|
||||||
|
round_positions: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const teams: ChampHubTeam[] = [
|
||||||
|
{ team_name: 'Red Bull', team_colour: '3671c6', points: 150, position: 1, wins: 4 },
|
||||||
|
{ team_name: 'McLaren', team_colour: 'ff8000', points: 120, position: 2, wins: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const hub: ChampionshipHub = {
|
||||||
|
season: 2025,
|
||||||
|
round: 2,
|
||||||
|
total_rounds: 2,
|
||||||
|
rounds_left: 0,
|
||||||
|
last_race: 'Monaco GP',
|
||||||
|
round_labels: ['R1', 'R2'],
|
||||||
|
drivers,
|
||||||
|
teams,
|
||||||
|
}
|
||||||
|
|
||||||
|
const newsItems: NewsItem[] = [
|
||||||
|
{
|
||||||
|
source: 'autosport-f1',
|
||||||
|
title: 'Verstappen sets the pace in Bahrain',
|
||||||
|
url: 'https://example.com/bahrain-ver',
|
||||||
|
published_at: '2025-03-15T10:00:00Z',
|
||||||
|
fetched_at: '2025-03-15T11:00:00Z',
|
||||||
|
summary: 'Red Bull dominate opening practice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'racefans-f1',
|
||||||
|
title: 'Norris targets Monaco upgrade',
|
||||||
|
url: 'https://example.com/monaco-nor',
|
||||||
|
published_at: '2025-04-20T10:00:00Z',
|
||||||
|
fetched_at: '2025-04-20T11:00:00Z',
|
||||||
|
summary: 'McLaren bring new floor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'bbc-f1',
|
||||||
|
title: 'Undated paddock rumour',
|
||||||
|
url: 'https://example.com/undated',
|
||||||
|
fetched_at: '2025-04-21T11:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BriefingPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BriefingPage digest layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.setSystemTime(new Date('2025-04-10T12:00:00Z'))
|
||||||
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
|
mockFetchSeasonMeetings.mockResolvedValue([bahrain, monaco])
|
||||||
|
mockFetchHub.mockResolvedValue(hub)
|
||||||
|
mockFetchNews.mockResolvedValue(newsItems)
|
||||||
|
mockFetchNewsArticle.mockResolvedValue({ title: 'Article', content: '<p>Body</p>' })
|
||||||
|
mockMarkNewsRead.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders sticky since-last header, GP sections, and recent bucket', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('digest-sticky-header')).toHaveTextContent('Since Bahrain')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('digest-window-2')).toHaveTextContent('Monaco')
|
||||||
|
expect(screen.getByTestId('digest-window-1')).toHaveTextContent('Bahrain')
|
||||||
|
expect(screen.getByTestId('digest-recent')).toHaveTextContent('Undated paddock rumour')
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters the digest when a tag chip is clicked', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('digest-sticky-header')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sticky = screen.getByTestId('digest-sticky-header')
|
||||||
|
const norTag = within(sticky).getByRole('button', { name: /^NOR$/i })
|
||||||
|
fireEvent.click(norTag)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Verstappen sets the pace in Bahrain')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Norris targets Monaco upgrade')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('digest-filter-clear')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('digest-filter-clear'))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Verstappen sets the pace in Bahrain')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves category tabs', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('tab', { name: /All/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('tab', { name: /News/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,6 +29,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 4,
|
poles: 4,
|
||||||
form: [25, 18, 25, 15, 25],
|
form: [25, 18, 25, 15, 25],
|
||||||
cumulative: [25, 43, 68, 83, 108, 200],
|
cumulative: [25, 43, 68, 83, 108, 200],
|
||||||
|
round_positions: [1, 2, 1, 3, 1, 1],
|
||||||
teammate_wins: 9,
|
teammate_wins: 9,
|
||||||
teammate_losses: 1,
|
teammate_losses: 1,
|
||||||
...over,
|
...over,
|
||||||
@@ -47,6 +48,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
position: 2,
|
position: 2,
|
||||||
wins: 3,
|
wins: 3,
|
||||||
cumulative: [18, 36, 54, 80, 120, 160],
|
cumulative: [18, 36, 54, 80, 120, 160],
|
||||||
|
round_positions: [2, 1, 2, 2, 2, 2],
|
||||||
}),
|
}),
|
||||||
driver({
|
driver({
|
||||||
driver_number: 16,
|
driver_number: 16,
|
||||||
@@ -58,6 +60,7 @@ const drivers: ChampHubDriver[] = [
|
|||||||
position: 3,
|
position: 3,
|
||||||
wins: 1,
|
wins: 1,
|
||||||
cumulative: [15, 28, 40, 60, 90, 120],
|
cumulative: [15, 28, 40, 60, 90, 120],
|
||||||
|
round_positions: [3, 3, 3, 0, 3, 3],
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -112,6 +115,7 @@ describe('ChampionshipPage', () => {
|
|||||||
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||||
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
|
expect(screen.getByText('Monaco GP', { exact: false })).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
|
expect(screen.getByTestId('champ-titlemath')).toHaveTextContent('mathematically win the title')
|
||||||
|
expect(screen.getAllByText('~10 pts/round').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to constructors and progression views', async () => {
|
it('switches to constructors and progression views', async () => {
|
||||||
@@ -128,6 +132,22 @@ describe('ChampionshipPage', () => {
|
|||||||
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
expect(screen.getByText('Cumulative points', { exact: false })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('switches to the rivalry view with the top two drivers preselected', async () => {
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('championship')).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('champ-tab-rivalry'))
|
||||||
|
expect(screen.getByTestId('champ-view-rivalry')).toBeInTheDocument()
|
||||||
|
// Default pair is the top two in the standings: VER vs NOR.
|
||||||
|
expect(screen.getByTestId('rivalry-pick-a')).toHaveValue('1')
|
||||||
|
expect(screen.getByTestId('rivalry-pick-b')).toHaveValue('4')
|
||||||
|
// VER beats NOR in rounds 1, 3, 5, 6 → 4–2.
|
||||||
|
expect(screen.getByTestId('rivalry-h2h-num')).toHaveTextContent('4–2')
|
||||||
|
expect(screen.getByTestId('rivalry-points-race')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rivalry-gap')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('switches to the simulator view and projects standings', async () => {
|
it('switches to the simulator view and projects standings', async () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
@@ -149,4 +169,61 @@ describe('ChampionshipPage', () => {
|
|||||||
})
|
})
|
||||||
expect(screen.getByText('No championship data')).toBeInTheDocument()
|
expect(screen.getByText('No championship data')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders teammate battles section ordered by closest split', async () => {
|
||||||
|
const h2hDrivers: ChampHubDriver[] = [
|
||||||
|
driver({ driver_number: 1, name_acronym: 'VER', team_name: 'Red Bull', points: 200, position: 1 }),
|
||||||
|
driver({
|
||||||
|
driver_number: 4,
|
||||||
|
name_acronym: 'NOR',
|
||||||
|
full_name: 'Lando Norris',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 160,
|
||||||
|
position: 2,
|
||||||
|
teammate_wins: 6,
|
||||||
|
teammate_losses: 5,
|
||||||
|
cumulative: [18, 36, 54, 80, 120, 160],
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 81,
|
||||||
|
name_acronym: 'PIA',
|
||||||
|
full_name: 'Oscar Piastri',
|
||||||
|
team_name: 'McLaren',
|
||||||
|
team_colour: 'ff8000',
|
||||||
|
points: 140,
|
||||||
|
position: 3,
|
||||||
|
teammate_wins: 5,
|
||||||
|
teammate_losses: 6,
|
||||||
|
cumulative: [12, 28, 45, 70, 110, 140],
|
||||||
|
}),
|
||||||
|
driver({
|
||||||
|
driver_number: 11,
|
||||||
|
name_acronym: 'PER',
|
||||||
|
full_name: 'Sergio Perez',
|
||||||
|
team_name: 'Red Bull',
|
||||||
|
team_colour: '3671c6',
|
||||||
|
points: 60,
|
||||||
|
position: 4,
|
||||||
|
teammate_wins: 1,
|
||||||
|
teammate_losses: 9,
|
||||||
|
cumulative: [5, 12, 20, 35, 50, 60],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
mockFetchHub.mockResolvedValue({ ...hub, drivers: h2hDrivers })
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('champ-teammate-battles')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Teammate battles')).toBeInTheDocument()
|
||||||
|
const rows = screen.getAllByTestId('teammate-h2h')
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
// McLaren 6–5 is closer than Red Bull 9–1 — McLaren row first.
|
||||||
|
expect(rows[0]).toHaveTextContent('McLaren')
|
||||||
|
expect(rows[0]).toHaveTextContent('6–5')
|
||||||
|
expect(rows[1]).toHaveTextContent('Red Bull')
|
||||||
|
expect(rows[1]).toHaveTextContent('9–1')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function driver(over: Partial<ChampHubDriver>): ChampHubDriver {
|
|||||||
poles: 0,
|
poles: 0,
|
||||||
form: [],
|
form: [],
|
||||||
cumulative: [],
|
cumulative: [],
|
||||||
|
round_positions: [],
|
||||||
teammate_wins: 0,
|
teammate_wins: 0,
|
||||||
teammate_losses: 0,
|
teammate_losses: 0,
|
||||||
...over,
|
...over,
|
||||||
|
|||||||