Tell the boxes from the flight paths, and say how far out things are

Three changes to what is on the picture, and both drawings got all three.

The line from an information box to its aircraft was drawn in the
aircraft's own colour, which made it the same colour as that aircraft's
trail: a straight solid line running out of an aeroplane, in the colour of
the path behind the aeroplane, reads as more path, and on a busy picture
that is a heading nobody flew.  It has its own colour now, and is dashed.
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, which only came out when the two
were held up against each other: a label pushed into one of the outward
rings by a crowd had nothing tying it to the aeroplane it was about.  It has
one now, 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 and neither runs past the aircraft it points at.

A red flag stands where the receiver was told it is standing, from the
coordinates in the settings.  The foot of the pole is the position and the
pennant flies up and to the right, so nothing the flag is made of covers the
place it points at.  It is pure red in every theme: that is the one mark on
the picture 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 -- a softer one 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.  It is
drawn only where a position was actually given, since a middle worked out
from whatever flew past is not a place anybody is standing.

And the range rings: faint discs at a quarter, a half and three quarters of
the radius, concentric on the receiver and labelled with the distance.  They
are translucent and they stack, so the ground inside the innermost is lifted
three times and the outer once, which gives a sense of how far away a thing
is without measuring anything.  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.  Separate settings for the two, because a picture is studied and
a window is glanced at.

Two bugs found by the tests rather than by looking.  The rings were built
across the whole canvas instead of the part of it the map is drawn on, so
they came out stretched by the height of the title bar -- four per cent,
which is invisible and wrong.  And a radius of zero killed the window: the
projection collapses, every pixel maps to one point, and the graticule asks
for lines across a span of nothing until Qt aborts.  The program never
passes a zero, but a caller could, and a crash is not an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-06 11:49:48 -07:00
parent 2e20c48971
commit 2da1d1f245
12 changed files with 1099 additions and 16 deletions

View file

@ -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 dozen frames a second on the window at 1920×1080 and spent a whole core
holding it there; keeping it takes the same window from 87 ms a frame to 14. holding it there; keeping it takes the same window from 87 ms a frame to 14.
**The 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 **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 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 thread that fetches the tiles but not behind them — they used to be fetched

View file

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

View file

@ -118,6 +118,8 @@ class AircraftOptions:
recheck: bool = False recheck: bool = False
basemap: bool = True basemap: bool = True
airports: bool = True airports: bool = True
rings: bool = True
window_rings: bool = True
theme: str = "night" theme: str = "night"
map_brightness: int = 70 map_brightness: int = 70
tile_url: str = "" tile_url: str = ""
@ -356,6 +358,26 @@ OPTIONS: tuple[Setting, ...] = (
flags=("--airports",), off_flags=("--no-airports",), flags=("--airports",), off_flags=("--no-airports",),
guidance="Turn it off for a picture with nothing but the aircraft on " guidance="Turn it off for a picture with nothing but the aircraft on "
"it, or where there is no network and nothing cached."), "it, or where there is no network and nothing cached."),
O("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", O("theme", "Colour theme", "Drawing", "choice",
"how the map looks: the colours, and whether the lines glow", "how the map looks: the colours, and whether the lines glow",
"The default draws a night-blue ground with height as colour, low " "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, radius_nm=radius_in_nm(options) or 100.0,
brightness=max(10, options.map_brightness) / 100.0, brightness=max(10, options.map_brightness) / 100.0,
fade=max(0.0, options.fade), fade=max(0.0, options.fade),
airports=options.airports) airports=options.airports,
rings=options.window_rings)
sky.started = started sky.started = started
sky.log_name = log.path.name if log is not None else "" sky.log_name = log.path.name if log is not None else ""
if options.simulate: if options.simulate:
@ -932,6 +955,7 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
tile_url=options.tile_url, tile_url=options.tile_url,
brightness=max(10, options.map_brightness) / 100.0, brightness=max(10, options.map_brightness) / 100.0,
airports=options.airports, airports=options.airports,
rings=options.rings,
radius_nm=radius_in_nm(options), radius_nm=radius_in_nm(options),
centre=read_position(options.location)) centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc: except (OSError, RuntimeError, ValueError) as exc:

View file

