From 8181fd4450da29a2233bb7f26016630ff3b91a9f Mon Sep 17 00:00:00 2001 From: angusdenham Date: Sat, 27 Jun 2026 23:19:15 +1000 Subject: [PATCH 1/3] Add qualifying knockout greying and lap-completion bubbles Qualifying knockout greying: - Derive each driver's reached segment from results Q1/Q2/Q3 columns and flag knocked_out per replay frame once a later segment begins; drivers keep their position and posted time and are dimmed in the leaderboard. - Live: KnockedOut now sets a knocked_out flag instead of retired, so eliminated drivers stay visible and dimmed rather than dropping out. Qualifying lap-completion bubbles: - New top-of-track bubble per completed flying lap showing initials, lap time (timing-tower colour), and sector markers. Toggleable in Settings -> Leaderboard (qualifying only), default on. - Extract the leaderboard's last-lap colour logic and sector squares into a shared lib/lapTiming module so the tower and bubble share one source. Requires recompute for historical qualifying sessions. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/services/f1_data.py | 27 +++ backend/services/live_state.py | 8 +- frontend/src/app/globals.css | 9 + frontend/src/app/replay/page.tsx | 12 ++ frontend/src/components/LapNotifications.tsx | 182 +++++++++++++++++++ frontend/src/components/Leaderboard.tsx | 57 +----- frontend/src/components/SessionBanner.tsx | 1 + frontend/src/hooks/useReplaySocket.ts | 1 + frontend/src/hooks/useSettings.ts | 2 + frontend/src/lib/lapTiming.tsx | 81 +++++++++ frontend/tsconfig.tsbuildinfo | 2 +- 11 files changed, 326 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/LapNotifications.tsx create mode 100644 frontend/src/lib/lapTiming.tsx diff --git a/backend/services/f1_data.py b/backend/services/f1_data.py index 23c680c..9f597e5 100644 --- a/backend/services/f1_data.py +++ b/backend/services/f1_data.py @@ -1009,6 +1009,24 @@ def _format_lap_time(seconds: float) -> str: except Exception as e: logger.error(f"Failed to parse qualifying phases: {e}") + # Per-driver highest qualifying segment reached (1/2/3), read straight from + # the results Q1/Q2/Q3 best-lap columns. Used to grey out drivers once a + # later segment begins (they keep their position and posted time). + driver_reached_phase: dict[str, int] = {} + if is_quali: + try: + for _, row in session.results.iterrows(): + abbr = str(row.get("Abbreviation", "")) + if not abbr: + continue + reached = 0 + for idx, col in enumerate(("Q1", "Q2", "Q3"), start=1): + if col in row and pd.notna(row.get(col)): + reached = idx + driver_reached_phase[abbr] = reached + except Exception as e: + logger.error(f"Failed to compute qualifying reached phases: {e}") + def _get_quali_phase(t_sec: float) -> dict | None: """Get qualifying phase info at time t_sec.""" if not quali_intervals: @@ -1474,6 +1492,15 @@ def _safe_float(v) -> float: d["gap"] = "No time" d["no_timing"] = False + # Qualifying: flag drivers knocked out in an earlier segment so the + # frontend can dim them. They keep their position and posted time. + if is_quali: + phase = _get_quali_phase(t_sec) + phase_idx = {"Q1": 1, "Q2": 2, "Q3": 3}.get(phase["phase"], 1) if phase else 1 + for d in frame_drivers: + reached = driver_reached_phase.get(d["abbr"], 0) + d["knocked_out"] = phase_idx >= 2 and reached < phase_idx + # Add live sector indicators for qualifying and practice if session_type in ("Q", "SQ", "FP1", "FP2", "FP3"): # Track overall best and personal best sector times up to now diff --git a/backend/services/live_state.py b/backend/services/live_state.py index 0c8a72d..3d95fb9 100644 --- a/backend/services/live_state.py +++ b/backend/services/live_state.py @@ -87,6 +87,7 @@ class _DriverState: "has_fastest_lap", "flag", "retired", + "knocked_out", "no_timing", "grid_position", "sectors", @@ -124,6 +125,7 @@ def __init__(self, racing_number: str) -> None: self.has_fastest_lap: bool = False self.flag: str | None = None # "investigation" | "penalty" | None self.retired: bool = False + self.knocked_out: bool = False self.no_timing: bool = False self.grid_position: int | None = None self.sectors: list[dict[str, Any]] | None = None @@ -159,6 +161,7 @@ def to_dict(self) -> dict[str, Any]: "has_fastest_lap": self.has_fastest_lap, "flag": self.flag, "retired": self.retired, + "knocked_out": self.knocked_out, "no_timing": self.no_timing, "grid_position": self.grid_position, "sectors": self.sectors, @@ -368,8 +371,9 @@ def _handle_timing_data(self, data: dict, _ts: float) -> None: drv.retired = True if "KnockedOut" in updates: - if updates["KnockedOut"]: - drv.retired = True + # Keep knocked-out drivers visible with their posted time; the + # frontend dims them rather than dropping them out of timing. + drv.knocked_out = bool(updates["KnockedOut"]) # Sectors (qualifying sector indicators) if "Sectors" in updates: diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0a182e3..b61452f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -39,3 +39,12 @@ body { ::-webkit-scrollbar-thumb:hover { background: #4a4a5a; } + +@keyframes lapBubbleIn { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +.lap-bubble { + animation: lapBubbleIn 0.25s ease-out; +} diff --git a/frontend/src/app/replay/page.tsx b/frontend/src/app/replay/page.tsx index cd4d066..b2b80a5 100644 --- a/frontend/src/app/replay/page.tsx +++ b/frontend/src/app/replay/page.tsx @@ -13,6 +13,7 @@ import TelemetryChart from "@/components/TelemetryChart"; import SyncPhoto from "@/components/SyncPhoto"; import PiPWindow from "@/components/PiPWindow"; import LapAnalysisPanel from "@/components/LapAnalysisPanel"; +import LapNotifications from "@/components/LapNotifications"; import type { SectorOverlay } from "@/lib/trackRenderer"; import { Maximize, Minimize, ArrowUpRight } from "lucide-react"; @@ -495,6 +496,17 @@ export default function ReplayPage() { )} + {/* Qualifying lap-completion bubbles */} + + {/* Race Control toggle - desktop only, mobile has its own section */}