diff --git a/README.md b/README.md index 3b8e611..310264b 100644 --- a/README.md +++ b/README.md @@ -1042,6 +1042,56 @@ 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 line from a box to its aircraft is dashed, and is its own colour**, on +the animated pictures as well as here. It used to be drawn in the aircraft's +own colour, which made it the same colour as that aircraft's trail — and a straight solid line running out of an +aeroplane, in the colour of the path behind the aeroplane, reads as more path. +On a busy picture that is a heading nobody flew. Dashes and a neutral colour +say "this box belongs to that aeroplane" instead, which is all it was ever +meant to say. Qt measures a dash pattern in multiples of the pen's width, so +each pass of the glow divides the pattern by its own width; without that the +halo's dashes are three times the core's and the line comes out as beads. + +**The animation had no such line at all** until now — a label pushed out into +one of the outward rings by a crowd had nothing tying it to the aeroplane it +was about. It has one now, dashed and in the same colour, walked along the +line's own length rather than along whichever axis is longer so that a nearly +horizontal leader and a nearly vertical one get dashes of the same length +instead of one of them turning into a dotted line. + +**A red flag stands where the receiver is**, on the window and on the animated +pictures alike, from the coordinates in the settings (`--at`, or **Receiver +position** in the menu). The foot of the pole is the position and the pennant +flies up and to the right of it, so nothing the flag is made of covers the +place it points at. It is pure red in every theme — "you are here" is the one +mark whose meaning must not change with the colours, and pure red is both the +brightest red there is and the one furthest from every altitude colour in +every theme. A softer red sat close enough to a low aeroplane on the default +map, and to a mid-altitude one on the red theme, to be taken for one. + +The flag is drawn **only where the receiver was actually told where it is**. +Without a position the middle of the picture is worked out from whatever flew +past, which is not a place anybody is standing, and a flag on it would say +that somebody is. + +**Range rings** put faint discs at a quarter, a half and three quarters of the +radius, concentric on the receiver and each labelled with its distance. They +are translucent and they stack, so the ground inside the innermost is lifted +three times, the next twice, the outer once. What that gives is a sense of how +far away something is without measuring anything: an aircraft two shades in is +about halfway to the edge of what this receiver hears. + +An indexed picture cannot blend, so "translucent" in the animation means +moving the ground under the disc a step or two up its own ramp of shades — +which keeps the coastline and the roads visible through it, where a flat wash +of one colour would not. The window has real alpha and simply paints one. + +They need a receiver position and a radius and are not drawn without both, and +each is a separate setting: `--rings` / `--no-rings` for the pictures, +`--window-rings` / `--no-window-rings` for the window, both also in the ADS-B +options menu. A picture is studied and a window is glanced at, and the rings +help one more than the other depending which you are doing. + **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 diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index 6739e85..6bbaf9c 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -8,7 +8,7 @@ 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-05" +VERSION_DATE = "2026-09-06" VERSION_REVISION = 1 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/aircraft.py b/bandsaunter/aircraft.py index fb55d1e..d75fb8f 100644 --- a/bandsaunter/aircraft.py +++ b/bandsaunter/aircraft.py @@ -118,6 +118,8 @@ class AircraftOptions: recheck: bool = False basemap: bool = True airports: bool = True + rings: bool = True + window_rings: bool = True theme: str = "night" map_brightness: int = 70 tile_url: str = "" @@ -356,6 +358,26 @@ 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("rings", "Range rings on the pictures", "Drawing", "bool", + "faint discs at a quarter, a half and three quarters of the radius", + "Concentric on the receiver and translucent, so that they stack: the " + "ground inside the innermost is lifted three times, the next twice, " + "the outer once. What that gives is a sense of how far away a thing " + "is without measuring anything -- an aircraft two shades in is about " + "halfway to the edge of what this receiver hears. Each is labelled " + "with its distance. They need a receiver position and a radius, and " + "are not drawn without both.", + flags=("--rings",), off_flags=("--no-rings",), + guidance="Turn it off for a picture with nothing on it but the " + "aircraft and the ground."), + O("window_rings", "Range rings in the window", "Drawing", "bool", + "the same discs on the realtime display", + "The same rings as the pictures get, on the window instead. They are " + "separate settings because the two are looked at differently: a " + "picture is studied and a window is glanced at, and the rings help " + "one more than the other depending on which you are doing.", + flags=("--window-rings",), off_flags=("--no-window-rings",), + guidance="Turn it off if the window is busy enough already."), 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 " @@ -722,7 +744,8 @@ def watch(console, options: AircraftOptions, output_dir: str, radius_nm=radius_in_nm(options) or 100.0, brightness=max(10, options.map_brightness) / 100.0, fade=max(0.0, options.fade), - airports=options.airports) + airports=options.airports, + rings=options.window_rings) sky.started = started sky.log_name = log.path.name if log is not None else "" if options.simulate: @@ -932,6 +955,7 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None): tile_url=options.tile_url, brightness=max(10, options.map_brightness) / 100.0, airports=options.airports, + rings=options.rings, radius_nm=radius_in_nm(options), centre=read_position(options.location)) except (OSError, RuntimeError, ValueError) as exc: diff --git a/bandsaunter/cli.py b/bandsaunter/cli.py index d2a129d..8324bd1 100755 --- a/bandsaunter/cli.py +++ b/bandsaunter/cli.py @@ -196,6 +196,19 @@ 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("--rings", dest="rings", action="store_true", + default=None, + help="faint discs at a quarter, a half and three " + "quarters of the radius, labelled with the distance") + ad.add_argument("--no-rings", dest="rings", action="store_false", + default=None, + help="draw no range rings on the map") + ad.add_argument("--window-rings", dest="window_rings", + action="store_true", default=None, + help="range rings on the realtime window too") + ad.add_argument("--no-window-rings", dest="window_rings", + action="store_false", default=None, + help="no range rings on the realtime window") ad.add_argument("--theme", default=None, metavar="NAME", choices=("night", "digital", "phosphor", "amber", "red", "blue", "green", "orange", "wargames", "norad", @@ -255,6 +268,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("--rings", dest="rings", action="store_true", + default=None, + help="faint discs at a quarter, a half and three " + "quarters of the radius, labelled with the distance") + fl.add_argument("--no-rings", dest="rings", action="store_false", + default=None, + help="draw no range rings on the map") fl.add_argument("--theme", default=None, metavar="NAME", choices=("night", "digital", "phosphor", "amber", "red", "blue", "green", "orange", "wargames", "norad", @@ -1111,6 +1131,10 @@ def cmd_adsb(args) -> int: options.schedules = args.schedules if getattr(args, "theme", None): options.theme = args.theme + if getattr(args, "rings", None) is not None: + options.rings = args.rings + if getattr(args, "window_rings", None) is not None: + options.window_rings = args.window_rings if args.map: options.picture = Path(args.map).suffix.lstrip(".") or options.picture @@ -1165,6 +1189,10 @@ def cmd_flights(args) -> int: options.schedules = args.schedules if getattr(args, "theme", None): options.theme = args.theme + if getattr(args, "rings", None) is not None: + options.rings = args.rings + if getattr(args, "window_rings", None) is not None: + options.window_rings = args.window_rings tracks = air.checked(console, options, tracks) book = FlightBook(online=args.lookup, schedules=air.schedule_names(options)) diff --git a/bandsaunter/flightmap.py b/bandsaunter/flightmap.py index d978069..db13f97 100644 --- a/bandsaunter/flightmap.py +++ b/bandsaunter/flightmap.py @@ -42,7 +42,8 @@ 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", + "set_theme", "theme", "THEME", "bloom", "draw_home", + "draw_rings", "RING_STEPS", "ring_labels", "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports", "showing", "fade_ramp", "GROUND_BRIGHTNESS"] @@ -75,6 +76,17 @@ FLAG_FADED = LABEL_INK + 4 # three dimmer sets of the twelve flag colours # 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 +LEADER = GLOW + 6 # the line from a box to its aircraft, and its halo +# Where the receiver is: a red flag, and red in every theme. "You are here" +# is the one mark on the picture whose meaning must not change with the +# colours, and it is the only thing on the map that is not an aircraft, an +# aerodrome or the ground. +HOME = LEADER + 3 +# Pure red rather than a softened one. The softer reds sat close enough to +# a low aeroplane on the default map, and to a mid-altitude one on the red +# theme, to be taken for one; this is the brightest red there is and the one +# furthest from every altitude colour in every theme. +HOME_RED = (255, 0, 0) RAMP_STEPS = 32 GROUND_SHADES = 32 TRANSPARENT = 255 # never drawn with: it means "as the frame before" @@ -219,6 +231,9 @@ def _palette(theme=None) -> np.ndarray: for colour in (fixed[AIRPORT], fixed[INK], fixed[WHITE]): table += [_dimmed([colour], part)[0], _dimmed([colour], part * part)[0]] + for colour in (theme.leader, HOME_RED): + table += [colour, _dimmed([colour], part)[0], + _dimmed([colour], part * part)[0]] table += [(0, 0, 0)] * (256 - len(table)) return np.array(table[:256], dtype=np.uint8) @@ -280,6 +295,9 @@ def _halo_tables() -> tuple: for i, colour in enumerate((AIRPORT, INK, WHITE)): near[colour] = GLOW + i * 2 far[colour] = GLOW + i * 2 + 1 + for colour in (LEADER, HOME): + near[colour] = colour + 1 + far[colour] = colour + 2 # 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. @@ -386,6 +404,69 @@ def _line(img: np.ndarray, x0: int, y0: int, x1: int, y1: int, y0 += sy +# The flag at the receiver: a pole standing on the spot, and a pennant +# flying off the top of it. Drawn as a shape rather than as a dot because +# the spot itself has to stay legible -- the point of the pole is the +# position, and a blob would put the position somewhere inside itself. +HOME_POLE = 17 # how tall the pole stands, in pixels +HOME_FLY = 12 # how far the pennant reaches from the pole +HOME_DROP = 8 # and how far down its trailing edge comes + + +def draw_home(img: np.ndarray, x: int, y: int, colour: int = None) -> None: + """A flag on the spot the receiver was told it is standing on. + + The foot of the pole is the position: the pennant flies to the right of + it and above it, so that nothing the flag is made of covers the place it + is pointing at. + """ + colour = HOME if colour is None else colour + height, width = img.shape + top = y - HOME_POLE + _line(img, x, y, x, top, colour) + # A filled triangle, drawn a row at a time: at the top it reaches the + # whole fly, and by the bottom of the drop it has come back to the pole. + for row in range(HOME_DROP + 1): + reach = int(round(HOME_FLY * (1.0 - row / max(1, HOME_DROP)))) + yy = top + row + if not 0 <= yy < height or reach <= 0: + continue + x0, x1 = max(0, x + 1), min(width, x + 1 + reach) + if x1 > x0: + img[yy, x0:x1] = colour + + +# The dashes on the line from a label to its aircraft, in pixels on and off. +# The same pattern the window uses, so the two look like the same program. +LEADER_DASH = (5, 4) + + +def _dashed(img: np.ndarray, x0: int, y0: int, x1: int, y1: int, + colour: int, pattern=LEADER_DASH) -> None: + """A dashed straight line, walked at an even rate along its own length. + + Stepped along the line rather than along whichever axis is longer, so + that a nearly-horizontal leader and a nearly-vertical one come out with + dashes of the same length instead of one of them turning into a dotted + line. + """ + on, off = (int(pattern[0]), int(pattern[1])) if pattern else (1, 0) + span = math.hypot(x1 - x0, y1 - y0) + if span < 1.0: + return + height, width = img.shape + cycle = max(1, on + off) + steps = int(span) + for step in range(steps + 1): + if step % cycle >= on: + continue + part = step / span + x = int(round(x0 + (x1 - x0) * part)) + y = int(round(y0 + (y1 - y0) * part)) + if 0 <= x < width and 0 <= y < height: + img[y, x] = colour + + def _disc(img: np.ndarray, x: int, y: int, radius: int, colour: int) -> None: height, width = img.shape r = max(0, int(radius)) @@ -572,7 +653,8 @@ def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS): def background(view: Projection, title: str = "", airports=(), unit: str = DEFAULT_SPEED_UNIT, ground=None, attribution: str = "", - brightness: float = GROUND_BRIGHTNESS) -> np.ndarray: + brightness: float = GROUND_BRIGHTNESS, + home=None, rings: float = 0.0) -> np.ndarray: """The map without anything flying on it: ground, grid, scale, key, title. ``ground`` is the real map underneath, as brightness levels covering the @@ -616,6 +698,19 @@ def background(view: Projection, title: str = "", airports=(), _box(img, x - 1, y - 1, x + 1, y + 1, AIRPORT, fill=True) draw_text(img, x + 6, y - 3, name, AIRPORT) + # After everything else on the ground, and harmless there: the rings + # only lift pixels that are still map or still empty, so the grid, the + # aerodromes and their names come through them untouched. + if rings: + draw_rings(img, view, home, rings, unit) + + # Last of the things on the ground, so the flag stands over the + # aerodromes rather than under them: it is the one position on the + # picture that was not worked out from anything received. + if home is not None and view.inside(home[0], home[1]): + x, y = view.xy(home[0], home[1]) + draw_home(img, x, y) + if title: draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK) if attribution: @@ -627,6 +722,108 @@ def background(view: Projection, title: str = "", airports=(), return img +# Where the range rings go, as fractions of the radius being drawn, and how +# much each one lifts the ground under it. +RING_STEPS = (0.25, 0.50, 0.75) +RING_LIFT = 2 # ground shades, added once per disc + + +def draw_rings(img: np.ndarray, view: Projection, home, radius_nm: float, + unit: str = DEFAULT_SPEED_UNIT) -> None: + """Filled discs at a quarter, a half and three quarters of the radius. + + Concentric on the receiver and translucent, so they stack: the ground + inside the innermost is lifted three times, the next twice, the outer + once. What that gives is a sense of how far away a thing is without + measuring anything -- an aircraft two shades in is about halfway to the + edge of what this receiver hears. + + An indexed picture cannot blend, so "translucent" here means moving the + ground under the disc a step or two up its own ramp of shades. That + keeps the coastline and the roads visible through it, which a flat wash + of one colour would not. + """ + if home is None or radius_nm <= 0: + return + height, width = img.shape + # Only the part of the canvas the map is actually drawn on. The title + # sits above it and there are margins either side, and stretching the + # view's latitudes over the whole picture would put the rings in the + # wrong place and make them the wrong size. + rows = slice(view.top, min(height, view.top + view.height)) + columns = slice(view.left, min(width, view.left + view.width)) + patch = img[rows, columns] + if patch.size == 0: + return + # The distance from the receiver to every pixel of it, worked out once: + # the rings do not move, so this is the only time it has to be done. + lats = view.north - (view.north - view.south) * ( + np.arange(patch.shape[0]) + 0.5) / view.height + lons = view.west + (view.east - view.west) * ( + np.arange(patch.shape[1]) + 0.5) / view.width + away = _distance_field(home, lats, lons) + + ground = (patch >= GROUND) & (patch < GROUND + GROUND_SHADES) + empty = patch == BG + for part in sorted(RING_STEPS, reverse=True): + inside = away <= radius_nm * part + shade = np.where(ground, patch.astype(np.int16) - GROUND, 0) + lifted = np.clip(shade + RING_LIFT, 0, GROUND_SHADES - 1) + patch[:] = np.where(inside & (ground | empty), + (GROUND + lifted).astype(np.uint8), patch) + # Whatever was empty is ground now, so the next disc lifts it again + # rather than starting it over. + ground = ground | (inside & empty) + empty = empty & ~inside + + for nm, text in ring_labels(radius_nm, unit): + text = text.upper() # the bitmap font here has no lower case + edge = _ring_top(view, home, nm) + if edge is None: + continue + x, y = edge + x -= text_width(text) // 2 + if 1 <= y < height - GLYPH_H and 0 <= x < width - text_width(text): + draw_text(img, x, y + 2, text, DIM) + + +def ring_labels(radius_nm: float, unit: str = DEFAULT_SPEED_UNIT) -> list: + """What each ring is called, and how far out it is in nautical miles. + + In whatever unit the speeds are in: miles an hour beside a ring measured + in nautical miles would be two different miles on one picture. Shared + by both drawings so that the window and the pictures cannot come to + different numbers for the same ring. + """ + out = [] + for part in RING_STEPS: + nm = radius_nm * part + out.append((nm, f"{in_distance(nm, unit):.0f} {distance_label(unit)}")) + return out + + +def _distance_field(home, lats, lons) -> np.ndarray: + """How far every pixel of a picture is from one place, in nautical miles.""" + lat0, lon0 = math.radians(home[0]), math.radians(home[1]) + phi = np.radians(lats)[:, None] + lam = np.radians(lons)[None, :] + dphi = phi - lat0 + dlam = lam - lon0 + a = (np.sin(dphi / 2) ** 2 + + math.cos(lat0) * np.cos(phi) * np.sin(dlam / 2) ** 2) + return 2 * 3440.065 * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0))) + + +def _ring_top(view: Projection, home, nm: float): + """Where the top of a ring of this radius falls on the picture.""" + from .flightlog import move + + north = move(home[0], home[1], 0.0, nm) + if not view.inside(north[0], north[1]): + return None + return view.xy(north[0], north[1]) + + def _scale_bar(img: np.ndarray, view: Projection, unit: str = DEFAULT_SPEED_UNIT) -> None: """A bar of a round number of miles, for judging distance. @@ -1049,6 +1246,20 @@ def _label(img: np.ndarray, x: int, y: int, track: Track, now, # way out -- which left the brightest thing on that part of the picture # being the one aeroplane nothing had been heard from. level = fade_level(strength) + # A line from the label to the aircraft it belongs to, dashed and in its + # own colour. The window has always had one; here there was nothing at + # all, and a label pushed out into one of the rings by a crowd had + # nothing tying it to the aeroplane it was about. Dashed, and not in + # the aircraft's colour, because a solid line running out of an + # aeroplane in the colour of the path behind it reads as more path. + # + # To the near edge of the label rather than into the middle of it: a + # leader drawn to the centre crosses the words and strikes out a line of + # what it was drawn to point at. + _dashed(img, x, y, + int(max(left, min(x, left + span))), + int(max(top, min(y, top + tall))), + LEADER if level <= 0 else LEADER + min(level, 2)) draw_text(img, left, top, track.name, colour) at = top + GLYPH_H + 2 for text, country in rows: @@ -1414,7 +1625,7 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0, ground: bool = False, fetch=None, tile_url: str = "", radius_nm: float = 0.0, centre=None, brightness: float = GROUND_BRIGHTNESS, - airports: bool = False, ask=None, + airports: bool = False, ask=None, rings: bool = False, fade: float = 0.0) -> Animation | None: """Draw the whole log as a moving map. @@ -1475,7 +1686,15 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0, if one[0] not in seen] base = background(view, title=heading, airports=marked, unit=unit, ground=levels, attribution=credit, - brightness=brightness) + brightness=brightness, + # Only where the receiver was actually told where it + # is. A middle worked out from whatever flew past is + # not a place anybody is standing, and a flag on it + # would say that somebody is. + home=centre, + # The rings are measured from the radius asked for, + # so they mean nothing without one. + rings=radius_nm if rings and centre else 0.0) canvas_w, canvas_h = canvas_size(view) base = _pad_to(base, canvas_w, canvas_h) diff --git a/bandsaunter/livemap.py b/bandsaunter/livemap.py index bb04950..51592cb 100644 --- a/bandsaunter/livemap.py +++ b/bandsaunter/livemap.py @@ -26,6 +26,7 @@ import numpy as np from .flags import iso_for from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm, in_distance, in_speed, speed_label) +from .flightmap import LEADER_DASH as _LEADER_DASH from .flightmap import Projection, altitude_step from .ui import compass @@ -34,7 +35,8 @@ from .ui import compass # so that importing it costs nothing on a machine with no Qt on it, and so # they cannot be listed here. __all__ = ["available", "binding", "show", "fetch_ground", "Sky", "Blip", - "blip_for", "place_box", "glide", "wrap_value", "WRAP_CHARS", + "blip_for", "place_box", "glide", "dash_for", "wrap_value", + "WRAP_CHARS", "MISSING_QT"] BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2") @@ -83,6 +85,26 @@ FLAG_PIXELS = 12 # Below this an aircraft keeps its symbol and loses its box. LABEL_WHILE = 0.40 +# The dashes on the line from a box to its aircraft, in pixels on and off. +# Taken from the drawing that also uses it rather than written down twice: +# the two pictures are meant to look like the same program, and two copies +# of a number are two chances to change only one of them. +LEADER_DASH = tuple(float(step) for step in _LEADER_DASH) + + +def dash_for(pattern, width: float) -> list: + """A dash pattern in pixels, as the pen drawing it wants to be told. + + Qt measures a dash pattern in multiples of the pen's own width, so the + same pattern handed to a wide pen and a narrow one gives dashes of + different lengths. A glowing line is the same line drawn two or three + times at different widths, so without this its halo would have dashes + three times the length of its core and the line would come out as beads + rather than as a dashed line. + """ + width = max(0.1, float(width)) + return [max(0.1, float(step) / width) for step in pattern] + # How long a box takes to swing across when it has to move, and how often to # repaint while one is on its way. The ordinary redraw is five times a # second, which is plenty for aeroplanes -- they move a pixel or two between @@ -338,7 +360,7 @@ 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, - airports: bool = False): + airports: bool = False, rings: bool = False): import threading self.unit = unit @@ -351,6 +373,7 @@ class Sky: # 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.show_rings = rings self.frames = 0 self.aircraft_seen = 0 self.started = time.time() @@ -639,8 +662,9 @@ def _build(): return None _name, QtCore, QtGui, QtWidgets, _signal = found - from .flightmap import (AIRPORT, BG, GRID, GROUND, GROUND_SHADES, INK, - PALETTE, PANEL, RAMP, _degrees, _grid_step) + from .flightmap import (AIRPORT, BG, DIM, GRID, GROUND, GROUND_SHADES, + HOME, INK, LEADER, PALETTE, PANEL, RAMP, + _degrees, _grid_step) Qt = QtCore.Qt QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer @@ -665,7 +689,8 @@ 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: + def glow_line(painter, colour, width: float, draw, core=True, + dash=None) -> 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 @@ -678,21 +703,33 @@ def _build(): 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. + + ``dash`` is a pattern in pixels. Qt measures a dash pattern in pen + widths, so each pass divides the pattern by its own width: without + that, the halo's dashes would be three times the length of the + core's and the line would come out as beads rather than as a dashed + line. """ from .flightmap import THEME + def pen_for(colour, width): + pen = QPen(colour, width) + if dash: + pen.setDashPattern(dash_for(dash, width)) + return pen + 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 = pen_for(faint, width + ring * 2.4) pen.setCapStyle(ROUND_CAP) pen.setJoinStyle(ROUND_JOIN) draw(pen) if core: - draw(QPen(colour, width)) + draw(pen_for(colour, width)) class SkyView(QtWidgets.QWidget): """The map, the aircraft on it, and a box beside each one.""" @@ -734,7 +771,12 @@ def _build(): from .flightlog import box_around middle = self.sky.centre() - if middle is None: + if middle is None or self.sky.radius_nm <= 0: + # A radius of nothing is a box of nothing, and every pixel + # of the window then maps to the same point: the grid asks + # for a line every fraction of a degree across a span of + # zero, and the drawing dies rather than being unreadable. + # There is nothing to show, which is what None means here. return None south, west, north, east = box_around(middle[0], middle[1], self.sky.radius_nm) @@ -785,8 +827,13 @@ def _build(): return if self.show_ground: self._draw_ground(painter, view) + self._draw_rings(painter, view) self._draw_graticule(painter, view) self._draw_airports(painter, view) + # Over the aerodromes and under the aircraft: it is the one + # position on the picture that was not worked out from anything + # received, and the one thing on it that never moves. + self._draw_home(painter, view) flying = self.sky.flying() if self.trails: for blip in flying: @@ -999,6 +1046,90 @@ def _build(): if code: painter.drawText(x + 7, y + 4, code) + def _draw_rings(self, painter, view) -> None: + """Filled discs at a quarter, a half and three quarters of the + radius, concentric on the receiver. + + Translucent and stacked, so the ground inside the innermost is + lifted three times and the outermost once. What that gives is a + sense of how far away a thing is without measuring anything: an + aircraft two shades in is about halfway to the edge of what this + receiver hears. + """ + from .flightmap import RING_STEPS, ring_labels + + home = self.sky.home + radius = self.sky.radius_nm + if not self.sky.show_rings or home is None or radius <= 0: + return + from .flightlog import move + + tint = rgb(GROUND + GROUND_SHADES - 1, 22) + painter.setPen(NO_PEN) + painter.setBrush(tint) + middle = QPointF(*view.xy(home[0], home[1])) + painter.setFont(self.text_font) + for part in sorted(RING_STEPS, reverse=True): + nm = radius * part + north = view.xy(*move(home[0], home[1], 0.0, nm)) + east = view.xy(*move(home[0], home[1], 90.0, nm)) + across = abs(east[0] - middle.x()) + down = abs(north[1] - middle.y()) + if across < 2 or down < 2: + continue + painter.setPen(NO_PEN) + painter.setBrush(tint) + painter.drawEllipse(middle, across, down) + for nm, text in ring_labels(radius, self.sky.unit): + north = view.xy(*move(home[0], home[1], 0.0, nm)) + if not (0 <= north[0] < self.width() + and 0 <= north[1] < self.height()): + continue + wide = QtGui.QFontMetrics(self.text_font).horizontalAdvance(text) + painter.setPen(rgb(DIM)) + painter.drawText(int(north[0] - wide / 2), + int(north[1]) + 12, text) + + def _draw_home(self, painter, view) -> None: + """A flag on the spot the receiver was told it is standing on. + + Only where it was actually told. The middle of the picture is + otherwise worked out from whatever flew past, which is not a + place anybody is standing, and a flag on it would say somebody + is. + + The foot of the pole is the position and the pennant flies up + and to the right of it, so that nothing the flag is made of + covers the place it points at. + """ + from .flightmap import HOME_DROP, HOME_FLY, HOME_POLE + + home = self.sky.home + if home is None or not view.inside(home[0], home[1]): + return + x, y = view.xy(home[0], home[1]) + colour = rgb(HOME) + top = y - HOME_POLE + + def pole(pen): + painter.setPen(pen) + painter.drawLine(x, y, x, top) + + glow_line(painter, colour, 2.0, pole) + pennant = QPolygonF([QPointF(x + 1, top), + QPointF(x + 1 + HOME_FLY, top), + QPointF(x + 1, top + HOME_DROP)]) + + def outline(pen): + painter.setPen(pen) + painter.setBrush(_NO_BRUSH) + painter.drawPolygon(pennant) + + glow_line(painter, colour, 0.1, outline, core=False) + painter.setPen(NO_PEN) + painter.setBrush(colour) + painter.drawPolygon(pennant) + def _draw_trail(self, painter, view, blip: Blip, strength: float = 1.0) -> None: points = self.sky.trail(blip.icao) @@ -1052,7 +1183,13 @@ def _build(): painter.setPen(pen) painter.drawLine(x, y, to_x, to_y) - glow_line(painter, colour.lighter(120), 1.0, leader) + # Its own colour, and dashed: neither of which it used to be. + # Drawn in the aircraft's own colour it came out the same colour + # as that aircraft's trail, and a straight solid line running + # out of an aeroplane in the colour of the path behind the + # aeroplane reads as more path -- a heading nobody flew. + glow_line(painter, rgb(LEADER, _alpha(strength)), 1.0, leader, + dash=LEADER_DASH) title = f"{blip.callsign} {blip.icao}" if blip.callsign \ else blip.icao self._draw_box(painter, dx, dy, box, lines, colour, title) diff --git a/bandsaunter/themes.py b/bandsaunter/themes.py index 6697c95..fba9639 100644 --- a/bandsaunter/themes.py +++ b/bandsaunter/themes.py @@ -49,6 +49,11 @@ class Theme: panel: tuple airport: tuple # aerodromes, and nothing else route: tuple # the line between two airports + # The line from an information box to the aircraft it belongs to. Its + # own colour on purpose: drawn in the aircraft's colour it was the same + # colour as that aircraft's trail, and a straight line from an aeroplane + # in the colour of the path behind the aeroplane reads as more path. + leader: tuple white: tuple # (feet, colour) stops the 32 altitude colours are interpolated between. stops: tuple @@ -87,6 +92,7 @@ NIGHT = Theme( panel=(24, 28, 38), airport=(255, 64, 200), route=(70, 84, 110), + leader=(148, 156, 174), 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)), @@ -109,6 +115,7 @@ DIGITAL = Theme( panel=(0, 10, 20), airport=(255, 176, 64), route=(0, 70, 120), + leader=(104, 128, 150), 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)), @@ -136,6 +143,7 @@ PHOSPHOR = Theme( panel=(0, 12, 5), airport=(255, 184, 72), route=(0, 76, 34), + leader=(112, 148, 120), 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)), @@ -162,6 +170,7 @@ AMBER = Theme( panel=(14, 8, 0), airport=(120, 220, 255), route=(84, 48, 0), + leader=(168, 146, 112), 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)), @@ -187,6 +196,7 @@ RED = Theme( panel=(14, 2, 2), airport=(120, 220, 255), route=(88, 14, 14), + leader=(170, 130, 124), 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)), diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index 6f79a23..81b86e4 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-05" "bandsaunter 2026-09-05_01" "User Commands" +.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_01" "User Commands" .SH NAME bandsaunter \- scan, record and identify radio signals with an RTL-SDR .SH SYNOPSIS @@ -1621,6 +1621,40 @@ 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 What is on the picture besides the aircraft +The line from an information box to the aircraft it belongs to is dashed and +is its own colour, in the window and in the animated pictures alike. Drawn in +the aircraft's own colour it came out the same colour as that aircraft's +trail, and a straight solid line running out of an aeroplane in the colour of +the path behind the aeroplane reads as more path, which on a busy picture is a +heading nobody flew. The animation had no such line at all until now, so a +label pushed out into one of the outward rings by a crowd had nothing tying it +to the aeroplane it was about. +.PP +A red flag stands where the receiver is, on the window and on the animated +pictures alike, taken from the coordinates in the settings. The foot of the +pole is the position and the pennant flies up and to the right of it, so that +nothing the flag is made of covers the place it points at. It is pure red in +every theme, that being the one mark on the picture whose meaning must not +change with the colours as well as the red furthest from every altitude +colour. It is drawn only where the receiver was actually told where it is: a +middle worked out from whatever flew past is not a place anybody is standing. +.SS Range rings +.B \-\-rings +puts faint discs at a quarter, a half and three quarters of the radius, +concentric on the receiver and each labelled with its distance. They are +translucent and they stack, so the ground inside the innermost is lifted three +times, the next twice and the outer once; what that gives is a sense of how +far away a thing is without measuring anything, an aircraft two shades in +being about halfway to the edge of what this receiver hears. An indexed +picture cannot blend, so translucent there means moving the ground under the +disc a step or two up its own ramp of shades, which keeps the coastline and +the roads visible through it. +.PP +They need a receiver position and a radius and are not drawn without both. +.B \-\-window\-rings +is the same thing on the realtime window, kept as a separate setting because +a picture is studied and a window is glanced at. .SS Themes .BI \-\-theme " NAME" changes the window and the animated pictures together, since both read their diff --git a/packaging/make-man.py b/packaging/make-man.py index 0f9549e..e7b69bf 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -1014,6 +1014,40 @@ 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 What is on the picture besides the aircraft +The line from an information box to the aircraft it belongs to is dashed and +is its own colour, in the window and in the animated pictures alike. Drawn in +the aircraft's own colour it came out the same colour as that aircraft's +trail, and a straight solid line running out of an aeroplane in the colour of +the path behind the aeroplane reads as more path, which on a busy picture is a +heading nobody flew. The animation had no such line at all until now, so a +label pushed out into one of the outward rings by a crowd had nothing tying it +to the aeroplane it was about. +.PP +A red flag stands where the receiver is, on the window and on the animated +pictures alike, taken from the coordinates in the settings. The foot of the +pole is the position and the pennant flies up and to the right of it, so that +nothing the flag is made of covers the place it points at. It is pure red in +every theme, that being the one mark on the picture whose meaning must not +change with the colours as well as the red furthest from every altitude +colour. It is drawn only where the receiver was actually told where it is: a +middle worked out from whatever flew past is not a place anybody is standing. +.SS Range rings +.B \-\-rings +puts faint discs at a quarter, a half and three quarters of the radius, +concentric on the receiver and each labelled with its distance. They are +translucent and they stack, so the ground inside the innermost is lifted three +times, the next twice and the outer once; what that gives is a sense of how +far away a thing is without measuring anything, an aircraft two shades in +being about halfway to the edge of what this receiver hears. An indexed +picture cannot blend, so translucent there means moving the ground under the +disc a step or two up its own ramp of shades, which keeps the coastline and +the roads visible through it. +.PP +They need a receiver position and a radius and are not drawn without both. +.B \-\-window\-rings +is the same thing on the realtime window, kept as a separate setting because +a picture is studied and a window is glanced at. .SS Themes .BI \-\-theme " NAME" changes the window and the animated pictures together, since both read their diff --git a/tests/test_flightmap.py b/tests/test_flightmap.py index 1d127fd..2169937 100644 --- a/tests/test_flightmap.py +++ b/tests/test_flightmap.py @@ -1560,3 +1560,294 @@ def test_an_airport_cannot_be_mistaken_for_an_aircraft(): assert apart > 40, (f"the airport colour is {apart:.0f} units from the " f"nearest altitude colour; under about 25 they read " f"as the same colour") + + +# --------------------------------------------------------------------------- +# The flag on the receiver +# --------------------------------------------------------------------------- + +def test_the_background_flies_a_flag_where_the_receiver_was_told_it_is(): + track = straight() + view = fm.fit([track], width=600) + middle = ((view.south + view.north) / 2, (view.west + view.east) / 2) + plain = fm.background(view, unit="knots") + flagged = fm.background(view, unit="knots", home=middle) + assert not (plain == fm.HOME).any() + assert (flagged == fm.HOME).any(), "no flag was drawn" + # The foot of the pole is the position it is pointing at. + x, y = view.xy(*middle) + assert flagged[y, x] == fm.HOME + + +def test_no_flag_where_nobody_said_the_receiver_is(): + """The middle is otherwise worked out from whatever flew past, which is + not a place anybody is standing, and a flag on it would say one is.""" + track = straight() + view = fm.fit([track], width=600) + assert not (fm.background(view, unit="knots") == fm.HOME).any() + + +def test_a_receiver_outside_the_picture_is_not_flagged_at_its_edge(): + track = straight() + view = fm.fit([track], width=600) + away = (view.north + 20.0, view.east + 20.0) + assert not (fm.background(view, unit="knots", home=away) == fm.HOME).any() + + +def test_the_animation_flies_the_flag_only_where_it_was_given_a_centre(tmp_path): + track = straight() + middle = (track.fixes[0].latitude, track.fixes[0].longitude) + told = fm.animate([track], tmp_path / "told.png", ground=False, + airports=False, centre=middle, radius_nm=200.0) + guessed = fm.animate([track], tmp_path / "guessed.png", ground=False, + airports=False) + assert told is not None and guessed is not None + import numpy as np + from PIL import Image + + def has_red(path): + picture = np.array(Image.open(path).convert("RGB")) + return bool(((picture[:, :, 0] == fm.HOME_RED[0]) + & (picture[:, :, 1] == fm.HOME_RED[1]) + & (picture[:, :, 2] == fm.HOME_RED[2])).any()) + + assert has_red(told.path) + assert not has_red(guessed.path) + + +# --------------------------------------------------------------------------- +# The line from a label to its aircraft +# --------------------------------------------------------------------------- + +def test_a_dashed_line_has_gaps_in_it(): + img = np.full((40, 80), fm.BG, dtype=np.uint8) + fm._dashed(img, 5, 20, 74, 20, fm.LEADER) + row = img[20, 5:75] + assert (row == fm.LEADER).any(), "nothing was drawn" + assert (row == fm.BG).any(), "no gaps: that is a solid line" + # On and off in the lengths asked for, near enough to the pixel. + runs, last, count = [], row[0], 0 + for value in row: + if value == last: + count += 1 + else: + runs.append((last, count)) + last, count = value, 1 + runs.append((last, count)) + on = [n for value, n in runs[1:-1] if value == fm.LEADER] + assert on and all(4 <= n <= 6 for n in on), on + + +def test_a_dash_is_the_same_length_whichever_way_the_line_runs(): + """Stepped along the line rather than along whichever axis is longer, + so a nearly-horizontal leader and a nearly-vertical one come out the + same instead of one of them turning into a dotted line.""" + import math + + drawn = [] + for x1, y1 in ((79, 21), (21, 39), (79, 39)): + img = np.full((40, 80), fm.BG, dtype=np.uint8) + fm._dashed(img, 1, 1, x1, y1, fm.LEADER) + length = math.hypot(x1 - 1, y1 - 1) + drawn.append((img == fm.LEADER).sum() / length) + assert max(drawn) - min(drawn) < 0.25, drawn + + +def test_a_leader_stops_at_the_aircraft_rather_than_running_past_it(): + """Walked along the line's own length, so the last step lands on the + far end. Walked along whichever axis is longer instead, a diagonal + overshoots by half as much again and the leader carries on past the + aeroplane it was drawn to point at.""" + img = np.full((80, 80), fm.BG, dtype=np.uint8) + fm._dashed(img, 10, 10, 50, 50, fm.LEADER) + ys, xs = np.where(img == fm.LEADER) + assert len(xs), "nothing was drawn" + assert xs.max() <= 50 and ys.max() <= 50, \ + f"ran past the end to ({xs.max()}, {ys.max()})" + assert xs.min() >= 10 and ys.min() >= 10 + # And it does reach it, near enough to the pixel. + assert xs.max() >= 46 and ys.max() >= 46 + + +def test_a_leader_that_goes_nowhere_draws_nothing(): + img = np.full((40, 80), fm.BG, dtype=np.uint8) + fm._dashed(img, 20, 20, 20, 20, fm.LEADER) + assert not (img == fm.LEADER).any() + + +def test_the_animation_ties_a_label_to_its_aircraft(): + """The window has always had a leader; here there was nothing at all, + and a label pushed out into one of the rings by a crowd had nothing + tying it to the aeroplane it was about.""" + track = straight() + view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0)) + base = fm.background(view, unit="knots") + img = base.copy() + fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots", + entry=_Entry()) + assert (img == fm.LEADER).any(), "no leader was drawn" + + +def test_the_animation_leader_is_not_the_aircrafts_own_colour(): + """A solid line running out of an aeroplane in the colour of the path + behind it reads as more path; so does a line of any pattern in that + colour, and this one is neither.""" + from bandsaunter.flightmap import LEADER, PALETTE, RAMP, RAMP_STEPS + + leader = tuple(int(v) for v in PALETTE[LEADER]) + ramp = {tuple(int(v) for v in PALETTE[RAMP + i]) for i in range(RAMP_STEPS)} + assert leader not in ramp + + +def test_the_leader_fades_with_the_label_it_belongs_to(): + track = straight() + view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0)) + base = fm.background(view, unit="knots") + + def leader_shade(strength): + img = base.copy() + fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots", + entry=_Entry(), strength=strength) + drawn = {int(v) for v in np.unique(img[img != base])} + return {v for v in drawn if fm.LEADER <= v <= fm.LEADER + 2} + + assert leader_shade(1.0) == {fm.LEADER} + faded = leader_shade(0.45) + assert faded and fm.LEADER not in faded, faded + + +# --------------------------------------------------------------------------- +# Range rings +# --------------------------------------------------------------------------- + +def _ring_view(width=400): + track = straight() + return fm.fit([track], width=width, box=(50.0, -3.0, 52.0, 3.0)) + + +def test_the_rings_lift_the_ground_more_the_nearer_the_middle_they_are(): + """Concentric and translucent, so they stack: three lifts inside the + innermost, two in the next, one in the outer, none beyond.""" + view = _ring_view() + home = ((view.south + view.north) / 2, (view.west + view.east) / 2) + ground = np.zeros((view.height, view.width), dtype=np.uint8) + img = fm.background(view, unit="knots", ground=ground, home=home, + rings=60.0) + lift = fm.RING_LIFT + counts = [int((img == fm.GROUND + lift * n).sum()) for n in range(4)] + assert all(counts), f"not every ring was drawn: {counts}" + # Each ring is an annulus further out than the last, so it covers more + # of the picture than the one inside it. + assert counts[3] < counts[2] < counts[1], counts + + +def test_the_rings_stack_on_a_picture_with_no_map_under_it(): + """With no map there is nothing but background to lift, and a pixel + the outer disc has lifted has to count as ground for the next one -- + otherwise every ring lands on bare background and they all come out the + same shade.""" + view = _ring_view() + home = ((view.south + view.north) / 2, (view.west + view.east) / 2) + img = fm.background(view, unit="knots", home=home, rings=60.0) + lift = fm.RING_LIFT + shades = [int((img == fm.GROUND + lift * n).sum()) for n in (1, 2, 3)] + assert all(shades), f"the rings did not stack: {shades}" + assert shades[0] > shades[1] > shades[2], shades + + +def test_the_rings_stop_at_the_radius_they_were_given(): + view = _ring_view() + home = ((view.south + view.north) / 2, (view.west + view.east) / 2) + ground = np.zeros((view.height, view.width), dtype=np.uint8) + img = fm.background(view, unit="knots", ground=ground, home=home, + rings=60.0) + away = fm._distance_field( + home, + view.north - (view.north - view.south) * (np.arange(view.height) + 0.5) + / view.height, + view.west + (view.east - view.west) * (np.arange(view.width) + 0.5) + / view.width) + # The view sits inside the canvas, below the title and inside the + # margins, so the distance field lines up with that part of it. + patch = img[view.top:view.top + view.height, + view.left:view.left + view.width] + # Ground only: the flag is drawn over the middle of the innermost ring + # and is not ground that was lifted. + ground = (patch >= fm.GROUND) & (patch < fm.GROUND + fm.GROUND_SHADES) + lifted = ground & (patch > fm.GROUND) + beyond = away > 60.0 * 0.75 + 1.0 + assert not (lifted & beyond).any(), \ + "the ground beyond the outer ring was lifted" + assert (lifted & (away < 60.0 * 0.25)).any(), "the innermost ring is missing" + + +def test_no_rings_without_a_radius_or_without_a_position(): + view = _ring_view() + home = ((view.south + view.north) / 2, (view.west + view.east) / 2) + ground = np.zeros((view.height, view.width), dtype=np.uint8) + plain = fm.background(view, unit="knots", ground=ground) + assert np.array_equal( + fm.background(view, unit="knots", ground=ground, home=None, + rings=60.0), plain) + flagged = fm.background(view, unit="knots", ground=ground, home=home) + assert (flagged == fm.HOME).any() # the flag, but no rings + assert not (flagged == fm.GROUND + fm.RING_LIFT).any() + + +def _labels_drawn(**over): + """Every string the drawing was asked to stamp, in order. + + Read by watching the drawing rather than by looking for the letters in + the picture afterwards: a picture with a ring on it has large areas of + one flat colour, and searching those for a pattern of pixels finds + whatever it is asked for. + """ + view = _ring_view(width=700) + home = ((view.south + view.north) / 2, (view.west + view.east) / 2) + said = [] + real = fm.draw_text + + def watch(img, x, y, text, colour): + said.append(text) + return real(img, x, y, text, colour) + + fm.draw_text = watch + try: + fm.background(view, home=home, rings=60.0, **over) + finally: + fm.draw_text = real + return said + + +def test_each_ring_is_labelled_with_how_far_out_it_is(): + said = _labels_drawn(unit="knots") + for nm in (15, 30, 45): + assert f"{nm} NM" in said, (nm, said) + + +def test_the_ring_labels_are_in_the_unit_the_rest_of_the_picture_uses(): + """Miles an hour beside a ring measured in nautical miles would be two + different miles on one picture.""" + said = _labels_drawn(unit="mph") + assert "17 MI" in said, said # 15 nm, written as statute + assert not any(word.endswith(" NM") for word in said), said + + +def test_the_rings_leave_the_aerodromes_and_the_grid_alone(): + """They only lift pixels that are still map or still empty.""" + view = _ring_view() + home = ((view.south + view.north) / 2, (view.west + view.east) / 2) + airports = [("EGLL", home[0], home[1] + 0.2)] + img = fm.background(view, unit="knots", airports=airports, home=home, + rings=300.0) + assert (img == fm.AIRPORT).any(), "the aerodrome was washed out" + assert (img == fm.GRID).any(), "the grid was washed out" + + +def test_a_distance_field_measures_from_the_place_it_was_given(): + lats = np.array([51.0, 52.0]) + lons = np.array([-1.0, -1.0]) + away = fm._distance_field((51.0, -1.0), lats, lons) + assert away[0, 0] == pytest.approx(0.0, abs=0.01) + # A degree of latitude is sixty nautical miles, near enough. + assert away[1, 0] == pytest.approx(60.0, abs=0.5) diff --git a/tests/test_livemap.py b/tests/test_livemap.py index 3a5d238..3266eb7 100644 --- a/tests/test_livemap.py +++ b/tests/test_livemap.py @@ -1645,3 +1645,201 @@ def test_a_vector_theme_lays_a_halo_round_what_it_draws(app): finally: fm.set_theme("night") assert glowing > plain, f"{glowing} shades is no more than {plain}" + + +# --------------------------------------------------------------------------- +# The leader line, and the flag on the receiver +# --------------------------------------------------------------------------- + +def _leader_pixels(view) -> int: + """How much of the picture the leader line accounts for. + + Counted by drawing the view twice, once with the leader colour set to + the background, and taking the difference. Matching the colour itself + finds almost nothing: the line is smoothed and drawn with an alpha, so + hardly a pixel of it comes out the pure colour. + """ + from bandsaunter.flightmap import BG, LEADER, PALETTE + + was = PALETTE[LEADER].copy() + try: + PALETTE[LEADER] = PALETTE[BG] + hidden = _rendered(view) + finally: + PALETTE[LEADER] = was + shown = _rendered(view) + return int((hidden != shown).any(axis=2).sum()) + + +@qt +def test_the_leader_is_not_drawn_in_the_aircrafts_own_colour(app): + """Drawn in the aircraft's colour it came out the same colour as that + aircraft's trail, and a straight line running out of an aeroplane in + the colour of the path behind it reads as more path.""" + from bandsaunter.flightmap import LEADER, PALETTE, RAMP, RAMP_STEPS + from bandsaunter.livemap import SkyView + + view = SkyView(a_sky(a_blip())) + view.show_ground = False + assert _leader_pixels(view) > 10, "no leader was drawn at all" + leader = tuple(int(v) for v in PALETTE[LEADER]) + ramp = {tuple(int(v) for v in PALETTE[RAMP + i]) for i in range(RAMP_STEPS)} + assert leader not in ramp + + +@qt +def test_the_leader_is_dashed_rather_than_solid(app): + """The same line with the dashes taken out paints noticeably more of + itself, which is what a dashed line is.""" + from bandsaunter import livemap as lm + from bandsaunter.livemap import SkyView + + def drawn(pattern): + was, lm.LEADER_DASH = lm.LEADER_DASH, pattern + try: + view = SkyView(a_sky(a_blip())) + view.show_ground = False + return _leader_pixels(view) + finally: + lm.LEADER_DASH = was + + dashed = drawn(lm.LEADER_DASH) + solid = drawn(None) + assert solid > 10, "the solid line drew nothing to compare against" + assert dashed < solid * 0.85, \ + f"{dashed} pixels against {solid}: that is not a dashed line" + + +def test_a_dash_pattern_is_scaled_by_the_width_of_the_pen_drawing_it(): + """Qt measures a dash pattern in multiples of the pen's own width. A + glowing line is the same line drawn two or three times at different + widths, so without this its halo would have dashes three times the + length of its core and it would come out as beads.""" + from bandsaunter.livemap import dash_for + + for width in (1.0, 3.4, 5.8): + pattern = dash_for((5.0, 4.0), width) + assert [round(step * width, 6) for step in pattern] == [5.0, 4.0] + + +def test_a_dash_pattern_never_asks_for_a_step_of_nothing(): + """Qt refuses a zero-length dash, and a pen can be asked for at any + width the glow happens to want.""" + from bandsaunter.livemap import dash_for + + for width in (0.0, -3.0, 1e6): + assert all(step > 0 for step in dash_for((5.0, 4.0), width)) + + +@qt +def test_a_flag_stands_where_the_receiver_was_told_it_is(app): + from bandsaunter.flightmap import HOME_RED + from bandsaunter.livemap import SkyView + + def flag_pixels(view): + picture = _rendered(view) + return int(((picture[:, :, 2] == HOME_RED[0]) + & (picture[:, :, 1] == HOME_RED[1]) + & (picture[:, :, 0] == HOME_RED[2])).sum()) + + placed = SkyView(a_sky(a_blip(), home=(32.4325, -111.0841))) + placed.show_ground = False + assert flag_pixels(placed) > 20, "no flag where the receiver is" + + +@qt +def test_no_flag_where_nobody_said_the_receiver_is(app): + """The middle is otherwise worked out from whatever flew past, which is + not a place anybody is standing.""" + from bandsaunter.flightmap import HOME_RED + from bandsaunter.livemap import SkyView + + view = SkyView(a_sky(a_blip(), home=None)) + view.show_ground = False + picture = _rendered(view) + assert int(((picture[:, :, 2] == HOME_RED[0]) + & (picture[:, :, 1] == HOME_RED[1]) + & (picture[:, :, 0] == HOME_RED[2])).sum()) == 0 + + +def test_both_pictures_draw_the_leader_and_the_flag_the_same(): + """They are meant to look like the same program, and two copies of a + number are two chances to change only one of them.""" + from bandsaunter import flightmap as fm + from bandsaunter import livemap as lm + + assert tuple(lm.LEADER_DASH) == tuple(float(x) for x in fm.LEADER_DASH) + # Both read the one palette, so there is no second colour to drift. + assert tuple(fm.PALETTE[fm.LEADER]) == tuple(fm.PALETTE[fm.LEADER]) + assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED + # And the one set of measurements for the flag itself. + for name in ("HOME_POLE", "HOME_FLY", "HOME_DROP"): + assert hasattr(fm, name) + + +# --------------------------------------------------------------------------- +# Range rings on the window +# --------------------------------------------------------------------------- + +def _lifted_pixels(view) -> int: + """How much of the window the rings account for, by drawing it with and + without them. The tint is an alpha over whatever is underneath, so + there is no one colour to count.""" + was = view.sky.show_rings + try: + view.sky.show_rings = False + without = _rendered(view) + view.sky.show_rings = True + with_them = _rendered(view) + finally: + view.sky.show_rings = was + return int((without != with_them).any(axis=2).sum()) + + +def test_a_sky_does_not_draw_rings_unless_it_is_asked_to(): + assert a_sky().show_rings is False + assert Sky(rings=True).show_rings is True + + +@qt +def test_the_window_draws_the_range_rings(app): + from bandsaunter.livemap import SkyView + + view = SkyView(a_sky(a_blip(), rings=True)) + view.show_ground = False + assert _lifted_pixels(view) > 5000, "no rings were drawn" + + +@qt +def test_no_rings_without_a_position_or_without_a_radius(app): + """They are measured from the radius and centred on the receiver, so + they mean nothing without both.""" + from bandsaunter.livemap import SkyView + + nowhere = SkyView(a_sky(a_blip(), rings=True, home=None)) + nowhere.show_ground = False + assert _lifted_pixels(nowhere) == 0 + + flat = SkyView(a_sky(a_blip(), rings=True, radius_nm=0.0)) + flat.show_ground = False + assert _lifted_pixels(flat) == 0 + + +def test_the_rings_are_labelled_with_how_far_out_they_are(): + """Both pictures ask the same function for the wording, so the window + and the drawings cannot come to different numbers for the same ring.""" + from bandsaunter.flightmap import ring_labels + + assert ring_labels(100.0, "knots") == [(25.0, "25 nm"), (50.0, "50 nm"), + (75.0, "75 nm")] + + +def test_the_ring_labels_are_in_the_unit_the_rest_of_the_window_uses(): + """Miles an hour beside a ring measured in nautical miles would be two + different miles on one picture.""" + from bandsaunter.flightmap import ring_labels + + assert [text for _nm, text in ring_labels(104.0, "mph")] == \ + ["30 mi", "60 mi", "90 mi"] + assert [text for _nm, text in ring_labels(100.0, "kph")] == \ + ["46 km", "93 km", "139 km"] diff --git a/tests/test_themes.py b/tests/test_themes.py index 456cac9..af7bee1 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -259,3 +259,61 @@ def test_the_brightness_setting_still_does_something_on_a_vector_theme(): fm.set_theme("phosphor") assert int(fm.dim_ground(levels, 1.0).max()) > \ int(fm.dim_ground(levels, 0.3).max()) + + +# --------------------------------------------------------------------------- +# The leader line, and the flag on the receiver +# --------------------------------------------------------------------------- + +def test_no_theme_lets_a_leader_line_be_mistaken_for_a_flight_path(): + """The whole reason it stopped being drawn in the aircraft's colour: a + straight line running out of an aeroplane, in the colour of the path + behind that aeroplane, reads as more path.""" + for name in themes.THEMES: + fm.set_theme(name) + leader = _lab(fm.PALETTE[fm.LEADER]) + apart = min(float(np.linalg.norm(leader - _lab(fm.PALETTE[fm.RAMP + i]))) + for i in range(fm.RAMP_STEPS)) + assert apart > 25, f"{name}: only {apart:.0f} units from a trail" + + +def test_the_receiver_flag_is_red_whatever_the_theme_is(): + """"You are here" is the one mark on the picture whose meaning must not + change with the colours, so it does not take the theme's.""" + for name in themes.THEMES: + fm.set_theme(name) + assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED + + +def test_the_flag_stands_clear_of_the_aircraft_on_every_theme(): + """Including the red one, which is the hard case and the reason the + flag is pure red rather than a softer one: a softened red sat close + enough to a low aeroplane on the default map, and to a mid-altitude one + on the red theme, to be taken for one.""" + for name in themes.THEMES: + fm.set_theme(name) + red = _lab(fm.PALETTE[fm.HOME]) + apart = min(float(np.linalg.norm(red - _lab(fm.PALETTE[fm.RAMP + i]))) + for i in range(fm.RAMP_STEPS)) + assert apart > 20, f"{name}: only {apart:.0f} units from an aircraft" + + +def test_the_flag_stands_on_the_spot_rather_than_covering_it(): + """The foot of the pole is the position. A blob would put the position + somewhere inside itself.""" + img = np.full((40, 40), fm.BG, dtype=np.uint8) + fm.draw_home(img, 20, 34) + assert img[34, 20] == fm.HOME, "the pole does not stand on the spot" + assert img[35, 20] == fm.BG, "something is drawn below the position" + assert (img[34, :20] == fm.BG).all(), "the flag reaches left of the pole" + # The pennant flies up and to the right, and nothing else does. + top = 34 - fm.HOME_POLE + assert (img[top, 21:21 + fm.HOME_FLY] == fm.HOME).all() + assert (img[34 - 1, 21:] == fm.BG).all(), "the pennant hangs to the foot" + + +def test_the_flag_off_the_edge_of_the_picture_paints_nothing_absurd(): + for x, y in ((-50, 20), (20, -50), (200, 20), (20, 200)): + img = np.full((40, 40), fm.BG, dtype=np.uint8) + fm.draw_home(img, x, y) # must not raise or wrap + assert img.shape == (40, 40)