Numbers outside of the map [still cluttered]

This commit is contained in:
Olaf committed 2026-10-21 10:51:50 +02:00
1 parent b692f94122
commit e39de24921
5 files changed
+274 -45

No files matched your search

+11 -2
View File
@@ -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.
+13
View File
@@ -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.
+113 -42
View File
@@ -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:
+64 -1
View File
@@ -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": [
+73
View File
@@ -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,
)