Add all local circuit work — year-by-year SVGs and tooling

This commit is contained in:
2026-03-25 22:17:47 +01:00
parent 6ceeba7fb9
commit c94ebcfb22
1149 changed files with 171650 additions and 421 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+38
View File
@@ -0,0 +1,38 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .track_layout import TrackLayout
if TYPE_CHECKING:
from .locality import Locality
@dataclass
class URLs:
wikipedia: str
fandom: str
@dataclass
class Circuit:
slug: str
name: str
urls: URLs
locality: 'Locality'
layouts: dict[str, TrackLayout]
@classmethod
def from_dict(cls, locality: 'Locality', slug: str, data: dict) -> 'Circuit':
circuit = cls(
slug=slug,
name=data["name"],
urls=URLs(**data["urls"]),
locality=locality,
layouts={}
)
circuit.layouts = {layout_slug: TrackLayout.from_dict(circuit, layout_slug, layout_data) for
layout_slug, layout_data in data["layouts"].items()}
return circuit
def load_geo_json_data(self):
"""Load data for all layouts"""
for track_layout in self.layouts.values():
track_layout.load_geo_json_data()
+24
View File
@@ -0,0 +1,24 @@
from dataclasses import dataclass
from .locality import Locality
@dataclass
class Country:
slug: str
name: str
localities: dict[str, Locality]
@classmethod
def from_dict(cls, slug: str, data: dict):
country = cls(
slug=slug,
name=data["name"],
localities={}
)
country.localities = {locality_slug: Locality.from_dict(country, locality_slug, locality_data) for
locality_slug, locality_data in data["localities"].items()}
return country
def load_geo_json_data(self):
"""Load data for all cities"""
for locality in self.localities.values():
locality.load_geo_json_data()
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
from dataclasses import dataclass
from typing import Optional, List, Dict, Any, TYPE_CHECKING
from models.geo_json.geometry import GeoJSONGeometry
from models.geo_json.properties import GeoJSONProperties
if TYPE_CHECKING:
from models.circuit import Circuit
@dataclass
class GeoJSONFeature:
type: str
properties: GeoJSONProperties
bbox: Optional[List[float]]
geometry: GeoJSONGeometry
@classmethod
def from_dict(cls, circuit: 'Circuit', data: Dict[str, Any]) -> 'GeoJSONFeature':
return cls(
type=data['type'],
properties=GeoJSONProperties.from_dict(circuit, data['properties']),
geometry=GeoJSONGeometry.from_dict(data['geometry']),
bbox=data.get('bbox')
)
+27
View File
@@ -0,0 +1,27 @@
from dataclasses import dataclass, asdict
from typing import List, Dict, Any, TYPE_CHECKING, Optional
from models.geo_json.feature import GeoJSONFeature
if TYPE_CHECKING:
from models.circuit import Circuit
@dataclass
class GeoJSON:
type: str
bbox: Optional[list[float]]
features: List[GeoJSONFeature]
@classmethod
def from_dict(cls, circuit: 'Circuit', data: Dict[str, Any]) -> 'GeoJSON':
return cls(
type=data['type'],
bbox=data['bbox'] if 'bbox' in data else None,
features=[GeoJSONFeature.from_dict(circuit, f) for f in data['features']],
)
def to_dict(self) -> Dict[str, Any]:
"""Convert the GeoJSON object back to a dictionary"""
data = asdict(self)
# Remove None values
return {k: v for k, v in data.items() if v is not None}
+56
View File
@@ -0,0 +1,56 @@
from dataclasses import dataclass
from typing import List, Dict, Any
import numpy as np
@dataclass
class GeoJSONGeometry:
type: str
coordinates: List[tuple[float, float]]
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'GeoJSONGeometry':
return cls(
type=data['type'],
coordinates=data['coordinates']
)
def find_longest_straight(self, window_size=5) -> (List[float], List[float]):
"""Find the longest approximately straight section of the track, including wrap-around."""
max_distance = 0
best_start_idx = 0
n = len(self.coordinates)
# Helper function to check straightness
def is_straight(points, start, end, length):
direction = (end - start) / length
distances = []
for point in points:
projection = start + np.dot(point - start, direction) * direction
distance = np.linalg.norm(point - projection)
distances.append(distance)
return max(distances) < length * 0.05 # 5% tolerance
# Check all possible segments, including wrap-around
for i in range(n):
# Get window_size points, handling wrap-around
segment = []
for j in range(window_size):
idx = (i + j) % n
segment.append(np.array(self.coordinates[idx]))
start = np.array(segment[0])
end = np.array(segment[-1])
length = np.linalg.norm(end - start)
if length > max_distance:
# Check if all points are roughly on the line
if is_straight(segment, start, end, length):
max_distance = length
best_start_idx = i
# Return indices that might wrap around
end_idx = (best_start_idx + window_size) % len(self.coordinates)
return self.coordinates[best_start_idx], self.coordinates[end_idx]
+29
View File
@@ -0,0 +1,29 @@
from dataclasses import dataclass
from typing import Optional, Dict, Any, TYPE_CHECKING
if TYPE_CHECKING:
from models.circuit import Circuit
@dataclass
class GeoJSONProperties:
id: str
country: str
locality: str
circuit: str
length: Optional[int] = None
altitude: Optional[int] = None
rotation: Optional[float] = None
direction: Optional[str] = None
fastest_laptime: Optional[float] = None
@classmethod
def from_dict(cls, circuit: 'Circuit', data: Dict[str, Any]) -> 'GeoJSONProperties':
return cls(
id=data.get("id"),
country=circuit.locality.country.name,
locality=circuit.locality.name,
circuit=circuit.name,
**{k: v for k, v in data.items() if
k in cls.__annotations__ and k not in ['id', 'country', 'locality',
'circuit']}
)
+30
View File
@@ -0,0 +1,30 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .circuit import Circuit
if TYPE_CHECKING:
from .country import Country
@dataclass
class Locality:
slug: str
name: str
country: 'Country'
circuits: dict[str, Circuit]
@classmethod
def from_dict(cls, country: 'Country', slug: str, data: dict):
locality = cls(
slug=slug,
name=data["name"],
country=country,
circuits={}
)
locality.circuits = {circuit_slug: Circuit.from_dict(locality, circuit_slug, circuit_data) for
circuit_slug, circuit_data in data["circuits"].items()}
return locality
def load_geo_json_data(self):
"""Load data for all circuits"""
for circuit in self.circuits.values():
circuit.load_geo_json_data()
+18
View File
@@ -0,0 +1,18 @@
import json
from pathlib import Path
from models.country import Country
def load_circuits_json(file_path: Path, load_geo_data: bool=False) -> dict[str, Country]:
with open(file_path) as f:
return parse_circuits_json(json.load(f), load_geo_data)
def parse_circuits_json(data: dict, load_geo_data: bool=False) -> dict[str, Country]:
countries = {}
for country_slug, country_data in data.items():
countries[country_slug] = Country.from_dict(country_slug, country_data)
if load_geo_data:
countries[country_slug].load_geo_json_data()
return countries
+135
View File
@@ -0,0 +1,135 @@
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from models.geo_json.geo_json import GeoJSON
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models.circuit import Circuit
@dataclass
class TrackLayout:
slug: str
description: str
imageUrl: Optional[str]
circuit: 'Circuit'
_geo_data: Optional[GeoJSON] = None
@classmethod
def from_dict(cls, circuit: 'Circuit', slug: str, data: dict) -> 'TrackLayout':
return cls(
slug=slug,
description=data["description"],
imageUrl=data["imageUrl"],
circuit=circuit
)
@property
def coordinates(self) -> list[tuple[float, float]]:
return self._geo_data.features[0].geometry.coordinates
def _relative_filepath(self, extension: str) -> Path:
return Path(f"{self.circuit.locality.country.slug}/"
f"{self.circuit.locality.slug}/"
f"{self.circuit.slug}/"
f"{self.slug}.{extension}")
@property
def relative_geojson_filepath(self) -> Optional[Path]:
return self._relative_filepath("geo.json")
@property
def relative_svg_filepath(self) -> Optional[Path]:
return self._relative_filepath("svg")
@property
def relative_png_filepath(self) -> Optional[Path]:
return self._relative_filepath("png")
def load_geo_json_data(self):
"""Load GeoJSON data for this layout"""
file_path = Path(f"./circuits").joinpath(self.relative_geojson_filepath)
if not file_path.exists():
return None
try:
with open(file_path, 'r') as f:
data = json.load(f)
self._geo_data = GeoJSON.from_dict(self.circuit, data)
except Exception as e:
print(f"Error loading GeoJSON data: {e}")
return None
def save_svg(self, path: Path, default_width: int=150, aspect_ratio: float=1.6):
if not self.coordinates:
print(f"No coordinates available for {self.circuit.name}")
return
# Extract coordinates
lons, lats = zip(*self.coordinates)
# Calculate canvas dimensions based on provided parameters
svg_width = default_width
svg_height = int(svg_width / aspect_ratio) # Higher aspect ratio = wider rectangle
# Normalize coordinates to fit in SVG while maintaining proportions
min_lon, max_lon = min(lons), max(lons)
min_lat, max_lat = min(lats), max(lats)
# Add padding
lon_padding = (max_lon - min_lon) * 0.05
lat_padding = (max_lat - min_lat) * 0.05
min_lon -= lon_padding
max_lon += lon_padding
min_lat -= lat_padding
max_lat += lat_padding
# Calculate scaling factors for both dimensions
lon_scale = svg_width / (max_lon - min_lon)
lat_scale = svg_height / (max_lat - min_lat)
# Use the smaller scaling factor to ensure the track fits within bounds
scale = min(lon_scale, lat_scale)
# Calculate centering offsets
lon_offset = (svg_width - (max_lon - min_lon) * scale) / 2
lat_offset = (svg_height - (max_lat - min_lat) * scale) / 2
# Create SVG header
svg = [
f'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
f'<svg width="{svg_width}px" height="{svg_height}px" viewBox="0 0 {svg_width} {svg_height}" '
'xmlns="http://www.w3.org/2000/svg">',
f' <title>{self.circuit.name} - {self.circuit.locality.name}</title>'
]
# Create path data
path_data = []
for i, (lon, lat) in enumerate(self.coordinates):
# Scale and center the coordinates
x = ((lon - min_lon) * scale) + lon_offset
y = svg_height - ((lat - min_lat) * scale) - lat_offset
if i == 0:
path_data.append(f"M{x:.2f},{y:.2f}")
else:
path_data.append(f"L{x:.2f},{y:.2f}")
# Add path to SVG
svg.append(f' <path d="{" ".join(path_data)}" fill="none" stroke="black" stroke-width="2"/>')
svg.append('</svg>')
# Write SVG to file
svg_path = path.joinpath(self.relative_svg_filepath)
os.makedirs(os.path.dirname(svg_path), exist_ok=True)
with open(svg_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(svg))
print(f"SVG saved to: {self.relative_svg_filepath}")