Initial Vibe coded project

This commit is contained in:
Olaf committed 2026-10-20 14:18:35 +02:00
commit 0891cf12a5
21 files changed
+2830

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
import logging
import math
import os
import tempfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from xml.etree import ElementTree as ET
from shapely.affinity import affine_transform
from shapely.geometry import LineString, box
from shapely.geometry.base import BaseGeometry
from flight_map.curves import Cubic
from flight_map.geo import Projection
from flight_map.lettering import lettering
from flight_map.maps import map_data, map_lines
from flight_map.storage import database, get_session, load_observations
from flight_map.terrain import Elevation
from flight_map.tracks import make_tracks
LOGGER = logging.getLogger(__name__)
SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)
def tag(name: str) -> str:
return f"{{{SVG_NS}}}{name}"
def lines(geometry: BaseGeometry) -> Iterator[LineString]:
if isinstance(geometry, LineString):
if geometry.length > 0:
yield geometry
elif hasattr(geometry, "geoms"):
for part in geometry.geoms:
yield from lines(part)
def add_line(group: ET.Element, line: LineString) -> None:
coordinates = list(line.coords)
path = "M " + " L ".join(f"{x:.5f},{y:.5f}" for x, y in coordinates)
ET.SubElement(group, tag("path"), {"d": path})
def add_curves(group: ET.Element, curves: list[Cubic]) -> None:
commands = []
previous = None
for curve in curves:
if all(point == curve.start for point in curve.points):
continue
if previous is None or math.dist(previous, curve.start) > 1e-7:
commands.append(f"M {curve.start[0]:.5f},{curve.start[1]:.5f}")
commands.append(
"C "
+ " ".join(
f"{x:.5f},{y:.5f}"
for x, y in (
curve.control1,
curve.control2,
curve.end,
)
)
)
previous = curve.end
if commands:
ET.SubElement(group, tag("path"), {"d": " ".join(commands)})
def label(group: ET.Element, text: str, x: float, y: float, height: float) -> None:
for line in lettering(text, x, y, height):
add_line(group, line)
def utc(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S")
@dataclass(frozen=True)
class PlotOptions:
width: float = 297
height: float = 210
margin: float = 12
extension: float = 4
pen_width: float = 0.3
font_height: float = 1.8
max_gap: float = 120
def validate(self) -> None:
if not all(
math.isfinite(value) and value > 0
for value in (
self.width,
self.height,
self.margin,
self.extension,
self.pen_width,
self.font_height,
self.max_gap,
)
):
raise ValueError(
"Page dimensions, margins, pen width and plot settings must be positive."
)
if 2 * (self.margin + self.extension) >= min(self.width, self.height):
raise ValueError("Margins and extensions leave no room for the map.")
def render(
db: Path,
session_id: str,
output: Path,
*,
start: float | None = None,
end: float | None = None,
options: PlotOptions | None = None,
cache: Path = Path(".flight-map-cache"),
local_map: Path | None = None,
terrain: Path | None = None,
terrain_reference: str = "unknown",
geoid: Path | None = None,
confirm_geometric_ellipsoid: bool = False,
force: bool = False,
refresh_map: bool = False,
) -> int:
options = options or PlotOptions()
options.validate()
if output.exists() and not force:
raise ValueError(f"Output already exists: {output}. Use --force to replace it.")
if any(
output.resolve() == source.resolve()
for source in (db, local_map, terrain, geoid)
if source is not None
):
raise ValueError("SVG output must not overwrite the recording database or input data.")
with database(db, readonly=True) as connection:
session = get_session(connection, session_id)
start = session.started if start is None else start
end = session.ended if end is None else end
if (
not math.isfinite(start)
or not math.isfinite(end)
or start < session.started
or end > session.ended
or start >= end
):
raise ValueError("Time selection must be a nonempty interval within the recording.")
observations = load_observations(connection, session_id, start, end)
if not observations:
raise ValueError("No recorded observations in the selected interval.")
projection = Projection(session.bounds)
with Elevation(terrain, terrain_reference, geoid, confirm_geometric_ellipsoid) as elevation:
ground = elevation.mean_ground(session.bounds)
tracks = make_tracks(observations, projection, elevation, ground, options.max_gap)
if not tracks:
raise ValueError("No usable airborne tracks cross the map in the selected interval.")
if ground is None:
LOGGER.warning(
"Average ground elevation unavailable: supply a complete terrain raster "
"and its vertical reference."
)
missing = sum(track.average_height is None for track in tracks)
if missing:
LOGGER.warning(
"%d track height(s) unavailable: compatible geometric altitude, terrain "
"and vertical-reference conversion are required.",
missing,
)
data = map_data(session.bounds, cache, local_map, refresh=refresh_map)
row_height = options.font_height * 1.8
legend_height = (len(tracks) + 4) * row_height
available_width = options.width - 2 * (options.margin + options.extension)
available_height = options.height - 2 * (options.margin + options.extension) - legend_height
if available_height < 25:
raise ValueError("Too many tracks for this page. Select a shorter interval or larger page.")
west, south, east, north = projection.rectangle.bounds
scale = min(available_width / (east - west), available_height / (north - south))
features = map_lines(data, projection, min_building_size=3 * options.pen_width / scale)
map_width, map_height = (east - west) * scale, (north - south) * scale
left = (options.width - map_width) / 2
top = options.margin + options.extension
matrix = [scale, 0, 0, -scale, left - west * scale, top + north * scale]
map_rectangle = box(left, top, left + map_width, top + map_height)
track_rectangle = box(
left - options.extension,
top - options.extension,
left + map_width + options.extension,
top + map_height + options.extension,
)
root = ET.Element(
tag("svg"),
{
"width": f"{options.width:g}mm",
"height": f"{options.height:g}mm",
"viewBox": f"0 0 {options.width:g} {options.height:g}",
"fill": "none",
"stroke": "black",
"stroke-width": f"{options.pen_width:g}",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
)
ET.SubElement(root, tag("title")).text = "Recorded flight map"
ET.SubElement(root, tag("desc")).text = (
f"Session {session.id}; bounds W,S,E,N={session.bounds.values}; "
f"{utc(start)} to {utc(end)} UTC. Local equirectangular projection. "
"Height is time-weighted aircraft altitude within the map minus the "
"area-sampled mean ground elevation, not terrain clearance. "
f"Terrain reference: {terrain_reference}; "
f"mean ground elevation in metres: {ground}; "
f"geometric ellipsoid interpretation confirmed: {confirm_geometric_ellipsoid}. "
"Map data: OpenStreetMap contributors, ODbL, https://www.openstreetmap.org/copyright. "
"Flight data: The OpenSky Network, https://opensky-network.org. "
"Schaefer et al., Bringing Up OpenSky: A Large-scale ADS-B Sensor Network "
"for Research, IPSN 2014, pp. 83-94."
)
map_group = ET.SubElement(root, tag("g"), {"id": "map"})
flight_group = ET.SubElement(root, tag("g"), {"id": "flights"})
text_group = ET.SubElement(root, tag("g"), {"id": "lettering"})
add_line(map_group, LineString(map_rectangle.exterior.coords))
seen = set()
for feature in features:
page_feature = affine_transform(feature, matrix).intersection(map_rectangle)
for part in lines(page_feature):
simplified = part.simplify(0.05)
key = simplified.normalize().wkb
if key not in seen:
for simplified_part in lines(simplified):
add_line(map_group, simplified_part)
seen.add(key)
occupied: list[BaseGeometry] = []
for index, track in enumerate(tracks, 1):
page_curves = [
part
for curve in track.curves
for part in curve.transform(matrix).clipped(track_rectangle.bounds)
]
group = ET.SubElement(
flight_group,
tag("g"),
{
"id": f"flight-{index}",
"data-icao24": track.observations[0].icao24,
"data-callsign": track.observations[0].callsign or "unavailable",
"data-start-utc": utc(track.start),
"data-end-utc": utc(track.end),
"data-average-height-metres": (
f"{track.average_height:.3f}"
if track.average_height is not None
else "unavailable"
),
},
)
add_curves(group, page_curves)
short_label = str(index)
placed = False
for fraction in (0.9, 0.1, 0.7, 0.3, 0.5):
curve_index = min(int(fraction * len(page_curves)), len(page_curves) - 1)
anchor_x, anchor_y = page_curves[curve_index].point(
fraction * len(page_curves) - curve_index
)
for dx, dy in ((1, 1), (1, -3), (-4, 1), (-4, -3)):
x, y = anchor_x + dx, anchor_y + dy
extent = box(
x - 0.3,
y - 0.3,
x + len(short_label) * options.font_height,
y + options.font_height + 0.3,
)
if track_rectangle.covers(extent) and not any(
extent.intersects(previous) for previous in occupied
):
label(text_group, short_label, x, y, options.font_height)
occupied.append(extent)
placed = True
break
if placed:
break
if not placed:
raise ValueError("Cannot place track identifiers legibly; select fewer tracks.")
legend_y = top + map_height + options.extension + row_height
ground_label = "N/A" if ground is None else f"{ground:.1f} M"
legend = [
"FLIGHT TIMES UTC - HEIGHT ABOVE MAP AVERAGE GROUND, NOT TERRAIN CLEARANCE",
f"MAP AVERAGE GROUND ELEVATION: {ground_label} - N/A MEANS UNAVAILABLE",
]
for index, track in enumerate(tracks, 1):
first = track.observations[0]
height_label = "N/A" if track.average_height is None else f"{track.average_height:.0f} M"
legend.append(
f"{index}: {utc(track.start)} TO {utc(track.end)} "
f"{first.callsign or 'CALLSIGN N/A'} ({first.icao24.upper()}) HEIGHT {height_label}"
)
legend.extend(
[
"MAP: OPENSTREETMAP CONTRIBUTORS (ODBL) - OPENSTREETMAP.ORG/COPYRIGHT",
"FLIGHTS: THE OPENSKY NETWORK - OPENSKY-NETWORK.ORG",
]
)
for index, text in enumerate(legend):
if len(text) * options.font_height > options.width - 2 * options.margin:
raise ValueError("Legend is too wide; reduce --font-height or increase page width.")
label(text_group, text, options.margin, legend_y + index * row_height, options.font_height)
output.parent.mkdir(parents=True, exist_ok=True)
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".svg", delete=False) as stream:
temp_path = Path(stream.name)
ET.ElementTree(root).write(stream, encoding="utf-8", xml_declaration=True)
os.replace(temp_path, output)
finally:
if temp_path and temp_path.exists():
temp_path.unlink()
return len(tracks)