Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to F1 Replay Timing will be documented in this file.

## 2.1

### Improvements

- **Track elevation overlay** The track map can now colour the circuit by elevation, from dark (lowest point) to bright (highest), on a fixed scale so flat tracks look flat and hilly tracks stand out. Toggle in Settings>Track Map. Requires re-compute for existing sessions. (suggested by [@db-can](https://github.com/db-can))

- **Downloaded session marker** A green dot now appears next to sessions that are already processed and ready to play instantly. Hover over a session to see its stored size. (suggested by [@avergeichik](https://github.com/avergeichik))

### Fixes

- **Live leaderboard column overflow** — the live leaderboard column spacing fixed on 1080x1920 resolution where tyre age was overflowing. (reported by [@meekha3l](https://github.com/meekha3l))

## 2.0.2

### Improvements
Expand Down
25 changes: 24 additions & 1 deletion backend/routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime, timezone

from fastapi import APIRouter, Query, HTTPException
from services.storage import get_json, put_json
from services.storage import get_json, put_json, list_sizes
from services.process import ensure_session_data

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -39,8 +39,16 @@ def _build_events(year: int) -> dict:
now = datetime.now(timezone.utc)
last_past_idx = None

# One storage listing for the whole season; used to mark which sessions
# have already been pre-computed (replay.json present) and their total size.
try:
sizes = list_sizes(f"sessions/{year}")
except Exception:
sizes = {}

for i, evt in enumerate(events):
has_past_session = False
rnd = evt.get("round_number")
for session in evt.get("sessions", []):
date_str = session.get("date_utc")
if date_str:
Expand All @@ -57,6 +65,21 @@ def _build_events(year: int) -> dict:
else:
session["available"] = False

# Pre-compute status + stored size (replay.json is the definitive
# marker that a session has been fully processed).
code = SESSION_NAME_TO_TYPE.get(session.get("name", ""))
if code and rnd is not None:
prefix = f"sessions/{year}/{rnd}/{code}/"
precomputed = (prefix + "replay.json") in sizes
session["precomputed"] = precomputed
session["size_bytes"] = (
sum(v for k, v in sizes.items() if k.startswith(prefix))
if precomputed else 0
)
else:
session["precomputed"] = False
session["size_bytes"] = 0

# Normalize date_utc to ISO 8601 with "Z" so the browser
# correctly interprets them as UTC and converts to local time
for session in evt.get("sessions", []):
Expand Down
22 changes: 21 additions & 1 deletion backend/services/f1_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,20 @@ def _get_track_data_sync(year: int, round_num: int, session_type: str = "R") ->
x = telemetry["X"].values
y = telemetry["Y"].values

# Elevation (Z is in 1/10 m). Store each point's height in metres above the
# track's lowest point, plus the total range, so the frontend can colour the
# line on a fixed absolute scale (flat tracks stay flat-looking).
z_rel_m = None
elevation = None
if "Z" in telemetry.columns:
z = telemetry["Z"].values.astype(float)
if len(z) > 0 and not np.isnan(z).all():
z_min = float(np.nanmin(z))
z_max = float(np.nanmax(z))
z_rel = (np.nan_to_num(z, nan=z_min) - z_min) / 10.0
z_rel_m = [round(float(v), 2) for v in z_rel]
elevation = {"range_m": round((z_max - z_min) / 10.0, 1)}

# Normalize to 0-1 range for frontend flexibility
x_min, x_max = float(x.min()), float(x.max())
y_min, y_max = float(y.min()), float(y.max())
Expand Down Expand Up @@ -333,15 +347,21 @@ def _get_track_data_sync(year: int, round_num: int, session_type: str = "R") ->
logger.warning(f"Could not extract marshal sector data: {e}")
marshal_sectors = None

if z_rel_m is not None:
track_points = [{"x": px, "y": py, "z": pz} for px, py, pz in zip(x_norm, y_norm, z_rel_m)]
else:
track_points = [{"x": px, "y": py} for px, py in zip(x_norm, y_norm)]

return {
"track_points": [{"x": px, "y": py} for px, py in zip(x_norm, y_norm)],
"track_points": track_points,
"rotation": rotation,
"circuit_name": str(session.event.get("Location", "")),
# Raw normalization params so driver positions use the same reference
"norm": {"x_min": x_min, "y_min": y_min, "scale": scale},
"sector_boundaries": sector_boundaries,
"corners": corners,
"marshal_sectors": marshal_sectors,
"elevation": elevation,
}


Expand Down
32 changes: 32 additions & 0 deletions backend/services/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ def _local_list_keys(prefix: str) -> list[str]:
return [str(p.relative_to(_data_dir())) for p in base.rglob("*") if p.is_file()]


def _local_list_sizes(prefix: str) -> dict[str, int]:
base = _data_dir() / prefix
if not base.exists():
return {}
sizes: dict[str, int] = {}
for p in base.rglob("*"):
if p.is_file():
sizes[str(p.relative_to(_data_dir()))] = p.stat().st_size
return sizes


# ---------------------------------------------------------------------------
# R2 backend
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -146,6 +157,16 @@ def _r2_list_keys(prefix: str) -> list[str]:
return keys


def _r2_list_sizes(prefix: str) -> dict[str, int]:
client = _get_r2_client()
sizes: dict[str, int] = {}
paginator = client.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=_r2_bucket(), Prefix=_r2_key(prefix)):
for obj in page.get("Contents", []):
sizes[obj["Key"]] = obj["Size"]
return sizes


# ---------------------------------------------------------------------------
# Public API - delegates to the configured backend
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -173,3 +194,14 @@ def list_keys(prefix: str) -> list[str]:
if _mode() == "r2":
return _r2_list_keys(prefix)
return _local_list_keys(prefix)


def list_sizes(prefix: str) -> dict[str, int]:
"""Return {storage_key: size_in_bytes} for every file under *prefix*.

For R2 the reported size is the gzip-compressed object size; for local
storage it is the on-disk (uncompressed) size.
"""
if _mode() == "r2":
return _r2_list_sizes(prefix)
return _local_list_sizes(prefix)
36 changes: 36 additions & 0 deletions frontend/src/app/features/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ export default function FeaturesPage() {

<div className="max-w-3xl mx-auto px-6 py-12 space-y-10">

{/* Session Picker */}
<section className="bg-f1-card border border-f1-border rounded-xl p-6">
<h2 className="text-lg font-bold text-white mb-1">Session Picker</h2>
<p className="text-xs font-bold text-f1-red uppercase tracking-wider mb-3">Home page</p>
<p className="text-f1-text leading-relaxed">
A green dot
<span className="inline-block align-middle w-1.5 h-1.5 rounded-full bg-green-400 mx-1.5" />
next to a session means it has already been downloaded and will play instantly. Hover over
a session to see its stored size. Sessions without a dot are processed on demand the first
time you open them.
</p>
</section>

{/* Track Map & Car Positions */}
<section className="bg-f1-card border border-f1-border rounded-xl p-6">
<h2 className="text-lg font-bold text-white mb-1">Track Map</h2>
Expand All @@ -37,6 +50,29 @@ export default function FeaturesPage() {
</p>
</section>

{/* Track Elevation */}
<section className="bg-f1-card border border-f1-border rounded-xl p-6">
<h2 className="text-lg font-bold text-white mb-1">Track Elevation</h2>
<p className="text-xs font-bold text-f1-red uppercase tracking-wider mb-3">All sessions</p>
<p className="text-f1-text leading-relaxed mb-4">
The track line can be coloured by elevation, derived from the same GPS telemetry as the map.
The lowest point of the lap is dark and the highest is bright:
</p>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-f1-muted">Low</span>
<div
className="h-3 flex-1 max-w-xs rounded-sm border border-black/30"
style={{ background: "linear-gradient(to right, #33384a, #6fb7d6, #ffffff)" }}
/>
<span className="text-xs text-f1-muted">High</span>
</div>
<p className="text-f1-text leading-relaxed">
Colours use a fixed scale across every circuit rather than each track&apos;s own range, so a flat
circuit stays uniformly dark while a hilly one shows a strong gradient. A legend on the map shows
the total elevation change. Toggle it in Settings &gt; Track Map.
</p>
</section>

{/* Driver Leaderboard */}
<section className="bg-f1-card border border-f1-border rounded-xl p-6">
<h2 className="text-lg font-bold text-white mb-1">Driver Leaderboard</h2>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/app/live/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,9 @@ export default function LivePage() {
if (!isRace) w += 18;
if (isRace && settings.showGridChange) w += 24;
if (!isRace && settings.showBestLapTime) w += 60; // best lap time column
if (settings.showGapToLeader) w += 56;
if (isQualifying && settings.showSectors) w += 36;
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 (!isRace && settings.showSectors) w += 36; // sector indicators (practice + qualifying)
if (isRace && settings.showPitStops) w += 24;
if (isRace && settings.showTyreHistory) w += 36;
if (settings.showTyreType) w += 24;
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/app/replay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ import type { SectorOverlay } from "@/lib/trackRenderer";
import { Maximize, Minimize, ArrowUpRight } from "lucide-react";

interface TrackData {
track_points: { x: number; y: number }[];
track_points: { x: number; y: number; z?: number }[];
rotation: number;
circuit_name: string;
sector_boundaries?: { s1_end: number; s2_end: number; total: number } | null;
corners?: { x: number; y: number; number: number; letter: string; angle: number }[] | null;
marshal_sectors?: { x: number; y: number; number: number }[] | null;
elevation?: { range_m: number } | null;
}

interface SessionData {
Expand Down Expand Up @@ -628,6 +629,8 @@ export default function ReplayPage() {
corners={settings.showCorners ? trackData?.corners : null}
marshalSectors={trackData?.marshal_sectors}
sectorFlags={replay.frame?.sector_flags}
showElevation={settings.showElevation}
elevationRangeM={trackData?.elevation?.range_m ?? null}
/>

{/* Telemetry now in bottom drawer */}
Expand Down Expand Up @@ -1055,6 +1058,8 @@ export default function ReplayPage() {
corners={settings.showCorners ? trackData?.corners : null}
marshalSectors={trackData?.marshal_sectors}
sectorFlags={replay.frame?.sector_flags}
showElevation={settings.showElevation}
elevationRangeM={trackData?.elevation?.range_m ?? null}
/>
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/SessionBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const WEATHER_SETTINGS: { key: keyof ReplaySettings; label: string }[] = [
const TRACK_MAP_SETTINGS: { key: keyof ReplaySettings; label: string }[] = [
{ key: "showDriverNames", label: "Driver names on track" },
{ key: "showCorners", label: "Corner numbers" },
{ key: "showElevation", label: "Elevation" },
];

const OTHER_SETTINGS: { key: keyof ReplaySettings; label: string; hint?: string }[] = [
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/components/SessionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ interface SessionEntry {
name: string;
date_utc: string | null;
available: boolean;
precomputed?: boolean;
size_bytes?: number;
}

interface LiveSessionInfo {
Expand Down Expand Up @@ -82,6 +84,13 @@ const SESSION_LABELS: Record<string, string> = {
"Practice 3": "FP3",
};

function formatSize(bytes?: number): string | null {
if (!bytes || bytes <= 0) return null;
const mb = bytes / (1024 * 1024);
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}

function formatLocalTime(dateUtc: string | null): { dayDate: string; time: string } | null {
if (!dateUtc) return null;
try {
Expand Down Expand Up @@ -257,6 +266,10 @@ export default function SessionPicker() {
);
}
if (session.available) {
const sizeLabel = formatSize(session.size_bytes);
const tooltip = session.precomputed
? `Downloaded${sizeLabel ? ` · ${sizeLabel}` : ""}`
: "Not downloaded — will process when opened";
return (
<div key={session.name} className="flex flex-col items-center">
{localTime && (
Expand All @@ -270,9 +283,16 @@ export default function SessionPicker() {
e.stopPropagation();
setNavigating(true);
}}
className="px-3 py-1.5 bg-f1-border text-white text-xs font-bold rounded hover:bg-f1-red transition-colors"
title={tooltip}
className="px-3 py-1.5 bg-f1-border text-white text-xs font-bold rounded hover:bg-f1-red transition-colors flex items-center gap-1.5"
>
{session.name}
{session.precomputed && (
<span
className="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0"
aria-label="Downloaded"
/>
)}
</a>
</div>
);
Expand Down
31 changes: 27 additions & 4 deletions frontend/src/components/TrackCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useRef, useEffect } from "react";
import { drawTrack, drawDrivers, TrackPoint, DriverMarker, SectorOverlay, Corner, MarshalSector, SectorFlag } from "@/lib/trackRenderer";
import { drawTrack, drawDrivers, TrackPoint, DriverMarker, SectorOverlay, Corner, MarshalSector, SectorFlag, ELEVATION_FULL_SCALE_M } from "@/lib/trackRenderer";

interface Props {
trackPoints: TrackPoint[];
Expand All @@ -15,6 +15,8 @@ interface Props {
corners?: Corner[] | null;
marshalSectors?: MarshalSector[] | null;
sectorFlags?: SectorFlag[] | null;
showElevation?: boolean;
elevationRangeM?: number | null;
}

// Longer than the 500ms frame interval so the dot is always still moving
Expand All @@ -35,7 +37,7 @@ function getCanvasWindow(canvas: HTMLCanvasElement | null): Window {
}


export default function TrackCanvas({ trackPoints, rotation, trackStatus = "green", drivers, highlightedDrivers, playbackSpeed = 1, showDriverNames = true, sectorOverlay = null, corners = null, marshalSectors = null, sectorFlags = null }: Props) {
export default function TrackCanvas({ trackPoints, rotation, trackStatus = "green", drivers, highlightedDrivers, playbackSpeed = 1, showDriverNames = true, sectorOverlay = null, corners = null, marshalSectors = null, sectorFlags = null, showElevation = false, elevationRangeM = null }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const sizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
Expand All @@ -56,6 +58,8 @@ export default function TrackCanvas({ trackPoints, rotation, trackStatus = "gree
marshalSectorsRef.current = marshalSectors;
const sectorFlagsRef = useRef(sectorFlags);
sectorFlagsRef.current = sectorFlags;
const showElevationRef = useRef(showElevation);
showElevationRef.current = showElevation;

// Update targets when drivers prop changes
useEffect(() => {
Expand Down Expand Up @@ -124,7 +128,7 @@ export default function TrackCanvas({ trackPoints, rotation, trackStatus = "gree
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);

drawTrack(ctx, trackPoints, w, h, rotation, trackStatusRef.current, sectorOverlayRef.current, cornersRef.current, marshalSectorsRef.current, sectorFlagsRef.current);
drawTrack(ctx, trackPoints, w, h, rotation, trackStatusRef.current, sectorOverlayRef.current, cornersRef.current, marshalSectorsRef.current, sectorFlagsRef.current, showElevationRef.current);

const now = performance.now();
const curr = driversRef.current;
Expand Down Expand Up @@ -170,9 +174,28 @@ export default function TrackCanvas({ trackPoints, rotation, trackStatus = "gree
return () => observer.disconnect();
}, []);

const showElevationLegend = showElevation && trackPoints.some((p) => typeof p.z === "number");

return (
<div ref={containerRef} className="w-full h-full bg-f1-dark">
<div ref={containerRef} className="w-full h-full bg-f1-dark relative">
<canvas ref={canvasRef} className="w-full h-full" />
{showElevationLegend && (
<div className="absolute bottom-16 left-3 flex items-end gap-2 pointer-events-none select-none">
<div className="flex flex-col items-center">
<span className="text-[9px] text-f1-muted leading-none mb-1">{ELEVATION_FULL_SCALE_M}m</span>
<div
className="w-2 h-20 rounded-sm border border-black/30"
style={{ background: "linear-gradient(to top, #33384a, #6fb7d6, #ffffff)" }}
/>
<span className="text-[9px] text-f1-muted leading-none mt-1">0m</span>
</div>
{typeof elevationRangeM === "number" && (
<span className="text-[10px] font-bold text-f1-text leading-none mb-1">
Elevation Δ {elevationRangeM}m
</span>
)}
</div>
)}
</div>
);
}
Loading
Loading