diff --git a/README.md b/README.md index 09731fa..3b8e611 100644 --- a/README.md +++ b/README.md @@ -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 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 — for when the sky is busy. `t` toggles trails, `g` the map underneath, `[`/`]` 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 `]` 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 Two things on the ISM bands are worth naming rather than reporting as hex. diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index d785877..6739e85 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -8,8 +8,8 @@ and transcribing speech. # 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 # to two digits so versions sort as text. -VERSION_DATE = "2026-09-04" -VERSION_REVISION = 10 +VERSION_DATE = "2026-09-05" +VERSION_REVISION = 1 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aircraft.py b/bandsaunter/aircraft.py index 61546b9..fb55d1e 100644 --- a/bandsaunter/aircraft.py +++ b/bandsaunter/aircraft.py @@ -118,6 +118,7 @@ class AircraftOptions: recheck: bool = False basemap: bool = True airports: bool = True + theme: str = "night" map_brightness: int = 70 tile_url: str = "" @@ -355,6 +356,21 @@ OPTIONS: tuple[Setting, ...] = ( flags=("--airports",), off_flags=("--no-airports",), guidance="Turn it off for a picture with nothing but the aircraft on " "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", "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 " @@ -696,11 +712,17 @@ def watch(console, options: AircraftOptions, output_dir: str, registry = AircraftRegistry() book = FlightBook(online=options.lookup, 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, home=read_position(options.location), radius_nm=radius_in_nm(options) or 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.log_name = log.path.name if log is not None else "" if options.simulate: @@ -738,7 +760,11 @@ def watch(console, options: AircraftOptions, output_dir: str, threads = [threading.Thread(target=listening, daemon=True, 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( target=livemap.fetch_ground, args=(sky, options.tile_url), 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): """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() 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") console.print(f"[grey62]drawing {out_path.name}…[/grey62]") try: + set_theme(options.theme) drawn = animate(tracks, out_path, book=book, fps=options.fps, seconds=options.length, speed=options.speed, width=options.width, trail_seconds=options.trail, diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index f6a380f..d2a129d 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -196,6 +196,13 @@ examples: ad.add_argument("--window", action="store_true", help="open a window and show the aircraft on a map as " "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) # -- flights -------------------------------------------------------------- @@ -248,6 +255,13 @@ examples: fl.add_argument("--map-brightness", type=int, default=None, metavar="PERCENT", 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", default=None, help="do not mark the aerodromes under the flight paths") @@ -1095,6 +1109,8 @@ def cmd_adsb(args) -> int: options.basemap = args.basemap if args.schedules is not None: options.schedules = args.schedules + if getattr(args, "theme", None): + options.theme = args.theme if args.map: options.picture = Path(args.map).suffix.lstrip(".") or options.picture @@ -1147,6 +1163,8 @@ def cmd_flights(args) -> int: options.recheck = True if args.schedules is not None: options.schedules = args.schedules + if getattr(args, "theme", None): + options.theme = args.theme tracks = air.checked(console, options, tracks) book = FlightBook(online=args.lookup, schedules=air.schedule_names(options)) @@ -1197,6 +1215,8 @@ def cmd_flights(args) -> int: options.tile_url = args.tiles if args.map_brightness is not None: options.map_brightness = args.map_brightness + if getattr(args, "theme", None): + options.theme = args.theme if args.airports is not None: options.airports = args.airports if args.radius is not None: diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py index 3d3acce..d978069 100644 --- a/bandsaunter/flightmap.py +++ b/bandsaunter/flightmap.py @@ -33,6 +33,7 @@ from pathlib import Path import numpy as np +from . import themes as _themes from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of, distance_label, distance_nm, in_distance, in_speed, 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", "write_mp4", "fit", "PALETTE", "ffmpeg_available", "ground_for", "background", "label_lines", "draw_flag", + "set_theme", "theme", "THEME", "bloom", "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports", "showing", "fade_ramp", "GROUND_BRIGHTNESS"] @@ -67,6 +69,12 @@ FAINT = FLAG + 12 # the same 32 again, fainter still, for fading # steps the aircraft itself fades through. LABEL_INK = FAINT + 32 # the row grey, at the four strengths 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 GROUND_SHADES = 32 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, # green, cyan and violet on height -- and it is what an aeronautical chart # marks an aerodrome in anyway. -_FIXED = ((14, 16, 22), # background: night, not black - (38, 44, 58), # grid - (196, 204, 218), # ink - (120, 130, 148), # dim ink - (24, 28, 38), # panel - (255, 64, 200), # airports: magenta, and nothing else is - (70, 84, 110), # route lines - (255, 255, 255)) # white +# Which theme is being drawn. Everything below is built from it, so +# changing it changes both the animation and the window: they read the same +# palette, and the window looks the colours up in it rather than keeping any +# of its own. +THEME = _themes.THEMES[_themes.DEFAULT_THEME] -# Low is warm, high is cold: the convention every other aircraft map uses, so -# an altitude can be read off the picture without looking at the key. -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)), - (45_000.0, (158, 142, 255))) +_FIXED = (THEME.background, THEME.grid, THEME.ink, THEME.dim, THEME.panel, + THEME.airport, THEME.route, THEME.white) + +# Low is warm, high is cold on the default map: the convention every other +# 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.""" + stops = ALTITUDE_STOPS if stops is None else stops out = [] for i in range(steps): feet = CEILING_FT * i / (steps - 1) - low = ALTITUDE_STOPS[0] - high = ALTITUDE_STOPS[-1] - for a, b in zip(ALTITUDE_STOPS, ALTITUDE_STOPS[1:]): + low = stops[0] + high = stops[-1] + for a, b in zip(stops, stops[1:]): if a[0] <= feet <= b[0]: low, high = a, b 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, # so there is room to turn it up on a screen that needs it and down on one # that does not. -GROUND_LOW = (16, 19, 26) -GROUND_HIGH = (150, 164, 186) +GROUND_LOW = THEME.ground_low +GROUND_HIGH = THEME.ground_high # 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 @@ -145,12 +154,15 @@ GROUND_HIGH = (150, 164, 186) 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 = [] for i in range(steps): part = i / max(1, steps - 1) 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 @@ -187,16 +199,26 @@ def fade_level(strength: float) -> int: FADE_FACTORS = (1.0, 0.55, 0.30, 0.14) -def _palette() -> np.ndarray: - ramp = _ramp() +def _palette(theme=None) -> np.ndarray: + 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() - table = (list(_FIXED) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30) - + _ground_shades() + flags + _dimmed(ramp, 0.14) + table = (list(fixed) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30) + + _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 # 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:]: 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)) return np.array(table[:256], dtype=np.uint8) @@ -204,6 +226,32 @@ def _palette() -> np.ndarray: 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 # 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 @@ -215,6 +263,83 @@ FADE_STEPS = ((0.66, RAMP), (0.40, TRAIL), (0.18, OLD), (0.0, FAINT)) 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: """Which set of colours an aircraft at this strength is drawn from.""" 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 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))) return np.clip((np.asarray(levels, dtype=np.float64) * top / (GROUND_SHADES - 1)).round(), @@ -695,7 +824,9 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track], places.end() if clock: _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, @@ -937,10 +1068,15 @@ def draw_flag(img: np.ndarray, x: int, y: int, country: str, """ from .flags import FLAG_W, flag_for - rows = flag_for(country) + rows = flag_for(country) if THEME.flags else 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(), - GRID if level <= 0 else LABEL_INK + min(level, 3)) + plain if level <= 0 else LABEL_INK + min(level, 3)) return height, width = img.shape for row, line in enumerate(rows): diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py index 5c072e9..bb04950 100644 --- a/bandsaunter/livemap.py +++ b/bandsaunter/livemap.py @@ -337,7 +337,8 @@ class Sky: def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.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 self.unit = unit @@ -346,6 +347,10 @@ class Sky: self.radius_nm = radius_nm self.brightness = brightness 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.aircraft_seen = 0 self.started = time.time() @@ -364,6 +369,12 @@ class Sky: self._ground_for = None self._ground_box = None 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._settled = None @@ -397,6 +408,48 @@ class Sky: if self._wanted is not None and self._wanted[0] == key: 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: """Which map is in hand. Changes whenever a new one is fetched.""" with self._lock: @@ -586,8 +639,8 @@ def _build(): return None _name, QtCore, QtGui, QtWidgets, _signal = found - from .flightmap import (BG, GRID, GROUND, GROUND_SHADES, INK, PALETTE, - RAMP, _degrees, _grid_step) + from .flightmap import (AIRPORT, BG, GRID, GROUND, GROUND_SHADES, INK, + PALETTE, PANEL, RAMP, _degrees, _grid_step) Qt = QtCore.Qt QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer @@ -595,6 +648,9 @@ def _build(): QPainter, QPen, QPolygonF = QtGui.QPainter, QtGui.QPen, QtGui.QPolygonF 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") RGB888 = _enum(QImage, "Format", "Format_RGB888") ANTIALIAS = _enum(QPainter, "RenderHint", "Antialiasing") @@ -609,6 +665,35 @@ def _build(): def craft_colour(feet, alpha=255) -> QColor: 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): """The map, the aircraft on it, and a box beside each one.""" @@ -701,6 +786,7 @@ def _build(): if self.show_ground: self._draw_ground(painter, view) self._draw_graticule(painter, view) + self._draw_airports(painter, view) flying = self.sky.flying() if self.trails: for blip in flying: @@ -854,16 +940,79 @@ def _build(): _degrees(lon, "lon")) 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, strength: float = 1.0) -> None: points = self.sky.trail(blip.icao) if len(points) < 2: return - painter.setPen(QPen(craft_colour(blip.altitude_ft, - int(90 * strength)), 1.4)) path = QPolygonF([QPointF(*view.xy(lat, lon)) 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): """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 # drawn to the centre crosses the box and strikes out a line of # what it was drawn to point at. - painter.setPen(QPen(colour.lighter(120), 1.0)) - painter.drawLine(x, y, - int(max(dx, min(x, dx + box[0]))), - int(max(dy, min(y, dy + box[1])))) + to_x = int(max(dx, min(x, dx + box[0]))) + to_y = 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 \ else blip.icao self._draw_box(painter, dx, dy, box, lines, colour, title) @@ -950,10 +1103,23 @@ def _build(): return QPointF(x + side * cos + ahead * sin, 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.setBrush(colour) - painter.drawPolygon(QPolygonF([point(9, 0), point(-6, 5), - point(-3, 0), point(-6, -5)])) + painter.drawPolygon(shape) painter.setBrush(QColor(255, 255, 255, int(200 * strength))) 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, title: str) -> None: 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.setBrush(QColor(10, 12, 18, int(215 * (colour.alpha() / 255)))) - painter.drawRoundedRect(QRectF(bx, by, width, height), 3.0, 3.0) + painter.drawRoundedRect(shape, 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) faded = colour.alpha() painter.setPen(colour.lighter(135)) @@ -1185,6 +1362,13 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None: from . import basemap 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() if wanted is None: 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) +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: from .flightmap import GROUND_SHADES diff --git a/bandsaunter/themes.py b/bandsaunter/themes.py new file mode 100644 index 0000000..6697c95 --- /dev/null +++ b/bandsaunter/themes.py @@ -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] diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index fa72a83..6f79a23 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" 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 bandsaunter \- scan, record and identify radio signals with an RTL-SDR .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 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. +.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 Two things on the ISM bands are worth naming rather than reporting as hexadecimal. diff --git a/packaging/make-man.py b/packaging/make-man.py index e7f7e39..0f9549e 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -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 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. +.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 Two things on the ISM bands are worth naming rather than reporting as hexadecimal. diff --git a/tests/test_livemap.py b/tests/test_livemap.py index 3950621..3a5d238 100644 --- a/tests/test_livemap.py +++ b/tests/test_livemap.py @@ -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}" diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..456cac9 --- /dev/null +++ b/tests/test_themes.py @@ -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())