From e39de2492148262d8c3832b7b093f7c04950988a Mon Sep 17 00:00:00 2001 From: Olaf Date: Wed, 21 Oct 2026 10:51:50 +0200 Subject: [PATCH] Numbers outside of the map [still cluttered] --- README.md | 13 ++- REQUIREMENTS.md | 13 +++ flight_map/plot.py | 155 +++++++++++++++++++++++++--------- tests/test_flight_map.py | 65 +++++++++++++- tests/test_outside_indices.py | 73 ++++++++++++++++ 5 files changed, 274 insertions(+), 45 deletions(-) create mode 100644 tests/test_outside_indices.py diff --git a/README.md b/README.md index fd2577b..6b71164 100644 --- a/README.md +++ b/README.md @@ -249,10 +249,19 @@ physically clipped, not hidden with masks. Groups `map`, `flights`, and with an original single-stroke alphabet: no installed fonts, raster images, filled glyphs, or SVG clipping support are required. -Each track has a numeric identifier and a legend entry with UTC dates/times, -callsign, ICAO identifier, and average reported barometric altitude. For dense recordings, select +Each track has a numeric identifier and a legend entry with a single UTC date +and time at the midpoint between its in-map start and end times, callsign, +ICAO identifier, and average reported barometric altitude labeled `Alt`. +Flight entries occupy two balanced columns, reading down the left column +and then down the right. Full start/end times and the barometric altitude +source remain in SVG metadata. For dense recordings, select shorter periods or larger paper. The program fails explicitly if the legend does not fit or a track identifier cannot be placed legibly. Flight identifiers +are placed entirely outside the map border in the extension margin, near an +entry/exit point, with a vector leader connecting them to the track. Tracks +entirely inside the map also use margin labels with leaders. Identifiers never +fall back to positions inside the map; increase `--extension` or select fewer +tracks if margin space is insufficient. Flight identifiers avoid each other; intersections with map geometry or flight paths are not fully optimized. Map lines are simplified at 0.05 mm tolerance and identical paths deduplicated; physical pen-path travel optimization is left to plotter software. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 1a02e62..a82b984 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -119,8 +119,21 @@ a physical pen plotter. recorded positions from the surrounding area, not fabricated extrapolation. - Keep the map background within its requested bounds and provide space in the SVG for the extended tracks and their labels. +- Place flight index numbers entirely outside the map border, within the + extension margin, near a track entry or exit point. Use vector leader lines + to associate labels with their tracks, including tracks entirely inside the + map. Avoid overlapping index labels and report insufficient margin space + rather than placing numbers inside the map. - Identify each flight track with its date, time, callsign or flight number when available, and average reported barometric (pressure) altitude in metres. +- In the printed flight legend, show the date and time once per flight, using + the midpoint between the track's in-map start and end times in UTC. Label + altitude as "Alt", without "Baro". Retain full start/end times and altitude + source in SVG metadata. +- Arrange flight legend entries in two balanced columns, reading down the left + column followed by the right, with shared headings and attribution spanning + the page. Reserve space based on the column row count and reject entries + that do not fit rather than overlapping or truncating them. - Do not present an OpenSky callsign as a verified passenger-facing flight number. Callsign-to-flight-number lookup is not required for the initial version. diff --git a/flight_map/plot.py b/flight_map/plot.py index 95c5211..066e119 100644 --- a/flight_map/plot.py +++ b/flight_map/plot.py @@ -74,6 +74,68 @@ def label(group: ET.Element, text: str, x: float, y: float, height: float) -> No add_line(group, line) +def outside_index( + group: ET.Element, + text: str, + curves: list[Cubic], + map_rectangle: BaseGeometry, + outer_rectangle: BaseGeometry, + occupied: list[BaseGeometry], + height: float, + pen_width: float, +) -> None: + west, top, east, bottom = map_rectangle.bounds + width = (len(text) - 1 + 2 / 3) * height + padding = pen_width / 2 + 0.2 + anchors: list[tuple[float, float]] = [] + for curve in curves: + for first, last in curve.inside_intervals(map_rectangle.bounds): + anchors.extend((curve.point(first), curve.point(last))) + candidates = [] + for ax, ay in anchors: + candidates.extend( + [ + (abs(ax - west), "left", ax, ay), + (abs(ax - east), "right", ax, ay), + (abs(ay - top), "top", ax, ay), + (abs(ay - bottom), "bottom", ax, ay), + ] + ) + for _, side, ax, ay in sorted(candidates): + for step in (0, 1, -1, 2, -2, 3, -3): + shift = step * (height + 2 * padding) + if side == "left": + x, y = west - width - 2 * padding, ay - height / 2 + shift + endpoint = (x + width + padding, y + height / 2) + elif side == "right": + x, y = east + 2 * padding, ay - height / 2 + shift + endpoint = (x - padding, y + height / 2) + elif side == "top": + x, y = ax - width / 2 + shift, top - height - 2 * padding + endpoint = (x + width / 2, y + height + padding) + else: + x, y = ax - width / 2 + shift, bottom + 2 * padding + endpoint = (x + width / 2, y - padding) + extent = box(x - padding, y - padding, x + width + padding, y + height + padding) + leader = LineString([(ax, ay), endpoint]) + if ( + outer_rectangle.covers(extent) + and extent.disjoint(map_rectangle) + and not any( + extent.intersects(previous) or leader.intersects(previous) + for previous in occupied + ) + ): + add_line(group, leader) + label(group, text, x, y, height) + occupied.append(extent) + return + raise ValueError( + "Cannot place track identifiers outside the map; select fewer tracks " + "or increase --extension." + ) + + def utc(timestamp: float) -> str: return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S") @@ -195,7 +257,8 @@ def render( ) row_height = options.font_height * 1.8 terrain_attribution = terrain_source.startswith("PDOK") - legend_height = (len(tracks) + 5 + int(terrain_attribution)) * row_height + flight_rows = (len(tracks) + 1) // 2 + legend_height = (flight_rows + 5 + int(terrain_attribution)) * 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: @@ -284,60 +347,68 @@ def render( }, ) 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.") + outside_index( + text_group, + str(index), + page_curves, + map_rectangle, + track_rectangle, + occupied, + options.font_height, + options.pen_width, + ) 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 - REPORTED BAROMETRIC ALTITUDE, NOT HEIGHT ABOVE GROUND", + "FLIGHT MIDPOINT TIMES UTC - ALT IS REPORTED PRESSURE ALTITUDE, NOT HEIGHT ABOVE GROUND", f"MAP AVERAGE LAND ELEVATION: {ground_label} - N/A MEANS UNAVAILABLE", f"LAND ELEVATION COVERAGE: {100 * land_coverage:.1f} PERCENT - MAPPED WATER EXCLUDED", ] - for index, track in enumerate(tracks, 1): + legend_width = options.width - 2 * options.margin + for index, text in enumerate(legend): + if len(text) * options.font_height > legend_width: + 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) + column_gap = 2 * options.font_height + column_width = (legend_width - column_gap) / 2 + for offset, track in enumerate(tracks): first = track.observations[0] altitude_label = ( "N/A" if track.average_altitude is None else f"{track.average_altitude:.0f} M" ) - legend.append( - f"{index}: {utc(track.start)} TO {utc(track.end)} " - f"{first.callsign or 'CALLSIGN N/A'} ({first.icao24.upper()}) BARO ALT {altitude_label}" + text = ( + f"{offset + 1}: {utc((track.start + track.end) / 2)} " + f"{first.callsign or 'CALLSIGN N/A'} ({first.icao24.upper()}) Alt {altitude_label}" ) - legend.extend( - [ - "MAP: OPENSTREETMAP CONTRIBUTORS (ODBL) - OPENSTREETMAP.ORG/COPYRIGHT", - "FLIGHTS: THE OPENSKY NETWORK - OPENSKY-NETWORK.ORG", - ] - ) + if len(text) * options.font_height > column_width: + raise ValueError( + "Flight legend entry is too wide for two columns; reduce --font-height " + "or increase page width." + ) + column, row = divmod(offset, flight_rows) + label( + text_group, + text, + options.margin + column * (column_width + column_gap), + legend_y + (len(legend) + row) * row_height, + options.font_height, + ) + attribution = [ + "MAP: OPENSTREETMAP CONTRIBUTORS (ODBL) - OPENSTREETMAP.ORG/COPYRIGHT", + "FLIGHTS: THE OPENSKY NETWORK - OPENSKY-NETWORK.ORG", + ] if terrain_attribution: - legend.append("TERRAIN: AHN DTM VIA PDOK (CC0) - ELEVATIONS RELATIVE TO NAP") - for index, text in enumerate(legend): - if len(text) * options.font_height > options.width - 2 * options.margin: + attribution.append("TERRAIN: AHN DTM VIA PDOK (CC0) - ELEVATIONS RELATIVE TO NAP") + for index, text in enumerate(attribution): + if len(text) * options.font_height > legend_width: 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) + label( + text_group, + text, + options.margin, + legend_y + (len(legend) + flight_rows + index) * row_height, + options.font_height, + ) output.parent.mkdir(parents=True, exist_ok=True) temp_path: Path | None = None try: diff --git a/tests/test_flight_map.py b/tests/test_flight_map.py index 74aba6c..bdb3785 100644 --- a/tests/test_flight_map.py +++ b/tests/test_flight_map.py @@ -18,7 +18,8 @@ from flight_map.geo import DEFAULT_BOUNDS, Bounds, Projection from flight_map.lettering import GLYPHS, lettering from flight_map.maps import map_data, map_lines from flight_map.network import request_json -from flight_map.plot import SVG_NS, PlotOptions, render +from flight_map.plot import SVG_NS, PlotOptions, render, utc +from flight_map.plot import label as draw_label from flight_map.record import OpenSky, collect from flight_map.storage import ( Observation, @@ -334,6 +335,68 @@ def test_invalid_plot_time_and_empty_data(recorded: tuple[Path, Session], tmp_pa ) +@pytest.mark.parametrize("count", [1, 2, 3, 4]) +def test_printed_flight_legend_midpoint_and_two_columns( + recorded: tuple[Path, Session], + local_map: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + count: int, +) -> None: + path, session = recorded + with database(path) as connection: + original = load_observations(connection, session.id, T0, T0 + 90) + for index in range(1, count): + save_observations( + connection, + session, + [ + replace(item, icao24=f"{index:06x}", callsign=f"TEST{index}") + for item in original + ], + T0 + 90, + ) + expected_tracks = make_tracks( + load_observations(connection, session.id, T0, T0 + 90), + Projection(session.bounds), + 120, + ) + captured = Mock(wraps=draw_label) + monkeypatch.setattr("flight_map.plot.label", captured) + output = tmp_path / "legend.svg" + options = PlotOptions() + assert ( + render(path, session.id, output, local_map=local_map, no_terrain=True, options=options) + == count + ) + entries = [call for call in captured.call_args_list if re.match(r"^\d+: ", call.args[1])] + assert len(entries) == count + root = ET.parse(output).getroot() + rows = (count + 1) // 2 + legend_width = options.width - 2 * options.margin + column_gap = 2 * options.font_height + column_width = (legend_width - column_gap) / 2 + for index, call in enumerate(entries): + text, x, y, height = call.args[1:] + flight = root.find(f".//{{{SVG_NS}}}g[@id='flight-{index + 1}']") + assert flight is not None + track = expected_tracks[index] + assert utc((track.start + track.end) / 2) in text + assert flight.attrib["data-start-utc"] == utc(track.start) + assert flight.attrib["data-end-utc"] == utc(track.end) + assert len(re.findall(r"\d{4}-\d{2}-\d{2}", text)) == 1 + assert len(re.findall(r"\d{2}:\d{2}:\d{2}", text)) == 1 + assert " Alt 900 M" in text and "BARO" not in text.upper() and " TO " not in text + column, row = divmod(index, rows) + assert x == pytest.approx(options.margin + column * (column_width + column_gap)) + assert y == pytest.approx(entries[0].args[3] + row * height * 1.8) + assert len(text) * height <= column_width + assert x + len(text) * height <= options.width - options.margin + assert y + height <= options.height - options.margin + if count >= 2: + assert entries[rows].args[2] > entries[0].args[2] + + def test_map_cache_and_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: payload = { "elements": [ diff --git a/tests/test_outside_indices.py b/tests/test_outside_indices.py new file mode 100644 index 0000000..bee5494 --- /dev/null +++ b/tests/test_outside_indices.py @@ -0,0 +1,73 @@ +from unittest.mock import Mock +from xml.etree import ElementTree as ET + +import pytest +from shapely.geometry import box +from shapely.geometry.base import BaseGeometry + +from flight_map.curves import interpolate +from flight_map.plot import SVG_NS, outside_index + + +@pytest.mark.parametrize( + "points", + [ + [(0, 50), (100, 50)], + [(50, 0), (50, 100)], + [(20, 30), (60, 40)], + [(0, 0), (100, 100)], + ], +) +@pytest.mark.parametrize("text", ["1", "12", "123"]) +def test_indices_are_outside_map_and_inside_page( + points: list[tuple[float, float]], + text: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + group = ET.Element("g") + map_rectangle = box(10, 10, 90, 90) + outer_rectangle = box(4, 4, 96, 96) + occupied: list[BaseGeometry] = [] + draw = Mock() + monkeypatch.setattr("flight_map.plot.label", draw) + outside_index( + group, text, interpolate(points), map_rectangle, outer_rectangle, occupied, 1.8, 0.3 + ) + assert len(occupied) == 1 + assert occupied[0].disjoint(map_rectangle) + assert outer_rectangle.covers(occupied[0]) + draw.assert_called_once() + assert group.find(f"{{{SVG_NS}}}path") is not None # Vector leader line. + + +def test_nearby_indices_do_not_overlap(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("flight_map.plot.label", Mock()) + occupied: list[BaseGeometry] = [] + curves = interpolate([(0, 50), (100, 50)]) + for index in range(1, 7): + outside_index( + ET.Element("g"), + str(index), + curves, + box(10, 10, 90, 90), + box(6, 6, 94, 94), + occupied, + 1.8, + 0.3, + ) + for index, extent in enumerate(occupied): + assert all(extent.disjoint(other) for other in occupied[index + 1 :]) + + +def test_too_narrow_margin_fails_instead_of_putting_index_inside() -> None: + with pytest.raises(ValueError, match="outside the map"): + outside_index( + ET.Element("g"), + "12", + interpolate([(0, 50), (100, 50)]), + box(10, 10, 90, 90), + box(9.9, 9.9, 90.1, 90.1), + [], + 1.8, + 0.3, + )