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

@ -1042,6 +1042,13 @@ brightness or the map itself has. Doing it every frame put a ceiling of a
dozen frames a second on the window at 1920×1080 and spent a whole core dozen frames a second on the window at 1920×1080 and spent a whole core
holding it there; keeping it takes the same window from 87 ms a frame to 14. holding it there; keeping it takes the same window from 87 ms a frame to 14.
**The aerodromes are marked here too**, in the same colour and with the same
square as the animation draws them. They are asked for once per area, on the
thread that fetches the tiles but not behind them — they used to be fetched
only in the same pass as a piece of map, which meant 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.
`d` cycles the detail — full box, just height and speed, or symbols alone — `d` cycles the detail — full box, just height and speed, or symbols alone —
for when the sky is busy. `t` toggles trails, `g` the map underneath, `[`/`]` for when the sky is busy. `t` toggles trails, `g` the map underneath, `[`/`]`
its brightness, `+`/`-` the range, `q` closes it. its brightness, `+`/`-` the range, `q` closes it.
@ -1490,6 +1497,64 @@ thing on the picture and light enough that a coastline can be made out at all,
and which way to err depends on the screen you are looking at. In the window, and which way to err depends on the screen you are looking at. In the window,
`[` and `]` change it while it runs. `[` and `]` change it while it runs.
## Themes
`--theme NAME`, on `bandsaunter adsb` and `bandsaunter flights` alike, and in
the ADS-B options menu. It changes the window and the animated pictures
together, because both read their colours out of the same palette.
| theme | |
| --- | --- |
| `night` | the default: a night-blue ground, height as colour |
| `digital` | blue phosphor, cyan vectors on black, amber aerodromes |
| `phosphor` | green P1 phosphor, height as brightness |
| `amber` | amber phosphor, warm vectors on black |
| `red` | red phosphor, for a room that wants its night vision |
The four vector themes are the screens the phrase "air defence display"
actually calls to mind: a black tube, one phosphor, and thin bright lines
with a halo around them. Three things follow from that, and they are
constraints rather than decoration.
**Height becomes brightness.** The default map spends the whole spectrum on
altitude — low warm, high cold — which is why nothing else on it can be amber
or green. A phosphor screen has one colour, so on those themes low is dim and
high burns. That is the same trade the real displays made.
**The ground goes well back.** A tinted photograph of a county behind the
vectors is the one thing that stops a vector display looking like one, so the
map underneath is drawn at about two-fifths of the brightness asked for and
the lines carry the picture. `--map-brightness` still moves it.
**Countries are named, not flown.** A flag is half a dozen colours and a
phosphor has one, so those themes write the two letters instead — which is
what a display of the period would have done anyway.
### The glow
A vector display draws by holding a beam on the phosphor, and the phosphor
spreads the light a little and keeps glowing after the beam has gone. So a
line on one of those screens is not one pixel wide with a hard edge; it is a
bright core inside a halo. Both drawings do that, by different means, because
they are different kinds of picture:
- **the window** lays the same line down two or three times, wider and fainter
each pass, and then the core on top — trails, aircraft, leader lines, box
borders and the aerodrome squares;
- **the animation** cannot blend at all, because a GIF is indexed colour. So
it dilates what it has drawn and fills the halo with the *dimmed copy* of
the colour underneath it. The aircraft colours already have dimmed copies —
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 have two rings each added to the palette for the purpose.
The halo goes over the map, the grid and the empty background and nothing
else: a halo is what light does to the dark around a line, and painting it
over another line would be light doing something light does not do. Where two
rings meet the nearer wins, which is what happens on the tube as well. It
costs about 55 ms a frame at 1400×1258, and the default theme skips the pass
entirely.
## Meters and weather sensors ## Meters and weather sensors
Two things on the ISM bands are worth naming rather than reporting as hex. Two things on the ISM bands are worth naming rather than reporting as hex.

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so # Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded # 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text. # to two digits so versions sort as text.
VERSION_DATE = "2026-09-04" VERSION_DATE = "2026-09-05"
VERSION_REVISION = 10 VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -118,6 +118,7 @@ class AircraftOptions:
recheck: bool = False recheck: bool = False
basemap: bool = True basemap: bool = True
airports: bool = True airports: bool = True
theme: str = "night"
map_brightness: int = 70 map_brightness: int = 70
tile_url: str = "" tile_url: str = ""
@ -355,6 +356,21 @@ OPTIONS: tuple[Setting, ...] = (
flags=("--airports",), off_flags=("--no-airports",), flags=("--airports",), off_flags=("--no-airports",),
guidance="Turn it off for a picture with nothing but the aircraft on " guidance="Turn it off for a picture with nothing but the aircraft on "
"it, or where there is no network and nothing cached."), "it, or where there is no network and nothing cached."),
O("theme", "Colour theme", "Drawing", "choice",
"how the map looks: the colours, and whether the lines glow",
"The default draws a night-blue ground with height as colour, low "
"warm to high cold, which is what every other aircraft map does and "
"is the easiest to read. The rest are the screens the phrase 'air "
"defence display' calls to mind: a black tube, one phosphor, and thin "
"bright vector lines with a halo round them. Those have one colour to "
"spend, so height is brightness instead -- low is dim, high burns -- "
"the ground underneath is pushed well back so the lines carry the "
"picture, and a country is named in two letters rather than drawn as "
"a flag, because a flag needs half a dozen colours and a phosphor has "
"one. It applies to the window and to the animated pictures alike.",
choices=("night", "digital", "phosphor", "amber", "red"),
flags=("--theme",), metavar="NAME", example="phosphor",
guidance="night to read it, the others to look at it."),
O("map_brightness", "Map brightness", "Drawing", "int", O("map_brightness", "Map brightness", "Drawing", "int",
"how bright the map under the aircraft is drawn, as a percentage", "how bright the map under the aircraft is drawn, as a percentage",
"The map is the ground, not the subject, so it is drawn dark enough " "The map is the ground, not the subject, so it is drawn dark enough "
@ -696,11 +712,17 @@ def watch(console, options: AircraftOptions, output_dir: str,
registry = AircraftRegistry() registry = AircraftRegistry()
book = FlightBook(online=options.lookup, book = FlightBook(online=options.lookup,
schedules=schedule_names(options)) schedules=schedule_names(options))
# Set before the window is built, because the window reads its colours
# out of the palette this writes.
from .flightmap import set_theme
set_theme(options.theme)
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold, sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
home=read_position(options.location), home=read_position(options.location),
radius_nm=radius_in_nm(options) or 100.0, radius_nm=radius_in_nm(options) or 100.0,
brightness=max(10, options.map_brightness) / 100.0, brightness=max(10, options.map_brightness) / 100.0,
fade=max(0.0, options.fade)) fade=max(0.0, options.fade),
airports=options.airports)
sky.started = started sky.started = started
sky.log_name = log.path.name if log is not None else "" sky.log_name = log.path.name if log is not None else ""
if options.simulate: if options.simulate:
@ -738,7 +760,11 @@ def watch(console, options: AircraftOptions, output_dir: str,
threads = [threading.Thread(target=listening, daemon=True, threads = [threading.Thread(target=listening, daemon=True,
name="adsb-receiver")] name="adsb-receiver")]
if options.basemap: # One thread serves both the map and the aerodromes, and either on its
# own is reason enough to start it: the aerodromes are a separate
# question to a separate service, and asking for them with the tiles
# turned off is a perfectly ordinary thing to want.
if options.basemap or options.airports:
threads.append(threading.Thread( threads.append(threading.Thread(
target=livemap.fetch_ground, args=(sky, options.tile_url), target=livemap.fetch_ground, args=(sky, options.tile_url),
daemon=True, name="adsb-basemap")) daemon=True, name="adsb-basemap"))
@ -886,7 +912,7 @@ def radius_in_nm(options: AircraftOptions) -> float:
def draw(console, options: AircraftOptions, tracks, out_path, book=None): def draw(console, options: AircraftOptions, tracks, out_path, book=None):
"""Draw the map, saying what it is drawing and what came out of it.""" """Draw the map, saying what it is drawing and what came out of it."""
from .flightmap import animate, ffmpeg_available from .flightmap import animate, ffmpeg_available, set_theme
out_path = Path(out_path).expanduser() out_path = Path(out_path).expanduser()
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \ if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
@ -896,6 +922,7 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
out_path = out_path.with_suffix(".gif") out_path = out_path.with_suffix(".gif")
console.print(f"[grey62]drawing {out_path.name}…[/grey62]") console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
try: try:
set_theme(options.theme)
drawn = animate(tracks, out_path, book=book, fps=options.fps, drawn = animate(tracks, out_path, book=book, fps=options.fps,
seconds=options.length, speed=options.speed, seconds=options.length, speed=options.speed,
width=options.width, trail_seconds=options.trail, width=options.width, trail_seconds=options.trail,

View file

@ -196,6 +196,13 @@ examples:
ad.add_argument("--window", action="store_true", ad.add_argument("--window", action="store_true",
help="open a window and show the aircraft on a map as " help="open a window and show the aircraft on a map as "
"they are heard, instead of a table in the terminal") "they are heard, instead of a table in the terminal")
ad.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
"p1", "crimson"),
help="how the window and the map look: night (the "
"default), or the vector-display themes digital, "
"phosphor, amber and red")
ad.set_defaults(log_frames=True, lookup=True) ad.set_defaults(log_frames=True, lookup=True)
# -- flights -------------------------------------------------------------- # -- flights --------------------------------------------------------------
@ -248,6 +255,13 @@ examples:
fl.add_argument("--map-brightness", type=int, default=None, fl.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT", metavar="PERCENT",
help="how bright the map under the aircraft is (10-100)") help="how bright the map under the aircraft is (10-100)")
fl.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
"p1", "crimson"),
help="how the map looks: night (the default), or the "
"vector-display themes digital, phosphor, amber "
"and red")
fl.add_argument("--no-airports", dest="airports", action="store_false", fl.add_argument("--no-airports", dest="airports", action="store_false",
default=None, default=None,
help="do not mark the aerodromes under the flight paths") help="do not mark the aerodromes under the flight paths")
@ -1095,6 +1109,8 @@ def cmd_adsb(args) -> int:
options.basemap = args.basemap options.basemap = args.basemap
if args.schedules is not None: if args.schedules is not None:
options.schedules = args.schedules options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
if args.map: if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture options.picture = Path(args.map).suffix.lstrip(".") or options.picture
@ -1147,6 +1163,8 @@ def cmd_flights(args) -> int:
options.recheck = True options.recheck = True
if args.schedules is not None: if args.schedules is not None:
options.schedules = args.schedules options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
tracks = air.checked(console, options, tracks) tracks = air.checked(console, options, tracks)
book = FlightBook(online=args.lookup, book = FlightBook(online=args.lookup,
schedules=air.schedule_names(options)) schedules=air.schedule_names(options))
@ -1197,6 +1215,8 @@ def cmd_flights(args) -> int:
options.tile_url = args.tiles options.tile_url = args.tiles
if args.map_brightness is not None: if args.map_brightness is not None:
options.map_brightness = args.map_brightness options.map_brightness = args.map_brightness
if getattr(args, "theme", None):
options.theme = args.theme
if args.airports is not None: if args.airports is not None:
options.airports = args.airports options.airports = args.airports
if args.radius is not None: if args.radius is not None:

View file

@ -33,6 +33,7 @@ from pathlib import Path
import numpy as np import numpy as np
from . import themes as _themes
from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of, from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of,
distance_label, distance_nm, in_distance, in_speed, distance_label, distance_nm, in_distance, in_speed,
speed_label, within) speed_label, within)
@ -41,6 +42,7 @@ from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif", __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"write_mp4", "fit", "PALETTE", "ffmpeg_available", "write_mp4", "fit", "PALETTE", "ffmpeg_available",
"ground_for", "background", "label_lines", "draw_flag", "ground_for", "background", "label_lines", "draw_flag",
"set_theme", "theme", "THEME", "bloom",
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports", "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
"showing", "fade_ramp", "showing", "fade_ramp",
"GROUND_BRIGHTNESS"] "GROUND_BRIGHTNESS"]
@ -67,6 +69,12 @@ FAINT = FLAG + 12 # the same 32 again, fainter still, for fading
# steps the aircraft itself fades through. # steps the aircraft itself fades through.
LABEL_INK = FAINT + 32 # the row grey, at the four strengths LABEL_INK = FAINT + 32 # the row grey, at the four strengths
FLAG_FADED = LABEL_INK + 4 # three dimmer sets of the twelve flag colours FLAG_FADED = LABEL_INK + 4 # three dimmer sets of the twelve flag colours
# A vector display draws by holding a beam on the phosphor, and the phosphor
# spreads the light: a line on one of those screens is a bright core with a
# halo round it. The aircraft colours already have dimmed copies to make a
# halo out of -- that is what the trail shades are -- but the fixed colours
# do not, so the three that are worth a glow get two rings each here.
GLOW = FLAG_FADED + 36 # airport, ink and white, at two ring strengths
RAMP_STEPS = 32 RAMP_STEPS = 32
GROUND_SHADES = 32 GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before" TRANSPARENT = 255 # never drawn with: it means "as the frame before"
@ -93,30 +101,31 @@ MAX_FRAMES = 3000
# other hue the ramp leaves free -- the ramp already spends red, amber, # other hue the ramp leaves free -- the ramp already spends red, amber,
# green, cyan and violet on height -- and it is what an aeronautical chart # green, cyan and violet on height -- and it is what an aeronautical chart
# marks an aerodrome in anyway. # marks an aerodrome in anyway.
_FIXED = ((14, 16, 22), # background: night, not black # Which theme is being drawn. Everything below is built from it, so
(38, 44, 58), # grid # changing it changes both the animation and the window: they read the same
(196, 204, 218), # ink # palette, and the window looks the colours up in it rather than keeping any
(120, 130, 148), # dim ink # of its own.
(24, 28, 38), # panel THEME = _themes.THEMES[_themes.DEFAULT_THEME]
(255, 64, 200), # airports: magenta, and nothing else is
(70, 84, 110), # route lines
(255, 255, 255)) # white
# Low is warm, high is cold: the convention every other aircraft map uses, so _FIXED = (THEME.background, THEME.grid, THEME.ink, THEME.dim, THEME.panel,
# an altitude can be read off the picture without looking at the key. THEME.airport, THEME.route, THEME.white)
ALTITUDE_STOPS = ((0.0, (252, 96, 72)), (10_000.0, (250, 190, 64)),
(20_000.0, (132, 226, 96)), (30_000.0, (72, 200, 236)), # Low is warm, high is cold on the default map: the convention every other
(45_000.0, (158, 142, 255))) # aircraft map uses, so an altitude can be read off the picture without
# looking at the key. A phosphor theme has one colour to spend and reads
# height as brightness instead.
ALTITUDE_STOPS = THEME.stops
def _ramp(steps: int = RAMP_STEPS) -> list[tuple[int, int, int]]: def _ramp(steps: int = RAMP_STEPS, stops=None) -> list[tuple[int, int, int]]:
"""The altitude ramp, interpolated between the stops above.""" """The altitude ramp, interpolated between the stops above."""
stops = ALTITUDE_STOPS if stops is None else stops
out = [] out = []
for i in range(steps): for i in range(steps):
feet = CEILING_FT * i / (steps - 1) feet = CEILING_FT * i / (steps - 1)
low = ALTITUDE_STOPS[0] low = stops[0]
high = ALTITUDE_STOPS[-1] high = stops[-1]
for a, b in zip(ALTITUDE_STOPS, ALTITUDE_STOPS[1:]): for a, b in zip(stops, stops[1:]):
if a[0] <= feet <= b[0]: if a[0] <= feet <= b[0]:
low, high = a, b low, high = a, b
break break
@ -136,8 +145,8 @@ def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]:
# far up that range a drawing actually goes is the brightness setting below, # far up that range a drawing actually goes is the brightness setting below,
# so there is room to turn it up on a screen that needs it and down on one # so there is room to turn it up on a screen that needs it and down on one
# that does not. # that does not.
GROUND_LOW = (16, 19, 26) GROUND_LOW = THEME.ground_low
GROUND_HIGH = (150, 164, 186) GROUND_HIGH = THEME.ground_high
# What fraction of that range the map is drawn over unless told otherwise. # What fraction of that range the map is drawn over unless told otherwise.
# Enough to read a coastline and the name of a town, and not so much that a # Enough to read a coastline and the name of a town, and not so much that a
@ -145,12 +154,15 @@ GROUND_HIGH = (150, 164, 186)
GROUND_BRIGHTNESS = 0.70 GROUND_BRIGHTNESS = 0.70
def _ground_shades(steps: int = GROUND_SHADES) -> list[tuple[int, int, int]]: def _ground_shades(steps: int = GROUND_SHADES, low=None,
high=None) -> list[tuple[int, int, int]]:
low = GROUND_LOW if low is None else low
high = GROUND_HIGH if high is None else high
out = [] out = []
for i in range(steps): for i in range(steps):
part = i / max(1, steps - 1) part = i / max(1, steps - 1)
out.append(tuple(int(round(a + (b - a) * part)) out.append(tuple(int(round(a + (b - a) * part))
for a, b in zip(GROUND_LOW, GROUND_HIGH))) for a, b in zip(low, high)))
return out return out
@ -187,16 +199,26 @@ def fade_level(strength: float) -> int:
FADE_FACTORS = (1.0, 0.55, 0.30, 0.14) FADE_FACTORS = (1.0, 0.55, 0.30, 0.14)
def _palette() -> np.ndarray: def _palette(theme=None) -> np.ndarray:
ramp = _ramp() theme = THEME if theme is None else theme
fixed = (theme.background, theme.grid, theme.ink, theme.dim, theme.panel,
theme.airport, theme.route, theme.white)
ramp = _ramp(stops=theme.stops)
flags = _flag_colours() flags = _flag_colours()
table = (list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30) table = (list(fixed) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
+ _ground_shades() + flags + _dimmed(ramp, 0.14) + _ground_shades(low=theme.ground_low, high=theme.ground_high)
+ flags + _dimmed(ramp, 0.14)
# The row grey and then the flags, dimmed the same way the # The row grey and then the flags, dimmed the same way the
# aircraft above them is, so that a whole label fades together. # aircraft above them is, so that a whole label fades together.
+ [_dimmed([_FIXED[DIM]], factor)[0] for factor in FADE_FACTORS]) + [_dimmed([fixed[DIM]], factor)[0] for factor in FADE_FACTORS])
for factor in FADE_FACTORS[1:]: for factor in FADE_FACTORS[1:]:
table += _dimmed(flags, factor) table += _dimmed(flags, factor)
# The halo colours: the near ring and the far one, for the three fixed
# colours bright enough to be worth glowing.
part = theme.glow_part or 0.34
for colour in (fixed[AIRPORT], fixed[INK], fixed[WHITE]):
table += [_dimmed([colour], part)[0],
_dimmed([colour], part * part)[0]]
table += [(0, 0, 0)] * (256 - len(table)) table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8) return np.array(table[:256], dtype=np.uint8)
@ -204,6 +226,32 @@ def _palette() -> np.ndarray:
PALETTE = _palette() PALETTE = _palette()
def set_theme(name) -> "_themes.Theme":
"""Draw in this theme from now on, and say which one that turned out to be.
The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to this array
and a new one would leave half the program painting in the old colours.
Returns the theme, so a caller that passed a name can say what it got.
"""
global THEME, _FIXED, ALTITUDE_STOPS, GROUND_LOW, GROUND_HIGH
theme = name if isinstance(name, _themes.Theme) else _themes.theme_named(name)
THEME = theme
_FIXED = (theme.background, theme.grid, theme.ink, theme.dim, theme.panel,
theme.airport, theme.route, theme.white)
ALTITUDE_STOPS = theme.stops
GROUND_LOW, GROUND_HIGH = theme.ground_low, theme.ground_high
PALETTE[:] = _palette(theme)
return theme
def theme() -> "_themes.Theme":
"""Which theme is being drawn."""
return THEME
# How faint an aircraft is drawn as it fades, in the four strengths the # How faint an aircraft is drawn as it fades, in the four strengths the
# palette holds. An indexed picture cannot blend, so a fade is a handful of # palette holds. An indexed picture cannot blend, so a fade is a handful of
# steps rather than a slope -- which at a second or two apart reads as a # steps rather than a slope -- which at a second or two apart reads as a
@ -215,6 +263,83 @@ FADE_STEPS = ((0.66, RAMP), (0.40, TRAIL), (0.18, OLD), (0.0, FAINT))
LABEL_WHILE = 0.40 LABEL_WHILE = 0.40
# Which colour the halo around each drawn colour is, a ring at a time. The
# aircraft families already have their dimmed copies -- the trail shades --
# so a glowing aeroplane spreads into the colour its own trail is drawn in,
# which is the same colour a phosphor would have spread into.
_NO_HALO = TRANSPARENT
def _halo_tables() -> tuple:
near = np.full(256, _NO_HALO, dtype=np.uint8)
far = np.full(256, _NO_HALO, dtype=np.uint8)
for step in range(RAMP_STEPS):
for base in (RAMP, TRAIL, OLD, FAINT):
near[base + step] = TRAIL + step
far[base + step] = OLD + step
for i, colour in enumerate((AIRPORT, INK, WHITE)):
near[colour] = GLOW + i * 2
far[colour] = GLOW + i * 2 + 1
# The quieter rows of a label glow too, into the fainter greys they
# already fade through. Lettering on those screens has a halo the same
# as everything else does; it is the same beam drawing it.
near[DIM] = LABEL_INK + 1
far[DIM] = LABEL_INK + 2
for level in range(3):
near[LABEL_INK + level] = LABEL_INK + level + 1
far[LABEL_INK + level] = LABEL_INK + min(3, level + 2)
return near, far
_HALO_NEAR, _HALO_FAR = _halo_tables()
def _spread(values: np.ndarray) -> np.ndarray:
"""One pixel of halo in every direction, first writer wins.
Done as two one-dimensional passes rather than eight shifts of the whole
picture: the second pass spreads what the first one already spread, so
the corners come out with it, at half the work.
"""
def moved(values, axis, step):
# Rolled and then cut, because a roll wraps: without this a line
# down the left edge would glow on the right edge of the picture.
shifted = np.roll(values, step, axis=axis)
edge = [slice(None), slice(None)]
edge[axis] = slice(0, 1) if step > 0 else slice(-1, None)
shifted[tuple(edge)] = _NO_HALO
return shifted
out = values.copy()
for step in (1, -1):
out = np.where(out == _NO_HALO, moved(values, 1, step), out)
across = out.copy()
for step in (1, -1):
out = np.where(out == _NO_HALO, moved(across, 0, step), out)
return out
def bloom(img: np.ndarray, theme=None) -> np.ndarray:
"""Put a halo around everything bright, the way a phosphor does.
Only over the ground, the grid and the empty background: a halo is what
light does to the dark around a line, and painting it over another line
would be light doing something light does not do.
The near ring goes down after the far one, so where two rings meet the
brighter wins -- which is what happens on the tube as well.
"""
theme = THEME if theme is None else theme
if not theme.glow:
return img
empty = ((img == BG) | (img == GRID)
| ((img >= GROUND) & (img < GROUND + GROUND_SHADES)))
near = _spread(_HALO_NEAR[img])
far = near if theme.glow < 2 else _spread(_spread(_HALO_FAR[img]))
out = np.where(empty & (far != _NO_HALO), far, img)
return np.where(empty & (near != _NO_HALO), near, out)
def fade_ramp(strength: float) -> int: def fade_ramp(strength: float) -> int:
"""Which set of colours an aircraft at this strength is drawn from.""" """Which set of colours an aircraft at this strength is drawn from."""
for above, base in FADE_STEPS: for above, base in FADE_STEPS:
@ -433,7 +558,11 @@ def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS):
palette means the animation's colour table stays the same table from one palette means the animation's colour table stays the same table from one
frame to the next, which is the whole basis of the frame differencing. frame to the next, which is the whole basis of the frame differencing.
""" """
part = max(0.05, min(1.0, float(brightness))) # The theme has a say as well as the setting. A screen made of lines
# wants the ground well out of the way: a tinted photograph of a county
# behind the vectors is the one thing that stops a vector display
# looking like one.
part = max(0.05, min(1.0, float(brightness) * THEME.ground_part))
top = max(1, int(round((GROUND_SHADES - 1) * part))) top = max(1, int(round((GROUND_SHADES - 1) * part)))
return np.clip((np.asarray(levels, dtype=np.float64) return np.clip((np.asarray(levels, dtype=np.float64)
* top / (GROUND_SHADES - 1)).round(), * top / (GROUND_SHADES - 1)).round(),
@ -695,7 +824,9 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
places.end() places.end()
if clock: if clock:
_clock_strip(img, view, clock, flying) _clock_strip(img, view, clock, flying)
return img # Last of all, so that everything drawn this frame glows and nothing
# drawn after it paints over the halo.
return bloom(img)
def showing(track: Track, when: float, stale: float = 300.0, def showing(track: Track, when: float, stale: float = 300.0,
@ -937,10 +1068,15 @@ def draw_flag(img: np.ndarray, x: int, y: int, country: str,
""" """
from .flags import FLAG_W, flag_for from .flags import FLAG_W, flag_for
rows = flag_for(country) rows = flag_for(country) if THEME.flags else None
if rows is None: if rows is None:
# Named in the theme's own colour rather than the grid's when the
# theme has no flags at all: there it is not a fallback, it is what
# every country gets, and it has to be as readable as the row it
# sits beside.
plain = DIM if not THEME.flags else GRID
draw_text(img, x, y + 1, country[:2].upper(), draw_text(img, x, y + 1, country[:2].upper(),
GRID if level <= 0 else LABEL_INK + min(level, 3)) plain if level <= 0 else LABEL_INK + min(level, 3))
return return
height, width = img.shape height, width = img.shape
for row, line in enumerate(rows): for row, line in enumerate(rows):

View file

@ -337,7 +337,8 @@ class Sky:
def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0, def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0,
home=None, radius_nm: float = 100.0, home=None, radius_nm: float = 100.0,
brightness: float = 0.70, fade: float = 20.0): brightness: float = 0.70, fade: float = 20.0,
airports: bool = False):
import threading import threading
self.unit = unit self.unit = unit
@ -346,6 +347,10 @@ class Sky:
self.radius_nm = radius_nm self.radius_nm = radius_nm
self.brightness = brightness self.brightness = brightness
self.fade = fade self.fade = fade
# Off unless asked for, because saying yes means a question to a
# network the first time an area is drawn. The program turns it on;
# anything using this as a library has to say so on purpose.
self.show_airports = airports
self.frames = 0 self.frames = 0
self.aircraft_seen = 0 self.aircraft_seen = 0
self.started = time.time() self.started = time.time()
@ -364,6 +369,12 @@ class Sky:
self._ground_for = None self._ground_for = None
self._ground_box = None self._ground_box = None
self._ground_serial = 0 self._ground_serial = 0
# The aerodromes under the view, fetched with the map and kept the
# same way: they come from the same place, cover the same box, and
# go stale at the same moment.
self._airports: list | None = None
self._airports_box = None
self._airports_want = None
self._wanted = None self._wanted = None
self._settled = None self._settled = None
@ -397,6 +408,48 @@ class Sky:
if self._wanted is not None and self._wanted[0] == key: if self._wanted is not None and self._wanted[0] == key:
self._wanted = None self._wanted = None
def set_airports(self, found, box) -> None:
"""Keep the aerodromes fetched, and the piece of world they cover.
An empty answer is kept as an empty list rather than as nothing at
all: an area with no aerodromes in it has been asked about and
answered, and asking again five times a second for the rest of the
night would be the wrong lesson to draw from it.
"""
with self._lock:
self._airports = list(found or [])
self._airports_box = box
if self._airports_want == box:
self._airports_want = None
def want_airports(self, box) -> None:
"""Say which aerodromes are needed. Painting never waits."""
with self._lock:
if self._airports_box != box:
self._airports_want = box
def wanted_airports(self):
with self._lock:
return self._airports_want
def airports_covering(self, south, west, north, east):
"""The aerodromes fetched, or None if what is here does not cover.
Held to the same rule as the map: what was asked for covers rather
more world than is being shown, so a view that has drifted inside it
is answered from what is already here. A view that has moved
outside it is answered with None -- not an empty list, which would
mean "asked, and there are none" -- so that the caller knows to ask.
"""
with self._lock:
found, box = self._airports, self._airports_box
if found is None or box is None:
return None
if (south >= box[0] and west >= box[1]
and north <= box[2] and east <= box[3]):
return found
return None
def ground_serial(self) -> int: def ground_serial(self) -> int:
"""Which map is in hand. Changes whenever a new one is fetched.""" """Which map is in hand. Changes whenever a new one is fetched."""
with self._lock: with self._lock:
@ -586,8 +639,8 @@ def _build():
return None return None
_name, QtCore, QtGui, QtWidgets, _signal = found _name, QtCore, QtGui, QtWidgets, _signal = found
from .flightmap import (BG, GRID, GROUND, GROUND_SHADES, INK, PALETTE, from .flightmap import (AIRPORT, BG, GRID, GROUND, GROUND_SHADES, INK,
RAMP, _degrees, _grid_step) PALETTE, PANEL, RAMP, _degrees, _grid_step)
Qt = QtCore.Qt Qt = QtCore.Qt
QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer
@ -595,6 +648,9 @@ def _build():
QPainter, QPen, QPolygonF = QtGui.QPainter, QtGui.QPen, QtGui.QPolygonF QPainter, QPen, QPolygonF = QtGui.QPainter, QtGui.QPen, QtGui.QPolygonF
NO_PEN = _enum(Qt, "PenStyle", "NoPen") NO_PEN = _enum(Qt, "PenStyle", "NoPen")
ROUND_CAP = _enum(Qt, "PenCapStyle", "RoundCap")
ROUND_JOIN = _enum(Qt, "PenJoinStyle", "RoundJoin")
_NO_BRUSH = QtGui.QBrush(_enum(Qt, "BrushStyle", "NoBrush"))
DOTTED = _enum(Qt, "PenStyle", "DotLine") DOTTED = _enum(Qt, "PenStyle", "DotLine")
RGB888 = _enum(QImage, "Format", "Format_RGB888") RGB888 = _enum(QImage, "Format", "Format_RGB888")
ANTIALIAS = _enum(QPainter, "RenderHint", "Antialiasing") ANTIALIAS = _enum(QPainter, "RenderHint", "Antialiasing")
@ -609,6 +665,35 @@ def _build():
def craft_colour(feet, alpha=255) -> QColor: def craft_colour(feet, alpha=255) -> QColor:
return rgb(RAMP + altitude_step(feet), alpha) return rgb(RAMP + altitude_step(feet), alpha)
def glow_line(painter, colour, width: float, draw, core=True) -> None:
"""Lay one line down two or three times, wider and fainter each pass.
A vector display holds a beam on the phosphor, and the phosphor
spreads the light and keeps glowing after the beam has moved on, so
a line on one of those screens is a bright core inside a halo. This
is that, done the only way a painter can do it cheaply: the halo
first, in a wide soft pen, and the core last on top of it.
``draw`` is handed a pen and asked to draw with it, so the same
thing works for a polyline, a rectangle or a leader. ``core=False``
lays down the halo alone, for a shape whose core wants drawing some
other way -- unsmoothed, or filled rather than stroked.
"""
from .flightmap import THEME
rings = THEME.glow
if rings:
part = THEME.glow_part
for ring in range(rings, 0, -1):
faint = QColor(colour)
faint.setAlpha(max(6, int(colour.alpha() * part ** ring)))
pen = QPen(faint, width + ring * 2.4)
pen.setCapStyle(ROUND_CAP)
pen.setJoinStyle(ROUND_JOIN)
draw(pen)
if core:
draw(QPen(colour, width))
class SkyView(QtWidgets.QWidget): class SkyView(QtWidgets.QWidget):
"""The map, the aircraft on it, and a box beside each one.""" """The map, the aircraft on it, and a box beside each one."""
@ -701,6 +786,7 @@ def _build():
if self.show_ground: if self.show_ground:
self._draw_ground(painter, view) self._draw_ground(painter, view)
self._draw_graticule(painter, view) self._draw_graticule(painter, view)
self._draw_airports(painter, view)
flying = self.sky.flying() flying = self.sky.flying()
if self.trails: if self.trails:
for blip in flying: for blip in flying:
@ -854,16 +940,79 @@ def _build():
_degrees(lon, "lon")) _degrees(lon, "lon"))
lon += step lon += step
def _draw_airports(self, painter, view) -> None:
"""Every aerodrome under the view, marked and named.
A route names the two airports its aircraft are flying between,
and those are almost never the ones underneath: a receiver hears
aircraft over its own county, and the county's airports are what
say where on the map you are looking.
Drawn under the aircraft and before them, since an aeroplane is
what the window is for and an airport is where it is.
"""
if not self.sky.show_airports:
return
found = self.sky.airports_covering(view.south, view.west,
view.north, view.east)
if found is None:
# Ask for them and carry on drawing. They arrive when they
# arrive; the aircraft are the part that cannot wait.
self.sky.want_airports(self.ground_box(view))
return
if not found:
return
from .flightmap import THEME
colour = rgb(AIRPORT)
glowing = bool(THEME.glow)
painter.setFont(self.text_font)
for code, lat, lon in found:
# Skipped rather than drawn and clipped. 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, and
# they are not worth a draw call each to have Qt throw away.
if not view.inside(lat, lon):
continue
x, y = view.xy(lat, lon)
# The square goes down unsmoothed. It is six pixels on a
# side and axis-aligned, so antialiasing it only spreads a
# one-pixel line across two and leaves the mark looking out
# of focus next to the crisp aircraft beside it. The name
# keeps its smoothing, which is what letters want.
def square(pen):
painter.setPen(pen)
painter.setBrush(_NO_BRUSH)
painter.drawRect(x - 3, y - 3, 6, 6)
# The halo wants smoothing and the square does not. It is
# six pixels on a side and axis-aligned, so antialiasing the
# square itself only spreads a one-pixel line across two and
# leaves the mark looking out of focus beside the crisp
# aircraft; the glow round it is all curve and needs it.
if glowing:
glow_line(painter, colour, 1.0, square, core=False)
painter.setRenderHint(ANTIALIAS, False)
square(QPen(colour, 1.0))
painter.fillRect(x - 1, y - 1, 2, 2, colour)
painter.setRenderHint(ANTIALIAS, True)
if code:
painter.drawText(x + 7, y + 4, code)
def _draw_trail(self, painter, view, blip: Blip, def _draw_trail(self, painter, view, blip: Blip,
strength: float = 1.0) -> None: strength: float = 1.0) -> None:
points = self.sky.trail(blip.icao) points = self.sky.trail(blip.icao)
if len(points) < 2: if len(points) < 2:
return return
painter.setPen(QPen(craft_colour(blip.altitude_ft,
int(90 * strength)), 1.4))
path = QPolygonF([QPointF(*view.xy(lat, lon)) path = QPolygonF([QPointF(*view.xy(lat, lon))
for lat, lon, _ in points]) for lat, lon, _ in points])
painter.drawPolyline(path)
def stroke(pen):
painter.setPen(pen)
painter.drawPolyline(path)
glow_line(painter, craft_colour(blip.altitude_ft,
int(90 * strength)), 1.4, stroke)
def _lay_out(self, blip: Blip, x, y, taken, strength: float = 1.0): def _lay_out(self, blip: Blip, x, y, taken, strength: float = 1.0):
"""Where this aircraft's box goes, and what is in it. """Where this aircraft's box goes, and what is in it.
@ -896,10 +1045,14 @@ def _build():
# To the near edge of the box rather than its middle: a leader # To the near edge of the box rather than its middle: a leader
# drawn to the centre crosses the box and strikes out a line of # drawn to the centre crosses the box and strikes out a line of
# what it was drawn to point at. # what it was drawn to point at.
painter.setPen(QPen(colour.lighter(120), 1.0)) to_x = int(max(dx, min(x, dx + box[0])))
painter.drawLine(x, y, to_y = int(max(dy, min(y, dy + box[1])))
int(max(dx, min(x, dx + box[0]))),
int(max(dy, min(y, dy + box[1])))) def leader(pen):
painter.setPen(pen)
painter.drawLine(x, y, to_x, to_y)
glow_line(painter, colour.lighter(120), 1.0, leader)
title = f"{blip.callsign} {blip.icao}" if blip.callsign \ title = f"{blip.callsign} {blip.icao}" if blip.callsign \
else blip.icao else blip.icao
self._draw_box(painter, dx, dy, box, lines, colour, title) self._draw_box(painter, dx, dy, box, lines, colour, title)
@ -950,10 +1103,23 @@ def _build():
return QPointF(x + side * cos + ahead * sin, return QPointF(x + side * cos + ahead * sin,
y + side * sin - ahead * cos) y + side * sin - ahead * cos)
shape = QPolygonF([point(9, 0), point(-6, 5),
point(-3, 0), point(-6, -5)])
# The halo is stroked round the outline rather than filled, so
# that it spreads outwards from the symbol instead of merely
# making it bigger.
from .flightmap import THEME
if THEME.glow:
def outline(pen):
painter.setPen(pen)
painter.setBrush(_NO_BRUSH)
painter.drawPolygon(shape)
glow_line(painter, colour, 0.1, outline, core=False)
painter.setPen(NO_PEN) painter.setPen(NO_PEN)
painter.setBrush(colour) painter.setBrush(colour)
painter.drawPolygon(QPolygonF([point(9, 0), point(-6, 5), painter.drawPolygon(shape)
point(-3, 0), point(-6, -5)]))
painter.setBrush(QColor(255, 255, 255, int(200 * strength))) painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
painter.drawEllipse(QPointF(x, y), 1.6, 1.6) painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
@ -1002,9 +1168,20 @@ def _build():
def _draw_box(self, painter, bx, by, box, lines, colour, def _draw_box(self, painter, bx, by, box, lines, colour,
title: str) -> None: title: str) -> None:
width, height = box width, height = box
shape = QRectF(bx, by, width, height)
# The panel colour, from the theme, so a box on a phosphor
# screen is the black of the tube rather than a blue-grey card.
back = rgb(PANEL, int(215 * (colour.alpha() / 255)))
painter.setBrush(back)
painter.setPen(QPen(colour, 1.2)) painter.setPen(QPen(colour, 1.2))
painter.setBrush(QColor(10, 12, 18, int(215 * (colour.alpha() / 255)))) painter.drawRoundedRect(shape, 3.0, 3.0)
painter.drawRoundedRect(QRectF(bx, by, width, height), 3.0, 3.0)
def outline(pen):
painter.setPen(pen)
painter.setBrush(_NO_BRUSH)
painter.drawRoundedRect(shape, 3.0, 3.0)
glow_line(painter, colour, 1.2, outline)
painter.setFont(self.head_font) painter.setFont(self.head_font)
faded = colour.alpha() faded = colour.alpha()
painter.setPen(colour.lighter(135)) painter.setPen(colour.lighter(135))
@ -1185,6 +1362,13 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
from . import basemap from . import basemap
while not sky.stopping: while not sky.stopping:
# The aerodromes first, and on their own account. They used to be
# fetched only in the same pass as a piece of map, which meant they
# waited 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 one small question and they
# have nothing to do with the tiles.
_airports(sky)
wanted = sky.wanted_ground() wanted = sky.wanted_ground()
if wanted is None: if wanted is None:
time.sleep(0.2) time.sleep(0.2)
@ -1210,6 +1394,30 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
sky.set_ground(levels, key, box if levels is not None else None) sky.set_ground(levels, key, box if levels is not None else None)
def _airports(sky: Sky) -> None:
"""Fetch the aerodromes the window is asking for, if it is asking.
On the fetching thread, for the same reason the tiles are: it is a
question to a network the first time an area is drawn, and the window
must not stop repainting while it is asked. Cached on disk for a month
afterwards, since a runway does not move.
"""
from . import basemap
box = sky.wanted_airports()
if box is None or not sky.show_airports:
return
try:
found = [(a["code"], a["latitude"], a["longitude"])
for a in basemap.airports_in(*box)]
except Exception:
# Kept as nothing rather than left unanswered: an area that could
# not be asked about must not be asked about again every fifth of a
# second for the rest of the night.
found = []
sky.set_airports(found, box)
def _ground_shades() -> int: def _ground_shades() -> int:
from .flightmap import GROUND_SHADES from .flightmap import GROUND_SHADES

230
bandsaunter/themes.py Normal file
View file

@ -0,0 +1,230 @@
"""How the maps look: the colours, and whether the lines glow.
The default is what this program has always drawn -- a night-blue ground
under aircraft coloured by height, which is what every other aircraft map
does and is the easiest to read. The rest are the screens the phrase "air
defence display" actually calls to mind: a black tube, a single phosphor,
and thin bright vector lines with a halo around them.
Two things change between a theme and a screenshot of one.
The first is what altitude means. On the default map it is hue -- low warm,
high cold -- which needs the whole spectrum and is why nothing else on the
picture can be amber or green. A phosphor screen has one colour, so on
those themes altitude is *brightness* instead: low is dim, high burns. That
is not an imitation of the old displays, it is the same constraint they had.
The second is the glow. A vector display draws by pointing a beam at the
phosphor and holding it there, and the phosphor spreads the light a little
and keeps glowing after the beam has gone. So a line on one of those
screens is not one pixel wide with a hard edge; it is a bright core with a
halo. Both drawings here do that -- the window by laying the same line down
two or three times, wider and fainter each pass, and the animation by
dilating what it has drawn and filling the halo with the dimmed copy of the
colour underneath it, which is the same trick an indexed picture has to use
for everything.
Each theme names its colours as ordinary RGB. The palettes, the dimmed
sets, the fade steps and the flag colours are all built from these, so a
theme is a dozen numbers rather than a table of two hundred and twenty.
"""
from __future__ import annotations
from dataclasses import dataclass, field
__all__ = ["Theme", "THEMES", "DEFAULT_THEME", "theme_named", "theme_names"]
@dataclass(frozen=True)
class Theme:
"""One look: eight fixed colours, an altitude ramp, and a glow."""
name: str
summary: str
background: tuple
grid: tuple
ink: tuple # ordinary text
dim: tuple # the quieter text in a label
panel: tuple
airport: tuple # aerodromes, and nothing else
route: tuple # the line between two airports
white: tuple
# (feet, colour) stops the 32 altitude colours are interpolated between.
stops: tuple
ground_low: tuple # the map underneath, at its darkest
ground_high: tuple # and at its brightest
# How far the halo around a line reaches, in pixels, and how bright it
# is as a fraction of the line itself. Zero for no glow at all.
glow: int = 0
glow_part: float = 0.34
# How much of the asked-for brightness the map underneath actually gets.
# A screen made of lines wants the ground well out of the way: a tinted
# photograph of a county behind the vectors is the one thing that stops
# a vector display looking like one.
ground_part: float = 1.0
# Whether flags are drawn as flags. A flag is half a dozen colours, and
# on a single-phosphor screen there are not half a dozen colours to draw
# it in -- so those themes name the country in two letters instead,
# which is what a display of the period would have done anyway.
flags: bool = True
# Whether height is read as brightness rather than as hue. Said out
# loud because the key at the bottom of the picture has to say which.
height_is_brightness: bool = False
aliases: tuple = field(default=())
# The colours this program has always drawn. Low is warm and high is cold,
# which is the convention every other aircraft map uses, so a height can be
# read off the picture without looking at the key.
NIGHT = Theme(
name="night",
summary="the default: a night-blue ground, height as colour",
background=(14, 16, 22),
grid=(38, 44, 58),
ink=(196, 204, 218),
dim=(120, 130, 148),
panel=(24, 28, 38),
airport=(255, 64, 200),
route=(70, 84, 110),
white=(255, 255, 255),
stops=((0.0, (252, 96, 72)), (10_000.0, (250, 190, 64)),
(20_000.0, (132, 226, 96)), (30_000.0, (72, 200, 236)),
(45_000.0, (158, 142, 255))),
ground_low=(16, 19, 26),
ground_high=(150, 164, 186),
)
# The blue tube: cyan coastlines, amber for the places on the ground, and a
# hard black behind all of it. Height runs from a deep unlit blue up
# through the working cyan to white at the top of the sky, so the highest
# aircraft is the brightest thing moving.
DIGITAL = Theme(
name="digital",
summary="blue phosphor: cyan vectors on black, amber aerodromes",
background=(0, 2, 6),
grid=(0, 46, 84),
ink=(150, 224, 255),
dim=(72, 148, 198),
panel=(0, 10, 20),
airport=(255, 176, 64),
route=(0, 70, 120),
white=(232, 248, 255),
stops=((0.0, (0, 60, 140)), (10_000.0, (0, 122, 208)),
(20_000.0, (0, 190, 244)), (30_000.0, (120, 230, 255)),
(45_000.0, (168, 234, 255))),
ground_low=(0, 8, 18),
ground_high=(0, 132, 190),
glow=2,
glow_part=0.50,
ground_part=0.26,
flags=False,
height_is_brightness=True,
aliases=("blue", "wargames", "norad"),
)
# P1 phosphor, the green one on every oscilloscope and radar repeater ever
# built. The aerodromes are amber, which is what a second phosphor looked
# like on the same tube and is the one colour that never reads as a height.
PHOSPHOR = Theme(
name="phosphor",
summary="green phosphor: P1 vectors on black, height as brightness",
background=(0, 5, 2),
grid=(0, 56, 24),
ink=(150, 255, 170),
dim=(72, 168, 96),
panel=(0, 12, 5),
airport=(255, 184, 72),
route=(0, 76, 34),
white=(236, 255, 240),
stops=((0.0, (0, 74, 30)), (10_000.0, (0, 146, 56)),
(20_000.0, (24, 210, 84)), (30_000.0, (120, 246, 150)),
(45_000.0, (176, 255, 196)),),
ground_low=(0, 10, 4),
ground_high=(0, 148, 62),
glow=2,
glow_part=0.50,
ground_part=0.26,
flags=False,
height_is_brightness=True,
aliases=("green", "p1"),
)
# The amber screens: easier on the eyes than green, and the reason half the
# terminals of the period were the colour of a streetlight.
AMBER = Theme(
name="amber",
summary="amber phosphor: warm vectors on black, height as brightness",
background=(6, 3, 0),
grid=(70, 40, 0),
ink=(255, 204, 120),
dim=(180, 128, 52),
panel=(14, 8, 0),
airport=(120, 220, 255),
route=(84, 48, 0),
white=(255, 244, 224),
stops=((0.0, (96, 44, 0)), (10_000.0, (168, 88, 0)),
(20_000.0, (232, 148, 16)), (30_000.0, (255, 200, 96)),
(45_000.0, (255, 226, 150))),
ground_low=(10, 6, 0),
ground_high=(170, 106, 16),
glow=2,
glow_part=0.50,
ground_part=0.26,
flags=False,
height_is_brightness=True,
aliases=("orange",),
)
# The red one, for a room somebody wants to keep their night vision in.
RED = Theme(
name="red",
summary="red phosphor: for a room that wants its night vision",
background=(6, 0, 0),
grid=(74, 12, 12),
ink=(255, 138, 130),
dim=(184, 76, 70),
panel=(14, 2, 2),
airport=(120, 220, 255),
route=(88, 14, 14),
white=(255, 228, 224),
stops=((0.0, (92, 0, 0)), (10_000.0, (164, 16, 8)),
(20_000.0, (226, 56, 40)), (30_000.0, (255, 128, 112)),
(45_000.0, (255, 176, 164))),
ground_low=(10, 0, 0),
ground_high=(168, 30, 24),
glow=2,
glow_part=0.50,
ground_part=0.26,
flags=False,
height_is_brightness=True,
aliases=("crimson",),
)
THEMES: dict[str, Theme] = {t.name: t for t in (NIGHT, DIGITAL, PHOSPHOR,
AMBER, RED)}
DEFAULT_THEME = NIGHT.name
def theme_names() -> list[str]:
"""Every theme, in the order they are offered."""
return list(THEMES)
def theme_named(name: str) -> Theme:
"""One theme by name or by any of its aliases.
An unknown name is the default rather than an error: a theme is how the
picture looks, and refusing to draw an evening's flying because of a
misspelt colour would be the wrong trade.
"""
wanted = (name or "").strip().lower()
if not wanted:
return THEMES[DEFAULT_THEME]
if wanted in THEMES:
return THEMES[wanted]
for theme in THEMES.values():
if wanted in theme.aliases:
return theme
return THEMES[DEFAULT_THEME]

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-04" "bandsaunter 2026-09-04_10" "User Commands" .TH BANDSAUNTER 1 "2026-09-05" "bandsaunter 2026-09-05_01" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS
@ -1621,6 +1621,34 @@ where a moving one is going rather than where it has reached. A still picture
has no frame before it and places its labels exactly as it always did. The has no frame before it and places its labels exactly as it always did. The
whole box fades with its aircraft: an indexed picture cannot blend, so the row whole box fades with its aircraft: an indexed picture cannot blend, so the row
grey and all twelve flag colours have dimmed copies at each fade step. grey and all twelve flag colours have dimmed copies at each fade step.
.SS Themes
.BI \-\-theme " NAME"
changes the window and the animated pictures together, since both read their
colours out of the same palette.
.B night
is the default: a night-blue ground with height as colour, low warm to high
cold, which is what every other aircraft map does and is the easiest to read.
.BR digital ", " phosphor ", " amber " and " red
are the screens the phrase "air defence display" calls to mind \[em] a black
tube, one phosphor, and thin bright vector lines with a halo round them.
.PP
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. The map underneath is drawn at about
two-fifths 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.
.PP
A vector display draws by holding a beam on the phosphor, which spreads the
light a little and keeps glowing after the beam has gone, so a line on one of
those screens is a bright core inside a halo. The window does that by laying
the same line down two or three times, wider and fainter each pass, and the
core last. 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: an aeroplane glows into the colour its own trail is drawn
in, which is the colour a phosphor would have spread into. The halo goes over
the map, the grid and the background and over nothing else that was drawn.
.SH METERS AND SENSORS .SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as Two things on the ISM bands are worth naming rather than reporting as
hexadecimal. hexadecimal.

View file

@ -1014,6 +1014,34 @@ where a moving one is going rather than where it has reached. A still picture
has no frame before it and places its labels exactly as it always did. The has no frame before it and places its labels exactly as it always did. The
whole box fades with its aircraft: an indexed picture cannot blend, so the row whole box fades with its aircraft: an indexed picture cannot blend, so the row
grey and all twelve flag colours have dimmed copies at each fade step. grey and all twelve flag colours have dimmed copies at each fade step.
.SS Themes
.BI \-\-theme " NAME"
changes the window and the animated pictures together, since both read their
colours out of the same palette.
.B night
is the default: a night-blue ground with height as colour, low warm to high
cold, which is what every other aircraft map does and is the easiest to read.
.BR digital ", " phosphor ", " amber " and " red
are the screens the phrase "air defence display" calls to mind \[em] a black
tube, one phosphor, and thin bright vector lines with a halo round them.
.PP
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. The map underneath is drawn at about
two-fifths 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.
.PP
A vector display draws by holding a beam on the phosphor, which spreads the
light a little and keeps glowing after the beam has gone, so a line on one of
those screens is a bright core inside a halo. The window does that by laying
the same line down two or three times, wider and fainter each pass, and the
core last. 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: an aeroplane glows into the colour its own trail is drawn
in, which is the colour a phosphor would have spread into. The halo goes over
the map, the grid and the background and over nothing else that was drawn.
.SH METERS AND SENSORS .SH METERS AND SENSORS
Two things on the ISM bands are worth naming rather than reporting as Two things on the ISM bands are worth naming rather than reporting as
hexadecimal. hexadecimal.

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, craft = Aircraft(icao="AE07D3", callsign="PRIME04", latitude=32.5,
longitude=-111.0, category="heavy") longitude=-111.0, category="heavy")
assert blip_for(craft).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}"

