Numbers outside of the map [still cluttered]
This commit is contained in:
1 parent
b692f94122
commit
e39de24921
5 files changed
+274
-45
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user