@ -196,6 +196,19 @@ examples:
ad.add_argument("--window", action="store_true", ad.add_argument("--window", action="store_true",
help="open a window and show the aircraft on a map as " help="open a window and show the aircraft on a map as "
"they are heard, instead of a table in the terminal") "they are heard, instead of a table in the terminal")
ad.add_argument("--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", ad.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red", choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad", "blue", "green", "orange", "wargames", "norad",
@ -255,6 +268,13 @@ examples:
fl.add_argument("--map-brightness", type=int, default=None, fl.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT", metavar="PERCENT",
help="how bright the map under the aircraft is (10-100)") help="how bright the map under the aircraft is (10-100)")
fl.add_argument("--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", fl.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red", choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad", "blue", "green", "orange", "wargames", "norad",
@ -1111,6 +1131,10 @@ def cmd_adsb(args) -> int:
options.schedules = args.schedules options.schedules = args.schedules
if getattr(args, "theme", None): if getattr(args, "theme", None):
options.theme = args.theme 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: if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture options.picture = Path(args.map).suffix.lstrip(".") or options.picture
@ -1165,6 +1189,10 @@ def cmd_flights(args) -> int:
options.schedules = args.schedules options.schedules = args.schedules
if getattr(args, "theme", None): if getattr(args, "theme", None):
options.theme = args.theme 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) tracks = air.checked(console, options, tracks)
book = FlightBook(online=args.lookup, book = FlightBook(online=args.lookup,
schedules=air.schedule_names(options)) schedules=air.schedule_names(options))

View file

