Mark the aerodromes in the window, and draw the lot on a vector display

The aerodromes were never on the window at all: only the animation drew
them, and the window's map had whatever airport glyphs the tiles happened to
carry.  They are drawn there now, in the same colour and the same square.

The first attempt at that had a bug worth naming, because it would have
looked like the feature simply not working.  They were fetched inside the
same pass of the fetching loop as a piece of map, so they queued behind a
hundred and twenty tiles coming off a network -- and once the map was in
hand there were no more passes, so they were never fetched again.  They are
their own question now, asked on the same thread but not behind the tiles,
and they arrive whether the tiles do or not.  An area that has been asked
about and has none in it is remembered as none, rather than asked about
again five times a second for the rest of the night.  And the thread now
starts if either the map or the aerodromes are wanted, so --no-basemap no
longer quietly takes the airports with it.

Then the themes, which change the window and the animated pictures together
because both read their colours out of the same palette.  night is what this
program has always drawn and is untouched.  digital, phosphor, amber and red
are the screens the phrase "air defence display" actually calls to mind: a
black tube, one phosphor, and thin bright vector lines with a halo round
them.

Three things follow from having one colour to spend, and they are
constraints rather than decoration.  Height becomes brightness, since hue is
no longer free -- low is dim and high burns, which is the trade those
displays made.  The map underneath drops to about a quarter of the
brightness asked for, because a tinted photograph of a county behind the
vectors is the one thing that stops a vector display looking like one.  And
a country is named in two letters rather than drawn as a flag, a flag being
half a dozen colours.

The glow is done twice, differently, because the two are different kinds of
picture.  The window lays each line down two or three times, wider and
fainter each pass, with the core last: trails, symbols, leader lines, box
borders and the aerodrome squares.  The animation cannot blend at all, a GIF
being indexed colour, so it dilates what it has drawn and fills the halo
with the dimmed copy of the colour underneath -- and the aircraft colours
already had dimmed copies, since those are the trail shades, so an aeroplane
glows into the colour its own trail is drawn in, which is the colour a
phosphor would have spread into.  The fixed colours get two rings each in
the palette for the purpose.  The halo goes over the map, the grid and the
background and over nothing else that was drawn, since a halo is what light
does to the dark around a line; where two rings meet the nearer wins.  It
costs about 55 ms a frame at 1400 by 1258 and the default theme skips the
pass entirely.

The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to that array and a
new one would leave half the program painting in the colours of the theme
before.  There is a test that every theme keeps the aerodrome colour more
than forty units of CIELAB from every altitude colour, stated as the
distance rather than as the colour, so that a new theme cannot quietly walk
an aircraft back into the airports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-05 00:39:20 -07:00
parent f9b21f7e35
commit 2e20c48971
11 changed files with 1342 additions and 50 deletions

View file