261
tests/test_themes.py Normal file
View file

@ -0,0 +1,261 @@
"""The colour themes, and the glow that goes with the vector ones.
A theme changes both drawings at once, because both read their colours out
of the same palette: the animation looks indices up in it directly and the
window asks it for a QColor. So the tests here are mostly about that
palette -- that it is rewritten rather than replaced, that every theme keeps
the aerodromes distinguishable from the aircraft, and that the halo goes
where light would go and nowhere else.
"""
import numpy as np
import pytest
from bandsaunter import flightmap as fm
from bandsaunter import themes
@pytest.fixture(autouse=True)
def back_to_night():
"""Every test leaves the program drawing the way it found it."""
yield
fm.set_theme(themes.DEFAULT_THEME)
# ---------------------------------------------------------------------------
# Choosing one
# ---------------------------------------------------------------------------
def test_every_theme_says_what_it_is():
for name, theme in themes.THEMES.items():
assert theme.name == name
assert theme.summary and not theme.summary.endswith(".")
assert len(theme.stops) >= 2
assert theme.stops[0][0] == 0.0
def test_a_theme_can_be_asked_for_by_name_or_by_what_it_looks_like():
assert themes.theme_named("phosphor").name == "phosphor"
assert themes.theme_named("green").name == "phosphor"
assert themes.theme_named("wargames").name == "digital"
assert themes.theme_named("BLUE").name == "digital"
assert themes.theme_named(" amber ").name == "amber"
def test_a_name_nobody_recognises_is_the_default_rather_than_a_refusal():
"""A theme is how the picture looks. Refusing to draw an evening's
flying because of a misspelt colour would be the wrong trade."""
assert themes.theme_named("puce").name == themes.DEFAULT_THEME
assert themes.theme_named("").name == themes.DEFAULT_THEME
assert themes.theme_named(None).name == themes.DEFAULT_THEME
def test_no_two_themes_share_a_name_or_an_alias():
seen = set()
for theme in themes.THEMES.values():
for word in (theme.name,) + tuple(theme.aliases):
assert word not in seen, word
seen.add(word)
# ---------------------------------------------------------------------------
# What setting one does to the palette
# ---------------------------------------------------------------------------
def test_the_palette_is_written_over_rather_than_replaced():
"""Both drawings and every one of their helpers hold a reference to
this array. A new one would leave half the program painting in the
colours of the theme before."""
before = fm.PALETTE
fm.set_theme("phosphor")
assert fm.PALETTE is before
assert tuple(fm.PALETTE[fm.BG]) != (14, 16, 22)
def test_setting_a_theme_says_which_one_it_settled_on():
assert fm.set_theme("norad").name == "digital"
assert fm.set_theme("puce").name == themes.DEFAULT_THEME
def test_the_default_theme_draws_exactly_what_it_always_drew():
"""The colours this program has always used, unchanged: an existing
recording redrawn today has to come out the same picture."""
fm.set_theme("phosphor")
fm.set_theme("night")
assert tuple(fm.PALETTE[fm.BG]) == (14, 16, 22)
assert tuple(fm.PALETTE[fm.INK]) == (196, 204, 218)
assert tuple(fm.PALETTE[fm.RAMP]) == (252, 96, 72)
assert tuple(fm.PALETTE[fm.RAMP + fm.RAMP_STEPS - 1]) == (158, 142, 255)
def _lab(rgb):
c = np.asarray(rgb, float) / 255.0
c = np.where(c > 0.04045, ((c + 0.055) / 1.055) ** 2.4, c / 12.92)
m = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505]])
xyz = (c @ m.T) / np.array([0.9505, 1.0, 1.089])
f = np.where(xyz > 0.008856, np.cbrt(xyz), 7.787 * xyz + 16 / 116)
return np.array([116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])])
def test_no_theme_lets_an_aerodrome_be_mistaken_for_an_aircraft():
"""The whole reason the aerodromes stopped being amber. Stated as the
distance rather than as the colour, so that a new theme cannot quietly
walk an aircraft back into the airports."""
for name in themes.THEMES:
fm.set_theme(name)
airport = _lab(fm.PALETTE[fm.AIRPORT])
apart = min(float(np.linalg.norm(airport - _lab(fm.PALETTE[fm.RAMP + i])))
for i in range(fm.RAMP_STEPS))
assert apart > 40, f"{name}: only {apart:.0f} units apart"
def test_a_phosphor_theme_reads_height_as_brightness():
"""One colour to spend, so it cannot be spent on hue. Low is dim and
high burns, which is the constraint those screens actually had."""
for name in ("digital", "phosphor", "amber", "red"):
theme = fm.set_theme(name)
assert theme.height_is_brightness
weights = [int(fm.PALETTE[fm.RAMP + i].astype(int).sum())
for i in range(fm.RAMP_STEPS)]
assert weights == sorted(weights), f"{name} is not monotonic"
assert weights[-1] > weights[0] * 3
def test_the_default_theme_reads_height_as_hue_instead():
fm.set_theme("night")
assert not fm.THEME.height_is_brightness
def test_every_theme_fills_the_palette_without_running_off_the_end():
for name in themes.THEMES:
fm.set_theme(name)
assert fm.PALETTE.shape == (256, 3)
assert fm.GLOW + 6 <= 256
# Nothing drawn with is left as the black the unused tail is.
for index in (fm.INK, fm.AIRPORT, fm.RAMP, fm.GROUND + 31):
assert fm.PALETTE[index].any(), (name, index)
# ---------------------------------------------------------------------------
# The glow
# ---------------------------------------------------------------------------
def _picture(width=80, height=60):
return np.full((height, width), fm.BG, dtype=np.uint8)
def test_the_default_theme_has_no_glow_at_all():
fm.set_theme("night")
img = _picture()
img[30, 10:70] = fm.RAMP + 20
assert np.array_equal(fm.bloom(img), img)
def test_a_line_on_a_vector_theme_gets_a_halo_either_side_of_it():
fm.set_theme("phosphor")
img = _picture()
img[30, 10:70] = fm.RAMP + 20
out = fm.bloom(img)
assert (out[30, 10:70] == fm.RAMP + 20).all(), "the core was painted over"
assert (out[29, 10:70] == fm.TRAIL + 20).all(), "no halo above the line"
assert (out[31, 10:70] == fm.TRAIL + 20).all(), "no halo below it"
assert (out[28, 10:70] == fm.OLD + 20).all(), "no second, fainter ring"
assert (out[27, 10:70] == fm.BG).all(), "the halo reaches too far"
def test_the_halo_is_the_colour_of_the_thing_that_cast_it():
"""A green aeroplane glows green and a red one red: the halo is the
aircraft's own dimmed colour, which is what a phosphor would spread."""
fm.set_theme("digital")
for step in (0, 12, 31):
img = _picture()
img[30, 10:70] = fm.RAMP + step
out = fm.bloom(img)
assert (out[29, 10:70] == fm.TRAIL + step).all()
def test_a_halo_never_paints_over_something_else_that_was_drawn():
"""A halo is what light does to the dark around a line. Painting it
over another line would be light doing something light does not do."""
fm.set_theme("phosphor")
img = _picture()
img[30, 10:70] = fm.RAMP + 20 # an aeroplane
img[29, 10:70] = fm.AIRPORT # an aerodrome right beside it
out = fm.bloom(img)
assert (out[29, 10:70] == fm.AIRPORT).all()
def test_the_halo_does_not_wrap_round_the_edge_of_the_picture():
"""Rolled and then cut: without the cut, a line down the left edge
would glow on the right edge of the picture."""
fm.set_theme("amber")
img = _picture()
img[:, 0] = fm.RAMP + 20
out = fm.bloom(img)
assert (out[:, 1] == fm.TRAIL + 20).all()
assert (out[:, -1] == fm.BG).all()
assert (out[:, -2] == fm.BG).all()
tall = _picture()
tall[0, :] = fm.AIRPORT
assert (fm.bloom(tall)[-1, :] == fm.BG).all()
def test_the_halo_goes_over_the_map_and_the_grid_but_not_the_aircraft():
fm.set_theme("digital")
img = np.full((60, 80), fm.GROUND + 10, dtype=np.uint8)
img[30, 40] = fm.INK
out = fm.bloom(img)
assert out[30, 41] != fm.GROUND + 10, "no halo over the map"
assert out[30, 40] == fm.INK
def test_the_nearer_ring_wins_where_two_meet():
"""Which is what happens on the tube as well."""
fm.set_theme("phosphor")
img = _picture()
img[30, 40] = fm.RAMP + 20
out = fm.bloom(img)
assert out[31, 40] == fm.TRAIL + 20 # near
assert out[32, 40] == fm.OLD + 20 # far
# ---------------------------------------------------------------------------
# What else a vector theme changes
# ---------------------------------------------------------------------------
def test_a_phosphor_theme_names_the_country_instead_of_drawing_its_flag():
"""A flag is half a dozen colours and a phosphor screen has one. Two
letters are what a display of the period would have done anyway."""
from bandsaunter.flags import FLAG_H, FLAG_W
fm.set_theme("night")
flagged = np.full((40, 60), fm.BG, dtype=np.uint8)
fm.draw_flag(flagged, 5, 5, "US")
patch = flagged[5:5 + FLAG_H, 5:5 + FLAG_W]
assert ((patch >= fm.FLAG) & (patch < fm.FLAG + 12)).any()
fm.set_theme("phosphor")
lettered = np.full((40, 60), fm.BG, dtype=np.uint8)
fm.draw_flag(lettered, 5, 5, "US")
patch = lettered[5:5 + FLAG_H, 5:5 + FLAG_W]
assert not ((patch >= fm.FLAG) & (patch < fm.FLAG + 12)).any()
assert (lettered == fm.DIM).any(), "the letters were not drawn either"
def test_a_vector_theme_pushes_the_map_underneath_well_back():
"""A tinted photograph of a county behind the vectors is the one thing
that stops a vector display looking like one."""
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
fm.set_theme("night")
plain = int(fm.dim_ground(levels, 0.7).max())
fm.set_theme("digital")
quiet = int(fm.dim_ground(levels, 0.7).max())
assert quiet < plain * 0.6, f"{quiet} is not much darker than {plain}"
def test_the_brightness_setting_still_does_something_on_a_vector_theme():
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
fm.set_theme("phosphor")
assert int(fm.dim_ground(levels, 1.0).max()) > \
int(fm.dim_ground(levels, 0.3).max())