Add all local circuit work — year-by-year SVGs and tooling
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
|
||||
@@ -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']}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user