@ -42,7 +42,8 @@ from .images import GLYPH_H, draw_text, text_width, write_png
__all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif", __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"write_mp4", "fit", "PALETTE", "ffmpeg_available", "write_mp4", "fit", "PALETTE", "ffmpeg_available",
"ground_for", "background", "label_lines", "draw_flag", "ground_for", "background", "label_lines", "draw_flag",
"set_theme", "theme", "THEME", "bloom", "set_theme", "theme", "THEME", "bloom", "draw_home",
"draw_rings", "RING_STEPS", "ring_labels",
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports", "FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
"showing", "fade_ramp", "showing", "fade_ramp",
"GROUND_BRIGHTNESS"] "GROUND_BRIGHTNESS"]
@ -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 # 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. # 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 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 RAMP_STEPS = 32
GROUND_SHADES = 32 GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before" TRANSPARENT = 255 # never drawn with: it means "as the frame before"
@ -219,6 +231,9 @@ def _palette(theme=None) -> np.ndarray:
for colour in (fixed[AIRPORT], fixed[INK], fixed[WHITE]): for colour in (fixed[AIRPORT], fixed[INK], fixed[WHITE]):
table += [_dimmed([colour], part)[0], table += [_dimmed([colour], part)[0],
_dimmed([colour], part * 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)) table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8) return np.array(table[:256], dtype=np.uint8)
@ -280,6 +295,9 @@ def _halo_tables() -> tuple:
for i, colour in enumerate((AIRPORT, INK, WHITE)): for i, colour in enumerate((AIRPORT, INK, WHITE)):
near[colour] = GLOW + i * 2 near[colour] = GLOW + i * 2
far[colour] = GLOW + i * 2 + 1 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 # 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 # already fade through. Lettering on those screens has a halo the same
# as everything else does; it is the same beam drawing it. # 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 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: def _disc(img: np.ndarray, x: int, y: int, radius: int, colour: int) -> None:
height, width = img.shape height, width = img.shape
r = max(0, int(radius)) r = max(0, int(radius))
@ -572,7 +653,8 @@ def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS):
def background(view: Projection, title: str = "", airports=(), def background(view: Projection, title: str = "", airports=(),
unit: str = DEFAULT_SPEED_UNIT, ground=None, unit: str = DEFAULT_SPEED_UNIT, ground=None,
attribution: str = "", 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. """The map without anything flying on it: ground, grid, scale, key, title.
``ground`` is the real map underneath, as brightness levels covering the ``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) _box(img, x - 1, y - 1, x + 1, y + 1, AIRPORT, fill=True)
draw_text(img, x + 6, y - 3, name, AIRPORT) 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: if title:
draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK) draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK)
if attribution: if attribution:
@ -627,6 +722,108 @@ def background(view: Projection, title: str = "", airports=(),
return img 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, def _scale_bar(img: np.ndarray, view: Projection,
unit: str = DEFAULT_SPEED_UNIT) -> None: unit: str = DEFAULT_SPEED_UNIT) -> None:
"""A bar of a round number of miles, for judging distance. """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 # way out -- which left the brightest thing on that part of the picture
# being the one aeroplane nothing had been heard from. # being the one aeroplane nothing had been heard from.
level = fade_level(strength) 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) draw_text(img, left, top, track.name, colour)
at = top + GLYPH_H + 2 at = top + GLYPH_H + 2
for text, country in rows: 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 = "", ground: bool = False, fetch=None, tile_url: str = "",
radius_nm: float = 0.0, centre=None, radius_nm: float = 0.0, centre=None,
brightness: float = GROUND_BRIGHTNESS, brightness: float = GROUND_BRIGHTNESS,
airports: bool = False, ask=None, airports: bool = False, ask=None, rings: bool = False,
fade: float = 0.0) -> Animation | None: fade: float = 0.0) -> Animation | None:
"""Draw the whole log as a moving map. """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] if one[0] not in seen]
base = background(view, title=heading, airports=marked, base = background(view, title=heading, airports=marked,
unit=unit, ground=levels, attribution=credit, 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) canvas_w, canvas_h = canvas_size(view)
base = _pad_to(base, canvas_w, canvas_h) base = _pad_to(base, canvas_w, canvas_h)

View file

@ -26,6 +26,7 @@ import numpy as np
from .flags import iso_for from .flags import iso_for
from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm, from .flightlog import (DEFAULT_SPEED_UNIT, distance_label, distance_nm,
in_distance, in_speed, speed_label) in_distance, in_speed, speed_label)
from .flightmap import LEADER_DASH as _LEADER_DASH
from .flightmap import Projection, altitude_step from .flightmap import Projection, altitude_step
from .ui import compass 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 # so that importing it costs nothing on a machine with no Qt on it, and so
# they cannot be listed here. # they cannot be listed here.
__all__ = ["available", "binding", "show", "fetch_ground", "Sky", "Blip", __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"] "MISSING_QT"]
BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2") BINDINGS = ("PyQt6", "PyQt5", "PySide6", "PySide2")
@ -83,6 +85,26 @@ FLAG_PIXELS = 12
# Below this an aircraft keeps its symbol and loses its box. # Below this an aircraft keeps its symbol and loses its box.
LABEL_WHILE = 0.40 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 # 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 # 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 # 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, def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0,
home=None, radius_nm: float = 100.0, home=None, radius_nm: float = 100.0,
brightness: float = 0.70, fade: float = 20.0, brightness: float = 0.70, fade: float = 20.0,
airports: bool = False): airports: bool = False, rings: bool = False):
import threading import threading
self.unit = unit self.unit = unit
@ -351,6 +373,7 @@ class Sky:
# network the first time an area is drawn. The program turns it on; # 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. # anything using this as a library has to say so on purpose.
self.show_airports = airports self.show_airports = airports
self.show_rings = rings
self.frames = 0 self.frames = 0
self.aircraft_seen = 0 self.aircraft_seen = 0
self.started = time.time() self.started = time.time()
@ -639,8 +662,9 @@ def _build():
return None return None
_name, QtCore, QtGui, QtWidgets, _signal = found _name, QtCore, QtGui, QtWidgets, _signal = found
from .flightmap import (AIRPORT, BG, GRID, GROUND, GROUND_SHADES, INK, from .flightmap import (AIRPORT, BG, DIM, GRID, GROUND, GROUND_SHADES,
PALETTE, PANEL, RAMP, _degrees, _grid_step) HOME, INK, LEADER, PALETTE, PANEL, RAMP,
_degrees, _grid_step)
Qt = QtCore.Qt Qt = QtCore.Qt
QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer QPointF, QRectF, QTimer = QtCore.QPointF, QtCore.QRectF, QtCore.QTimer
@ -665,7 +689,8 @@ def _build():
def craft_colour(feet, alpha=255) -> QColor: def craft_colour(feet, alpha=255) -> QColor:
return rgb(RAMP + altitude_step(feet), alpha) return rgb(RAMP + altitude_step(feet), alpha)
def glow_line(painter, colour, width: float, draw, core=True) -> None: 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. """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 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`` 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 lays down the halo alone, for a shape whose core wants drawing some
other way -- unsmoothed, or filled rather than stroked. 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 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 rings = THEME.glow
if rings: if rings:
part = THEME.glow_part part = THEME.glow_part
for ring in range(rings, 0, -1): for ring in range(rings, 0, -1):
faint = QColor(colour) faint = QColor(colour)
faint.setAlpha(max(6, int(colour.alpha() * part ** ring))) 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.setCapStyle(ROUND_CAP)
pen.setJoinStyle(ROUND_JOIN) pen.setJoinStyle(ROUND_JOIN)
draw(pen) draw(pen)
if core: if core:
draw(QPen(colour, width)) draw(pen_for(colour, width))
class SkyView(QtWidgets.QWidget): class SkyView(QtWidgets.QWidget):
"""The map, the aircraft on it, and a box beside each one.""" """The map, the aircraft on it, and a box beside each one."""
@ -734,7 +771,12 @@ def _build():
from .flightlog import box_around from .flightlog import box_around
middle = self.sky.centre() 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 return None
south, west, north, east = box_around(middle[0], middle[1], south, west, north, east = box_around(middle[0], middle[1],
self.sky.radius_nm) self.sky.radius_nm)
@ -785,8 +827,13 @@ def _build():
return return
if self.show_ground: if self.show_ground:
self._draw_ground(painter, view) self._draw_ground(painter, view)
self._draw_rings(painter, view)
self._draw_graticule(painter, view) self._draw_graticule(painter, view)
self._draw_airports(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() flying = self.sky.flying()
if self.trails: if self.trails:
for blip in flying: for blip in flying:
@ -999,6 +1046,90 @@ def _build():
if code: if code:
painter.drawText(x + 7, y + 4, 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, def _draw_trail(self, painter, view, blip: Blip,
strength: float = 1.0) -> None: strength: float = 1.0) -> None:
points = self.sky.trail(blip.icao) points = self.sky.trail(blip.icao)
@ -1052,7 +1183,13 @@ def _build():
painter.setPen(pen) painter.setPen(pen)
painter.drawLine(x, y, to_x, to_y) 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 \ title = f"{blip.callsign} {blip.icao}" if blip.callsign \
else blip.icao else blip.icao
self._draw_box(painter, dx, dy, box, lines, colour, title) self._draw_box(painter, dx, dy, box, lines, colour, title)

View file

@ -49,6 +49,11 @@ class Theme:
panel: tuple panel: tuple
airport: tuple # aerodromes, and nothing else airport: tuple # aerodromes, and nothing else
route: tuple # the line between two airports 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 white: tuple
# (feet, colour) stops the 32 altitude colours are interpolated between. # (feet, colour) stops the 32 altitude colours are interpolated between.
stops: tuple stops: tuple
@ -87,6 +92,7 @@ NIGHT = Theme(
panel=(24, 28, 38), panel=(24, 28, 38),
airport=(255, 64, 200), airport=(255, 64, 200),
route=(70, 84, 110), route=(70, 84, 110),
leader=(148, 156, 174),
white=(255, 255, 255), white=(255, 255, 255),
stops=((0.0, (252, 96, 72)), (10_000.0, (250, 190, 64)), stops=((0.0, (252, 96, 72)), (10_000.0, (250, 190, 64)),
(20_000.0, (132, 226, 96)), (30_000.0, (72, 200, 236)), (20_000.0, (132, 226, 96)), (30_000.0, (72, 200, 236)),
@ -109,6 +115,7 @@ DIGITAL = Theme(
panel=(0, 10, 20), panel=(0, 10, 20),
airport=(255, 176, 64), airport=(255, 176, 64),
route=(0, 70, 120), route=(0, 70, 120),
leader=(104, 128, 150),
white=(232, 248, 255), white=(232, 248, 255),
stops=((0.0, (0, 60, 140)), (10_000.0, (0, 122, 208)), stops=((0.0, (0, 60, 140)), (10_000.0, (0, 122, 208)),
(20_000.0, (0, 190, 244)), (30_000.0, (120, 230, 255)), (20_000.0, (0, 190, 244)), (30_000.0, (120, 230, 255)),
@ -136,6 +143,7 @@ PHOSPHOR = Theme(
panel=(0, 12, 5), panel=(0, 12, 5),
airport=(255, 184, 72), airport=(255, 184, 72),
route=(0, 76, 34), route=(0, 76, 34),
leader=(112, 148, 120),
white=(236, 255, 240), white=(236, 255, 240),
stops=((0.0, (0, 74, 30)), (10_000.0, (0, 146, 56)), stops=((0.0, (0, 74, 30)), (10_000.0, (0, 146, 56)),
(20_000.0, (24, 210, 84)), (30_000.0, (120, 246, 150)), (20_000.0, (24, 210, 84)), (30_000.0, (120, 246, 150)),
@ -162,6 +170,7 @@ AMBER = Theme(
panel=(14, 8, 0), panel=(14, 8, 0),
airport=(120, 220, 255), airport=(120, 220, 255),
route=(84, 48, 0), route=(84, 48, 0),
leader=(168, 146, 112),
white=(255, 244, 224), white=(255, 244, 224),
stops=((0.0, (96, 44, 0)), (10_000.0, (168, 88, 0)), stops=((0.0, (96, 44, 0)), (10_000.0, (168, 88, 0)),
(20_000.0, (232, 148, 16)), (30_000.0, (255, 200, 96)), (20_000.0, (232, 148, 16)), (30_000.0, (255, 200, 96)),
@ -187,6 +196,7 @@ RED = Theme(
panel=(14, 2, 2), panel=(14, 2, 2),
airport=(120, 220, 255), airport=(120, 220, 255),
route=(88, 14, 14), route=(88, 14, 14),
leader=(170, 130, 124),
white=(255, 228, 224), white=(255, 228, 224),
stops=((0.0, (92, 0, 0)), (10_000.0, (164, 16, 8)), stops=((0.0, (92, 0, 0)), (10_000.0, (164, 16, 8)),
(20_000.0, (226, 56, 40)), (30_000.0, (255, 128, 112)), (20_000.0, (226, 56, 40)), (30_000.0, (255, 128, 112)),

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-05" "bandsaunter 2026-09-05_01" "User Commands" .TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_01" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS
@ -1621,6 +1621,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 has no frame before it and places its labels exactly as it always did. The
whole box fades with its aircraft: an indexed picture cannot blend, so the row whole box fades with its aircraft: an indexed picture cannot blend, so the row
grey and all twelve flag colours have dimmed copies at each fade step. grey and all twelve flag colours have dimmed copies at each fade step.
.SS 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 .SS Themes
.BI \-\-theme " NAME" .BI \-\-theme " NAME"
changes the window and the animated pictures together, since both read their changes the window and the animated pictures together, since both read their

View file

@ -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 has no frame before it and places its labels exactly as it always did. The
whole box fades with its aircraft: an indexed picture cannot blend, so the row whole box fades with its aircraft: an indexed picture cannot blend, so the row
grey and all twelve flag colours have dimmed copies at each fade step. grey and all twelve flag colours have dimmed copies at each fade step.
.SS 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 .SS Themes
.BI \-\-theme " NAME" .BI \-\-theme " NAME"
changes the window and the animated pictures together, since both read their changes the window and the animated pictures together, since both read their

View file

@ -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 " assert apart > 40, (f"the airport colour is {apart:.0f} units from the "
f"nearest altitude colour; under about 25 they read " f"nearest altitude colour; under about 25 they read "
f"as the same colour") 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)

View file

@ -1645,3 +1645,201 @@ def test_a_vector_theme_lays_a_halo_round_what_it_draws(app):
finally: finally:
fm.set_theme("night") fm.set_theme("night")
assert glowing > plain, f"{glowing} shades is no more than {plain}" 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"]

View file

@ -259,3 +259,61 @@ def test_the_brightness_setting_still_does_something_on_a_vector_theme():
fm.set_theme("phosphor") fm.set_theme("phosphor")
assert int(fm.dim_ground(levels, 1.0).max()) > \ assert int(fm.dim_ground(levels, 1.0).max()) > \
int(fm.dim_ground(levels, 0.3).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)