diff --git a/CHANGELOG.md b/CHANGELOG.md index e30e5bd..e110db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog -All notable changes to F1 Timing Replay will be documented in this file. +All notable changes to F1 Replay Timing will be documented in this file. + +## 1.3.2 + +### Improvements +- **Practice sector indicators** — live sector colours and track map sector overlay now available in practice sessions. Requires recompute +- **Last lap time for all sessions** — now available in practice and qualifying. Requires recompute +- **Last lap colour coding** — purple for fastest lap, green for personal best +- **Processing feedback** — real-time status messages during on-demand processing +- **Broadcast delay modal** — redesigned with quick adjust buttons and exact entry +- **Open all data panels** — toggle in Settings > Other to open telemetry, race control, and lap analysis (race only) in a single click. Closes automatically when any panel is hidden +- **Persistent panel layout** — panel states (telemetry, race control, lap analysis, sectors) are remembered per session type (race, qualifying, practice) across page loads +- **High contrast text** — toggle in Settings > Other for white text on muted elements + +### Fixes +- **Practice session precompute** — fixed timestamp alignment for practice sessions causing leaderboard to show future data. Requires recompute +- **Practice session time** — countdown aligned to 60-minute session duration, not overall replay length +- **Mobile qualifying time** — now shows remaining time counting down, matching desktop +- **PiP leaderboard scroll** — leaderboard no longer cut off by playback controls +- **Minor UI layout fixes** — spacing and alignment across mobile, tablet, and desktop + +--- ## 1.3.1 diff --git a/backend/routers/replay.py b/backend/routers/replay.py index 6b02e3c..40307f3 100644 --- a/backend/routers/replay.py +++ b/backend/routers/replay.py @@ -251,6 +251,7 @@ async def send_status(msg: str): # Clear cache entry in case we just processed new data _replay_cache.pop(f"{year}_{round_num}_{type}", None) + await send_status("Preparing replay...") frames = await _get_frames(year, round_num, type) cache_key = f"{year}_{round_num}_{type}" _client_connect(cache_key) diff --git a/backend/services/f1_data.py b/backend/services/f1_data.py index 817cb74..23c680c 100644 --- a/backend/services/f1_data.py +++ b/backend/services/f1_data.py @@ -353,6 +353,27 @@ def _get_lap_data_sync(year: int, round_num: int, session_type: str = "R") -> li session = _load_session(year, round_num, session_type) laps = session.laps + # Compute the replay start offset — must match how replay frames are generated. + # Replay uses min(Date) from driver telemetry (get_telemetry()), NOT pos_data. + # FastF1 lap Time is relative to t0_date, so: replay_ts = Time - (min_tel_date - t0_date) + replay_offset_secs = 0.0 + try: + all_dates = [] + drivers_list = laps["Driver"].unique().tolist() + for drv in drivers_list: + drv_laps = laps.pick_drivers(drv) + try: + tel = drv_laps.get_telemetry() + if tel is not None and "Date" in tel.columns and len(tel) > 0: + all_dates.extend(tel["Date"].dropna().tolist()) + except Exception: + continue + if all_dates and hasattr(session, "t0_date") and session.t0_date is not None: + min_date = min(all_dates) + replay_offset_secs = (min_date - session.t0_date).total_seconds() + except Exception: + replay_offset_secs = 0.0 + result = [] for _, lap in laps.iterrows(): def fmt_time(td): @@ -365,11 +386,17 @@ def fmt_time(td): return f"{mins}:{secs:06.3f}" return f"{secs:.3f}" + # Lap completion time relative to replay start (not session start) + lap_end_time = None + if pd.notna(lap.get("Time")): + lap_end_time = round(lap["Time"].total_seconds() - replay_offset_secs, 3) + result.append({ "driver": str(lap.get("Driver", "")), "lap_number": int(lap.get("LapNumber", 0)), "position": int(lap["Position"]) if pd.notna(lap.get("Position")) else None, "lap_time": fmt_time(lap.get("LapTime")), + "time": lap_end_time, "sector1": fmt_time(lap.get("Sector1Time")), "sector2": fmt_time(lap.get("Sector2Time")), "sector3": fmt_time(lap.get("Sector3Time")), @@ -852,12 +879,12 @@ def _get_driver_flag(abbr: str, frame_time: float) -> str | None: driver_best_lap_events[drv] = events driver_lap_completions[drv] = completions - # For qualifying sessions: build sector completion events per driver + # For qualifying and practice sessions: build sector completion events per driver # Each entry: (session_time, sector_num, sector_time_seconds, lap_number, is_out_lap) # Also pre-compute which laps are out laps (lap 1 or first lap after pit exit) driver_sector_events: dict[str, list[tuple[float, int, float, int, bool]]] = {} driver_out_laps: dict[str, set[int]] = {} - if session_type in ("Q", "SQ"): + if session_type in ("Q", "SQ", "FP1", "FP2", "FP3"): for drv in drivers_list: drv_laps_df = laps.pick_drivers(drv).sort_values("LapNumber") sector_events = [] @@ -900,16 +927,21 @@ def _format_lap_time(seconds: float) -> str: # Compute session time offset: t_sec (from min_date) + session_time_offset = session timedelta # This is needed because gap data uses session timedeltas, not min_date offsets + is_practice = session_type in ("FP1", "FP2", "FP3") session_time_offset = 0.0 - for tel in driver_pos_data.values(): - if "SessionTime" in tel.columns and "Date" in tel.columns and len(tel) > 0: - # Find the entry closest to min_date - diffs = (tel["Date"] - min_date).abs() - closest_idx = diffs.idxmin() - st = tel.loc[closest_idx, "SessionTime"] - if pd.notna(st): - session_time_offset = st.total_seconds() - break + if is_practice and hasattr(session, "t0_date") and session.t0_date is not None: + # For practice: use direct calculation from t0_date to min_date + session_time_offset = (min_date - session.t0_date).total_seconds() + else: + for tel in driver_pos_data.values(): + if "SessionTime" in tel.columns and "Date" in tel.columns and len(tel) > 0: + # Find the entry closest to min_date + diffs = (tel["Date"] - min_date).abs() + closest_idx = diffs.idxmin() + st = tel.loc[closest_idx, "SessionTime"] + if pd.notna(st): + session_time_offset = st.total_seconds() + break # Pre-compute track status (yellow/SC/VSC/red) lookup # track_status Time is a session timedelta, same as gap data @@ -1442,8 +1474,8 @@ def _safe_float(v) -> float: d["gap"] = "No time" d["no_timing"] = False - # Add live sector indicators for qualifying - if session_type in ("Q", "SQ"): + # 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 overall_best_sectors: dict[int, float] = {} # sector_num -> best time personal_best_sectors: dict[str, dict[int, float]] = {} # driver -> sector_num -> best time diff --git a/backend/services/process.py b/backend/services/process.py index 10438bc..e6e918e 100644 --- a/backend/services/process.py +++ b/backend/services/process.py @@ -123,7 +123,7 @@ def status(msg: str): except Exception as e: logger.warning(f"[{prefix}] Telemetry upload issue: {e}") - status("Done") + status("Processing complete") logger.info(f"[{prefix}] Done") return True diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index be239d2..0a182e3 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,6 +2,14 @@ @tailwind components; @tailwind utilities; +:root { + --f1-muted: #6B7280; +} + +.high-contrast { + --f1-muted: #FFFFFF; +} + html, body { margin: 0; diff --git a/frontend/src/app/live/[year]/[round]/page.tsx b/frontend/src/app/live/[year]/[round]/page.tsx index 262f0cc..441d11e 100644 --- a/frontend/src/app/live/[year]/[round]/page.tsx +++ b/frontend/src/app/live/[year]/[round]/page.tsx @@ -599,57 +599,157 @@ export default function LivePage() { > Delay: {delayOffset > 0 ? "+" : ""}{delayOffset}s - {showDelaySlider && ( -
-
- Broadcast Delay -
- setDelayOffset(Number(e.target.value))} - className="w-full h-1 bg-f1-border rounded-lg appearance-none cursor-pointer accent-blue-500" - /> -
- -60s - +10s -
-
- - { - const v = Number(e.target.value); - if (!isNaN(v)) setDelayOffset(Math.max(-60, Math.min(10, v))); - }} - className="w-16 px-1.5 py-0.5 bg-f1-dark border border-f1-border rounded text-[10px] text-white text-center focus:outline-none focus:border-blue-500" - /> - seconds + +
+ {/* Current value display */} +
+ {delayOffset.toFixed(1)} + seconds +
+ + {/* Slider */} + {/* Slider with zero tick mark */} +
+ setDelayOffset(Number(e.target.value))} + className="w-full h-1.5 bg-f1-border rounded-lg appearance-none cursor-pointer accent-blue-500 relative z-10" + /> + {/* Zero tick — positioned absolutely over the slider */} +
+
+
+
+
+ -60s + 0s + +10s +
+ + {/* Quick adjust buttons */} +
+ {[ + { label: "-5s", delta: -5 }, + { label: "-1s", delta: -1 }, + { label: "-0.5s", delta: -0.5 }, + ].map(({ label, delta }) => ( + + ))} + + {[ + { label: "+0.5s", delta: 0.5 }, + { label: "+1s", delta: 1 }, + { label: "+5s", delta: 5 }, + ].map(({ label, delta }) => ( + + ))} +
+ + {/* Manual input */} +
+ +
+ + { e.target.value = ""; }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const raw = (e.target as HTMLInputElement).value; + if (raw === "") return; + const v = Math.abs(Number(raw)); + if (isNaN(v)) return; + const sign = (document.getElementById("delay-sign-btn") as HTMLButtonElement)?.textContent === "−" ? -1 : 1; + setDelayOffset(Math.max(-60, Math.min(10, Math.round(v * sign * 2) / 2))); + (e.target as HTMLInputElement).value = ""; + } + }} + className="w-16 px-2 py-1.5 bg-transparent text-sm text-white text-center focus:outline-none" + /> +
+ +
+ +