@ -1356,3 +1356,292 @@ def test_the_category_reaches_the_window_from_the_air():
craft = Aircraft(icao="AE07D3", callsign="PRIME04", latitude=32.5,
longitude=-111.0, category="heavy")
assert blip_for(craft).category == "heavy"
# ---------------------------------------------------------------------------
# The aerodromes under the window
# ---------------------------------------------------------------------------
TUCSON_AIRPORTS = [("KTUS", 32.116, -110.941), ("KDMA", 32.166, -110.883),
("KAVQ", 32.409, -111.218)]
def test_a_sky_does_not_reach_for_airports_unless_it_is_asked_to():
"""A question to a network the first time an area is drawn, so the
program turns it on and a library user has to say so on purpose."""
assert a_sky().show_airports is False
assert Sky(airports=True).show_airports is True
def test_the_airports_come_back_when_what_was_fetched_covers_the_view():
sky = a_sky()
sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == TUCSON_AIRPORTS
def test_a_view_outside_what_was_fetched_asks_again():
"""None, not an empty list: nothing has been asked about this piece of
world, which is a different thing from having asked and found none."""
sky = a_sky()
sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(40.0, -112.0, 41.0, -110.0) is None
assert a_sky().airports_covering(31.5, -111.5, 32.5, -110.5) is None
def test_an_area_with_no_aerodromes_is_not_asked_about_all_night():
"""An empty answer is an answer. Kept as an empty list, so that the
difference between "there are none" and "nobody has looked" survives."""
sky = a_sky()
sky.want_airports((31.0, -112.0, 33.0, -110.0))
sky.set_airports([], (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == []
assert sky.wanted_airports() is None
def test_the_aerodromes_do_not_wait_behind_the_tiles():
"""The bug this is here for. They used to be fetched only in the same
pass as a piece of map, so they queued behind a hundred and twenty tiles
coming off a network -- and once the map was in hand there were no more
passes and they were never fetched at all."""
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
slow = threading.Event()
def tiles(*a, **kw): # a map that never arrives
slow.wait(10.0)
return None
basemap.airports_in = lambda s, w, n, e, **kw: [
{"code": c, "latitude": la, "longitude": lo}
for c, la, lo in TUCSON_AIRPORTS]
try:
# A map is asked for too, and the fetching of it hangs.
sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": tiles}, daemon=True)
worker.start()
for _ in range(60):
if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
break
time.sleep(0.05)
got = sky.airports_covering(32.0, -112.0, 33.0, -110.0)
slow.set()
sky.stopping = True
worker.join(timeout=3.0)
finally:
basemap.airports_in = real
slow.set()
assert got == TUCSON_AIRPORTS, "they waited for a map that never came"
def test_the_airports_are_fetched_off_the_painting_thread():
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
basemap.airports_in = lambda s, w, n, e, **kw: [
{"code": c, "latitude": la, "longitude": lo}
for c, la, lo in TUCSON_AIRPORTS]
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
for _ in range(60):
if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
break
time.sleep(0.05)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == TUCSON_AIRPORTS
def test_an_area_that_could_not_be_asked_about_is_not_asked_about_again():
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
tries = []
def broken(*a, **kw):
tries.append(1)
raise OSError("no network tonight")
basemap.airports_in = broken
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
time.sleep(0.6)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert len(tries) == 1, f"asked {len(tries)} times after failing once"
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == []
def test_no_airports_are_fetched_when_they_are_turned_off():
import threading
from bandsaunter import basemap
sky = a_sky() # show_airports is False
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
def refuse(*a, **kw):
raise AssertionError("asked for airports with them turned off")
basemap.airports_in = refuse
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
time.sleep(0.5)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) is None
def _airport_pixels(picture) -> int:
"""Pixels in the aerodrome colour, which nothing else on the window is."""
from bandsaunter.flightmap import AIRPORT, PALETTE
want = PALETTE[AIRPORT]
return int(((picture[:, :, 2] == want[0]) & (picture[:, :, 1] == want[1])
& (picture[:, :, 0] == want[2])).sum())
@qt
def test_the_window_marks_the_aerodromes_under_it(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
bare = _rendered(view)
assert _airport_pixels(bare) == 0, "something else is already that colour"
projection = view.projection()
sky.set_airports(TUCSON_AIRPORTS,
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
marked = _rendered(view)
assert _airport_pixels(marked) > 60, "no aerodrome was drawn"
@qt
def test_the_window_draws_no_aerodromes_when_they_are_turned_off(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip()) # show_airports is False
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
sky.set_airports(TUCSON_AIRPORTS,
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
assert _airport_pixels(_rendered(view)) == 0
@qt
def test_an_aerodrome_outside_the_view_is_nowhere_on_the_picture(app):
"""What is fetched covers rather more world than is shown, so on a
zoomed-in view most of the county's airports are outside it.
Projecting one gives coordinates off the widget and Qt clips them, so
this holds whether or not they are skipped first; it is here to catch
anyone who later clamps a position into the view instead, which would
pile every airport in the county along the border of the picture.
"""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
box = (projection.south - 5, projection.west - 5,
projection.north + 5, projection.east + 5)
sky.set_airports([("KJFK", projection.north + 3, projection.east + 3)], box)
assert _airport_pixels(_rendered(view)) == 0
# ---------------------------------------------------------------------------
# The window follows the theme
# ---------------------------------------------------------------------------
@qt
def test_the_window_draws_in_whatever_theme_is_set(app):
"""Both drawings read the same palette, so setting a theme changes the
window as well as the animation and there is nothing to keep in step."""
from bandsaunter import flightmap as fm
from bandsaunter.livemap import SkyView
def colours(theme):
fm.set_theme(theme)
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
sky.set_airports([("KTUS", projection.south + 0.1,
projection.west + 0.1)],
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
picture = _rendered(view)
return {tuple(int(v) for v in rgb) for rgb in
picture[:, :, [2, 1, 0]].reshape(-1, 3)}
try:
night = colours("night")
green = colours("phosphor")
finally:
fm.set_theme("night")
assert (255, 64, 200) in night, "the night aerodrome colour is missing"
assert (255, 64, 200) not in green, "a magenta aerodrome on a green screen"
assert (255, 184, 72) in green, "the phosphor aerodrome colour is missing"
@qt
def test_a_vector_theme_lays_a_halo_round_what_it_draws(app):
"""The window does its glow by laying the same line down two or three
times, wider and fainter each pass. So a themed window paints more
distinct colours than a plain one drawing the same aircraft."""
from bandsaunter import flightmap as fm
from bandsaunter.livemap import SkyView
def shades(theme):
fm.set_theme(theme)
sky = a_sky(a_blip())
view = SkyView(sky)
view.show_ground = False
picture = _rendered(view)
return len({tuple(int(v) for v in rgb) for rgb in
picture[:, :, [2, 1, 0]].reshape(-1, 3)})
try:
plain = shades("night")
glowing = shades("phosphor")
finally:
fm.set_theme("night")
assert glowing > plain, f"{glowing} shades is no more than {plain}"