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,
|
with an original single-stroke alphabet: no installed fonts, raster images,
|
||||||
filled glyphs, or SVG clipping support are required.
|
filled glyphs, or SVG clipping support are required.
|
||||||
|
|
||||||
Each track has a numeric identifier and a legend entry with UTC dates/times,
|
Each track has a numeric identifier and a legend entry with a single UTC date
|
||||||
callsign, ICAO identifier, and average reported barometric altitude. For dense recordings, select
|
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
|
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
|
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
|
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
|
optimized. Map lines are simplified at 0.05 mm tolerance and identical paths
|
||||||
deduplicated; physical pen-path travel optimization is left to plotter software.
|
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.
|
recorded positions from the surrounding area, not fabricated extrapolation.
|
||||||
- Keep the map background within its requested bounds and provide space in the
|
- Keep the map background within its requested bounds and provide space in the
|
||||||
SVG for the extended tracks and their labels.
|
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
|
- Identify each flight track with its date, time, callsign or flight number when
|
||||||
available, and average reported barometric (pressure) altitude in metres.
|
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
|
- Do not present an OpenSky callsign as a verified passenger-facing flight
|
||||||
number. Callsign-to-flight-number lookup is not required for the initial
|
number. Callsign-to-flight-number lookup is not required for the initial
|
||||||
version.
|
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)
|
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:
|
def utc(timestamp: float) -> str:
|
||||||
return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S")
|
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
|
row_height = options.font_height * 1.8
|
||||||
terrain_attribution = terrain_source.startswith("PDOK")
|
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_width = options.width - 2 * (options.margin + options.extension)
|
||||||
available_height = options.height - 2 * (options.margin + options.extension) - legend_height
|
available_height = options.height - 2 * (options.margin + options.extension) - legend_height
|
||||||
if available_height < 25:
|
if available_height < 25:
|
||||||
@@ -284,60 +347,68 @@ def render(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
add_curves(group, page_curves)
|
add_curves(group, page_curves)
|
||||||
short_label = str(index)
|
outside_index(
|
||||||
placed = False
|
text_group,
|
||||||
for fraction in (0.9, 0.1, 0.7, 0.3, 0.5):
|
str(index),
|
||||||
curve_index = min(int(fraction * len(page_curves)), len(page_curves) - 1)
|
page_curves,
|
||||||
anchor_x, anchor_y = page_curves[curve_index].point(
|
map_rectangle,
|
||||||
fraction * len(page_curves) - curve_index
|
track_rectangle,
|
||||||
)
|
occupied,
|
||||||
for dx, dy in ((1, 1), (1, -3), (-4, 1), (-4, -3)):
|
options.font_height,
|
||||||
x, y = anchor_x + dx, anchor_y + dy
|
options.pen_width,
|
||||||
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
|
legend_y = top + map_height + options.extension + row_height
|
||||||
ground_label = "N/A" if ground is None else f"{ground:.1f} M"
|
ground_label = "N/A" if ground is None else f"{ground:.1f} M"
|
||||||
legend = [
|
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"MAP AVERAGE LAND ELEVATION: {ground_label} - N/A MEANS UNAVAILABLE",
|
||||||
f"LAND ELEVATION COVERAGE: {100 * land_coverage:.1f} PERCENT - MAPPED WATER EXCLUDED",
|
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]
|
first = track.observations[0]
|
||||||
altitude_label = (
|
altitude_label = (
|
||||||
"N/A" if track.average_altitude is None else f"{track.average_altitude:.0f} M"
|
"N/A" if track.average_altitude is None else f"{track.average_altitude:.0f} M"
|
||||||
)
|
)
|
||||||
legend.append(
|
text = (
|
||||||
f"{index}: {utc(track.start)} TO {utc(track.end)} "
|
f"{offset + 1}: {utc((track.start + track.end) / 2)} "
|
||||||
f"{first.callsign or 'CALLSIGN N/A'} ({first.icao24.upper()}) BARO ALT {altitude_label}"
|
f"{first.callsign or 'CALLSIGN N/A'} ({first.icao24.upper()}) Alt {altitude_label}"
|
||||||
)
|
)
|
||||||
legend.extend(
|
if len(text) * options.font_height > column_width:
|
||||||
[
|
raise ValueError(
|
||||||
"MAP: OPENSTREETMAP CONTRIBUTORS (ODBL) - OPENSTREETMAP.ORG/COPYRIGHT",
|
"Flight legend entry is too wide for two columns; reduce --font-height "
|
||||||
"FLIGHTS: THE OPENSKY NETWORK - OPENSKY-NETWORK.ORG",
|
"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:
|
if terrain_attribution:
|
||||||
legend.append("TERRAIN: AHN DTM VIA PDOK (CC0) - ELEVATIONS RELATIVE TO NAP")
|
attribution.append("TERRAIN: AHN DTM VIA PDOK (CC0) - ELEVATIONS RELATIVE TO NAP")
|
||||||
for index, text in enumerate(legend):
|
for index, text in enumerate(attribution):
|
||||||
if len(text) * options.font_height > options.width - 2 * options.margin:
|
if len(text) * options.font_height > legend_width:
|
||||||
raise ValueError("Legend is too wide; reduce --font-height or increase page 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)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temp_path: Path | None = None
|
temp_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ from flight_map.geo import DEFAULT_BOUNDS, Bounds, Projection
|
|||||||
from flight_map.lettering import GLYPHS, lettering
|
from flight_map.lettering import GLYPHS, lettering
|
||||||
from flight_map.maps import map_data, map_lines
|
from flight_map.maps import map_data, map_lines
|
||||||
from flight_map.network import request_json
|
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.record import OpenSky, collect
|
||||||
from flight_map.storage import (
|
from flight_map.storage import (
|
||||||
Observation,
|
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:
|
def test_map_cache_and_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"elements": [
|
"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