+ Pauses the live data feed until it aligns with your broadcast. Set this to match the delay of your streaming service. +

+ + {/* Actions */} +
+ + +
-

- Pauses the live data feed until it aligns with your broadcast. Set this to match the delay of your streaming service so the leaderboard updates at the same time as the TV coverage. -

-
- )} + )}
{/* Live indicator + PiP */} diff --git a/frontend/src/app/replay/[year]/[round]/page.tsx b/frontend/src/app/replay/[year]/[round]/page.tsx index faa58c5..25434ca 100644 --- a/frontend/src/app/replay/[year]/[round]/page.tsx +++ b/frontend/src/app/replay/[year]/[round]/page.tsx @@ -63,7 +63,9 @@ export default function ReplayPage() { const [lapAnalysisOpen, setLapAnalysisOpen] = useState(false); const [mobileLapAnalysisOpen, setMobileLapAnalysisOpen] = useState(false); // Force telemetry to bottom when lap analysis panel is open to avoid squashing the track map - const effectiveTelemetryPosition = lapAnalysisOpen && telemetryPosition === "left" ? "bottom" : telemetryPosition; + // effectiveTelemetryPosition is computed later after settings is available + const [forceBottomTelemetry, setForceBottomTelemetry] = useState(false); + const effectiveTelemetryPosition = (lapAnalysisOpen || forceBottomTelemetry) && telemetryPosition === "left" ? "bottom" : telemetryPosition; const [leaderboardScale, setLeaderboardScale] = useState(1); const [pipTrackOpen, setPipTrackOpen] = useState(true); const [pipTelemetryOpen, setPipTelemetryOpen] = useState(false); @@ -73,6 +75,46 @@ export default function ReplayPage() { const [sectorFocusDriver, setSectorFocusDriver] = useState(null); const [rcPanelOpen, setRcPanelOpen] = useState(false); const [rcPinned, setRcPinned] = useState(false); + + // Persist panel layout per session type + const layoutCategory = sessionType === "R" || sessionType === "S" ? "race" + : sessionType === "Q" || sessionType === "SQ" ? "qualifying" + : "practice"; + const layoutKey = `f1replay_layout_${layoutCategory}`; + const layoutLoadedRef = useRef(false); + + // Load saved layout on mount + useEffect(() => { + try { + const saved = localStorage.getItem(layoutKey); + if (saved) { + const layout = JSON.parse(saved); + if (layout.showTelemetry != null) setShowTelemetry(layout.showTelemetry); + if (layout.telemetryPosition != null) setTelemetryPosition(layout.telemetryPosition); + if (layout.rcPinned != null) setRcPinned(layout.rcPinned); + if (layout.rcPanelOpen != null) setRcPanelOpen(layout.rcPanelOpen); + if (layout.lapAnalysisOpen != null) setLapAnalysisOpen(layout.lapAnalysisOpen); + if (layout.showSectorOverlay != null) setShowSectorOverlay(layout.showSectorOverlay); + } + } catch {} + // Allow saving after load completes + setTimeout(() => { layoutLoadedRef.current = true; }, 100); + }, [layoutKey]); + + // Save layout when panel states change (only after initial load) + useEffect(() => { + if (!layoutLoadedRef.current) return; + try { + localStorage.setItem(layoutKey, JSON.stringify({ + showTelemetry, + telemetryPosition, + rcPinned, + rcPanelOpen, + lapAnalysisOpen, + showSectorOverlay, + })); + } catch {} + }, [showTelemetry, telemetryPosition, rcPinned, rcPanelOpen, lapAnalysisOpen, showSectorOverlay, layoutKey]); const [rcPanelSize, setRcPanelSize] = useState<"sm" | "md" | "lg">("md"); const [rcPosition, setRcPosition] = useState<{ x: number; y: number } | null>(null); const rcDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); @@ -146,16 +188,15 @@ export default function ReplayPage() { ); // Fetch lap data for last lap time column (race/sprint only) + // Fetch lap data for last lap time column (all session types) const { data: lapsResponse } = useApi<{ laps: LapEntry[] }>( - sessionType === "R" || sessionType === "S" - ? `/api/sessions/${year}/${round}/laps?type=${sessionType}` - : null, + `/api/sessions/${year}/${round}/laps?type=${sessionType}`, ); // Build lookup: driver -> lap_number -> lap_time const lapData = useMemo(() => { if (!lapsResponse?.laps) return undefined; - const map = new Map>(); + const map = new Map>(); for (const lap of lapsResponse.laps) { if (!lap.lap_time) continue; let driverMap = map.get(lap.driver); @@ -163,7 +204,7 @@ export default function ReplayPage() { driverMap = new Map(); map.set(lap.driver, driverMap); } - driverMap.set(lap.lap_number, lap.lap_time); + driverMap.set(lap.lap_number, { time: lap.lap_time, completedAt: lap.time ?? null }); } return map; }, [lapsResponse]); @@ -199,6 +240,21 @@ export default function ReplayPage() { } }, [selectedDrivers.length, showTelemetry, effectiveTelemetryPosition]); + const isRace = sessionType === "R" || sessionType === "S"; + + // "Open all data panels" — must be before early returns to maintain hook order + useEffect(() => { + if (settings.showAllPanels) { + setShowTelemetry(true); + setRcPinned(true); + setRcPanelOpen(false); + setForceBottomTelemetry(true); + if (isRace) setLapAnalysisOpen(true); + } else { + setForceBottomTelemetry(false); + } + }, [settings.showAllPanels]); // eslint-disable-line react-hooks/exhaustive-deps + const isLoading = sessionLoading || trackLoading; const dataError = sessionError || trackError; @@ -207,10 +263,12 @@ export default function ReplayPage() { return (
-
-

Loading session data...

+
+

+ {replay.statusMessage || "Loading session data..."} +

- First load may take up to 60 seconds while data is fetched + {replay.statusMessage ? "This may take a few minutes the first time a session loads" : "First load may take up to 60 seconds while data is fetched"}

@@ -245,14 +303,26 @@ export default function ReplayPage() { ? Math.max(0, redFlagEnd - replay.frame.timestamp) : null; const weather = replay.frame?.weather; - const isRace = sessionType === "R" || sessionType === "S"; const isQualifying = sessionType === "Q" || sessionType === "SQ"; + const isPractice = sessionType === "FP1" || sessionType === "FP2" || sessionType === "FP3"; + const hasSectors = isQualifying || isPractice; + + // Turn off showAllPanels when user manually closes any panel + function closePanel(closeFn: () => void) { + closeFn(); + if (settings.showAllPanels) updateSetting("showAllPanels", false); + } + + // For practice sessions, cap the total time at the official session duration (60 min) + // so the "remaining" timer is accurate rather than including post-session telemetry + const PRACTICE_DURATION = 3600; // 60 minutes + const effectiveTotalTime = isPractice ? Math.min(replay.totalTime, PRACTICE_DURATION) : replay.totalTime; // Compute sector overlay for track map const SECTOR_HEX: Record = { purple: "#A855F7", green: "#22C55E", yellow: "#EAB308" }; const DEFAULT_SECTOR = "#3A3A4A"; const sectorOverlay: SectorOverlay | null = (() => { - if (!isQualifying || !showSectorOverlay || !trackData?.sector_boundaries) return null; + if (!hasSectors || !showSectorOverlay || !trackData?.sector_boundaries) return null; const target = sectorFocusDriver && selectedDrivers.includes(sectorFocusDriver) ? sectorFocusDriver : null; @@ -276,9 +346,9 @@ export default function ReplayPage() { if (!isRace) w += 18; // pit indicator (P box + margin) if (isRace && settings.showGridChange) w += 24; if (!isRace && settings.showBestLapTime) w += 60; // best lap time column - if (isRace && settings.showLastLapTime) w += 60; // last lap time column - if (settings.showGapToLeader) w += 56; - if (isQualifying && settings.showSectors) w += 36; // sector indicators (28 + 8 margin) + if (settings.showLastLapTime) w += 60; // last lap time column + if (settings.showGapToLeader) w += 56 + (!isRace ? 8 : 0); // extra margin between lap time and gap in practice/qualifying + if (hasSectors && settings.showSectors) w += 36; // sector indicators (28 + 8 margin) if (isRace && settings.showPitStops) w += 24; if (isRace && settings.showTyreHistory) w += 36; if (settings.showTyreType) w += 24; @@ -346,7 +416,7 @@ export default function ReplayPage() {
{/* Track section */} -
2 ? `flex ${effectiveTelemetryPosition === "left" ? "flex-row" : "flex-col"} min-h-0` : "relative"}`}> +
2 || settings.showAllPanels || rcPinned) ? `flex ${effectiveTelemetryPosition === "left" ? "flex-row" : "flex-col"} min-h-0` : "relative"}`}> {/* Mobile section header */} {isMobile && ( )}
- {selectedDrivers.map((abbr) => { - const drv = drivers.find((d) => d.abbr === abbr) || null; - return ; - })} + {selectedDrivers.length > 0 ? ( + selectedDrivers.map((abbr) => { + const drv = drivers.find((d) => d.abbr === abbr) || null; + return ; + }) + ) : ( +

Select drivers on the leaderboard to view telemetry

+ )}
@@ -724,7 +798,7 @@ export default function ReplayPage() {
Race Control
{/* PiP Race Control */} -
+
{/* PiP Telemetry */} -
+
{/* PiP Leaderboard */} -
+
{pipLeaderboardOpen && ( -
+
void; - lapData?: Map>; + lapData?: Map>; currentLap?: number; mobileTeamAbbrHidden?: boolean; } @@ -157,17 +158,17 @@ export default function Leaderboard({ drivers, highlightedDrivers, onDriverClick