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)