bandsaunter/bandsaunter/flightmap.py
The Dust Council f01de4117f Make the aircraft breathe, and send a ring out from each of them
Two things that move on their own, on the window and in the animated
pictures alike, and both turned off with a flag.

An aircraft swells from bright to dim and back.  At the top of the swell it
burns: drawn in a set of peak colours and wearing a halo, which is what a
phosphor does when the beam sits in one place a little too long.  The halo
is the aeroplane's own shape spread outward a pixel at a time rather than a
circle drawn round it -- the first attempt did draw a circle and it looked
exactly like what it was, a ring of dots -- so the glow has the shape of the
thing casting it.  It goes only where the picture was still empty, over the
ground and the grid and the background and never over another aircraft,
since a halo is what light does to the dark around a thing.

And a ring leaves each aircraft and travels outward, growing and dimming as
it goes.  What a radar repeater does, and what the eye reads as this thing
is transmitting -- which is exactly what an aeroplane on this picture is
doing, twice a second, and is how it got on the picture at all.  One ring at
a time per aircraft: a new one leaves as the last reaches the end of its
reach, so the sky has one ring per aeroplane rather than a stack of them to
read through.

Five settings, in a group of their own because the others were already at
eight: whether to pulse, how long a swell takes, whether to echo, how long
between rings, and how far a ring gets.  Both times are seconds of watching
rather than of flying, so the rhythm looks the same whatever speed an
evening is being run through.

Two decisions worth naming.  Each aircraft is offset by its own address, so
a sky full of them swells and rings separately rather than beating as one,
which would read as a display flashing rather than as a lot of separate
things transmitting; the offset comes from the address, so an aeroplane
keeps its rhythm from one frame to the next and from one drawing of the same
log to the next.  And only an aircraft still being heard pulses: one that
has gone quiet is already fading, and a thing that is fading and beating at
once says two contradictory things about itself.

The window can blend and its swell is continuous.  The animation cannot, a
GIF being indexed colour, so there the swell is the steps a palette allows
-- which family of colours the aeroplane is drawn from, and how far its halo
reaches, which is six between them and reads as a swell at a couple of
seconds a cycle.  The peak has sixteen colours of its own, sixteen being
exactly what was left of the palette once 255 is set aside as the
transparent index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 17:03:16 -07:00

2012 lines
84 KiB
Python

"""An evening of aircraft, drawn as a map that moves.
A log of ADS-B frames is a list of times and places. Read down the page it
says nothing; drawn on a map with the clock running it is an evening's air
traffic -- the arrivals stacking up over the airport, the transatlantic
crossings at thirty-eight thousand feet going the other way, the helicopter
that circled for twenty minutes.
The animation is in real time made faster. Every frame is a moment, each
aircraft is drawn where it actually was at that moment -- interpolated between
the position reports that arrived either side of it, and dead-reckoned from
its last known speed and heading when nothing arrived at all -- so an aircraft
crossing the picture in ten seconds of animation took the twenty minutes the
data says it took. Nothing here is drawn at a constant speed for the look of
the thing.
The picture is written as an animated GIF, encoded here from first principles
in the same spirit as the PNGs elsewhere in this program: a palette, an LZW
stream and no imaging library. Where ffmpeg happens to be installed an MP4
can be written instead, which is smaller and smoother, but nothing depends on
it being there.
"""
from __future__ import annotations
import math
import shutil
import struct
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import numpy as np
from . import themes as _themes
from .flightlog import (DEFAULT_SPEED_UNIT, Track, box_around, centre_of,
distance_label, distance_nm, in_distance, in_speed,
speed_label, within)
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", "draw_home",
"draw_rings", "RING_STEPS", "ring_labels",
"pulse_at", "phase_of", "echo_age", "hot_step",
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
"showing", "fade_ramp",
"GROUND_BRIGHTNESS"]
# ---------------------------------------------------------------------------
# Colours
# ---------------------------------------------------------------------------
# Everything is drawn in indexed colour, because a GIF is indexed colour and
# converting a picture into a palette afterwards is a guess about what the
# picture meant. Drawing straight into the palette means the file holds
# exactly the colours that were asked for.
BG, GRID, INK, DIM, PANEL, AIRPORT, ROUTE, WHITE = range(8)
RAMP = 8 # 32 altitude colours from here
TRAIL = RAMP + 32 # the same 32, dimmed, for the path just flown
OLD = TRAIL + 32 # and dimmer still, for the path flown earlier
GROUND = OLD + 32 # 32 shades of the map underneath
FLAG = GROUND + 32 # the dozen colours the little flags are made of
FAINT = FLAG + 12 # the same 32 again, fainter still, for fading
# A fading aircraft's box has to fade with it. An indexed picture cannot
# blend, so everything the box is made of needs its own dimmed copies: the
# grey the rows are written in, and the flag colours, at the same three
# steps the aircraft itself fades through.
LABEL_INK = FAINT + 32 # the row grey, at the four strengths
FLAG_FADED = LABEL_INK + 4 # three dimmer sets of the twelve flag colours
# A vector display draws by holding a beam on the phosphor, and the phosphor
# spreads the light: a line on one of those screens is a bright core with a
# halo round it. The aircraft colours already have dimmed copies to make a
# halo out of -- that is what the trail shades are -- but the fixed colours
# do not, so the three that are worth a glow get two rings each here.
GLOW = FLAG_FADED + 36 # airport, ink and white, at two ring strengths
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)
# The bright peak of a pulsing aircraft: the altitude colours pushed towards
# white, so that an aeroplane at the top of its pulse burns rather than
# merely being its own colour again.
#
# Sixteen of them rather than thirty-two, because sixteen is what is left of
# the palette -- 255 is the transparent index and can never be drawn with.
# Sixteen steps of altitude at the top of a pulse is plenty: it is a moment
# of a cycle, and the aeroplane spends the rest of the cycle in the full
# thirty-two.
HOT = HOME + 3
HOT_STEPS = 16
HOT_LIFT = 0.55 # how far towards white the peak is pushed
# How long one pulse of an aircraft takes, how often an echo leaves it, and
# how far an echo gets before it has faded to nothing.
PULSE_SECONDS = 2.2
ECHO_SECONDS = 3.0
ECHO_REACH = 46 # pixels
RAMP_STEPS = 32
GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
CEILING_FT = 45_000.0
# Past this many aircraft on one frame, the labels go back to the callsign,
# the height and the speed. Five lines beside each of three hundred aircraft
# is not more information, it is less: a still of a whole day would be a page
# of overlapping text with a map somewhere behind it. An animation shows a
# handful at a time and never reaches this.
CROWDED = 14
# The most frames any one animation is worth: about four minutes at twelve a
# second, by which point a viewer has stopped watching and a GIF has stopped
# opening.
MAX_FRAMES = 3000
# The airport colour is chosen to be as far from every altitude colour as a
# colour can be. The old amber sat 16 units of CIELAB from the ramp's
# 12,000-foot yellow, which is to say it was the same colour: an aeroplane
# low over a field was drawn in the field's own colour, and neither could be
# picked out from the other. Magenta is 56 units away, further than any
# other hue the ramp leaves free -- the ramp already spends red, amber,
# green, cyan and violet on height -- and it is what an aeronautical chart
# marks an aerodrome in anyway.
# Which theme is being drawn. Everything below is built from it, so
# changing it changes both the animation and the window: they read the same
# palette, and the window looks the colours up in it rather than keeping any
# of its own.
THEME = _themes.THEMES[_themes.DEFAULT_THEME]
_FIXED = (THEME.background, THEME.grid, THEME.ink, THEME.dim, THEME.panel,
THEME.airport, THEME.route, THEME.white)
# Low is warm, high is cold on the default map: the convention every other
# aircraft map uses, so an altitude can be read off the picture without
# looking at the key. A phosphor theme has one colour to spend and reads
# height as brightness instead.
ALTITUDE_STOPS = THEME.stops
def _ramp(steps: int = RAMP_STEPS, stops=None) -> list[tuple[int, int, int]]:
"""The altitude ramp, interpolated between the stops above."""
stops = ALTITUDE_STOPS if stops is None else stops
out = []
for i in range(steps):
feet = CEILING_FT * i / (steps - 1)
low = stops[0]
high = stops[-1]
for a, b in zip(stops, stops[1:]):
if a[0] <= feet <= b[0]:
low, high = a, b
break
span = high[0] - low[0]
part = 0.0 if span <= 0 else (feet - low[0]) / span
out.append(tuple(int(round(x + (y - x) * part))
for x, y in zip(low[1], high[1])))
return out
def _dimmed(colours, factor: float) -> list[tuple[int, int, int]]:
return [tuple(int(round(c * factor)) for c in rgb) for rgb in colours]
# The map under the aircraft: night-blue, running from nearly the background
# to about as bright as it can go before the aircraft stop standing out. How
# far up that range a drawing actually goes is the brightness setting below,
# so there is room to turn it up on a screen that needs it and down on one
# that does not.
GROUND_LOW = THEME.ground_low
GROUND_HIGH = THEME.ground_high
# What fraction of that range the map is drawn over unless told otherwise.
# Enough to read a coastline and the name of a town, and not so much that a
# city washes out the aircraft crossing it.
GROUND_BRIGHTNESS = 0.70
def _ground_shades(steps: int = GROUND_SHADES, low=None,
high=None) -> list[tuple[int, int, int]]:
low = GROUND_LOW if low is None else low
high = GROUND_HIGH if high is None else high
out = []
for i in range(steps):
part = i / max(1, steps - 1)
out.append(tuple(int(round(a + (b - a) * part))
for a, b in zip(low, high)))
return out
def _flag_colours() -> list[tuple[int, int, int]]:
"""The flag palette, in a fixed order so an index means one colour."""
from .flags import COLOURS, COLOUR_ORDER
return [COLOURS[letter] for letter in COLOUR_ORDER]
def flag_index(letter: str, level: int = 0) -> int:
"""Where one of the flag colours lives in the palette.
``level`` is how far the aircraft has faded, 0 for full strength.
"""
from .flags import COLOUR_ORDER
place = COLOUR_ORDER.index(letter)
if level <= 0:
return FLAG + place
return FLAG_FADED + (min(level, 3) - 1) * len(COLOUR_ORDER) + place
def fade_level(strength: float) -> int:
"""How far a thing at this strength has faded: 0 full, 3 nearly gone."""
for level, (above, _base) in enumerate(FADE_STEPS):
if strength > above:
return level
return len(FADE_STEPS) - 1
# What each fade step multiplies a colour by. The first is the aircraft at
# full strength and is not dimmed at all.
FADE_FACTORS = (1.0, 0.55, 0.30, 0.14)
def _palette(theme=None) -> np.ndarray:
theme = THEME if theme is None else theme
fixed = (theme.background, theme.grid, theme.ink, theme.dim, theme.panel,
theme.airport, theme.route, theme.white)
ramp = _ramp(stops=theme.stops)
flags = _flag_colours()
table = (list(fixed) + ramp + _dimmed(ramp, 0.55) + _dimmed(ramp, 0.30)
+ _ground_shades(low=theme.ground_low, high=theme.ground_high)
+ flags + _dimmed(ramp, 0.14)
# The row grey and then the flags, dimmed the same way the
# aircraft above them is, so that a whole label fades together.
+ [_dimmed([fixed[DIM]], factor)[0] for factor in FADE_FACTORS])
for factor in FADE_FACTORS[1:]:
table += _dimmed(flags, factor)
# The halo colours: the near ring and the far one, for the three fixed
# colours bright enough to be worth glowing.
part = theme.glow_part or 0.34
for colour in (fixed[AIRPORT], fixed[INK], fixed[WHITE]):
table += [_dimmed([colour], part)[0],
_dimmed([colour], part * part)[0]]
for colour in (theme.leader, HOME_RED):
table += [colour, _dimmed([colour], part)[0],
_dimmed([colour], part * part)[0]]
# The pulse's bright peak: each altitude colour lifted towards white.
for i in range(HOT_STEPS):
base = ramp[min(RAMP_STEPS - 1,
round(i * (RAMP_STEPS - 1) / max(1, HOT_STEPS - 1)))]
table.append(tuple(int(round(c + (255 - c) * HOT_LIFT)) for c in base))
table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8)
PALETTE = _palette()
def set_theme(name) -> "_themes.Theme":
"""Draw in this theme from now on, and say which one that turned out to be.
The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to this array
and a new one would leave half the program painting in the old colours.
Returns the theme, so a caller that passed a name can say what it got.
"""
global THEME, _FIXED, ALTITUDE_STOPS, GROUND_LOW, GROUND_HIGH
theme = name if isinstance(name, _themes.Theme) else _themes.theme_named(name)
THEME = theme
_FIXED = (theme.background, theme.grid, theme.ink, theme.dim, theme.panel,
theme.airport, theme.route, theme.white)
ALTITUDE_STOPS = theme.stops
GROUND_LOW, GROUND_HIGH = theme.ground_low, theme.ground_high
PALETTE[:] = _palette(theme)
return theme
def theme() -> "_themes.Theme":
"""Which theme is being drawn."""
return THEME
# How faint an aircraft is drawn as it fades, in the four strengths the
# palette holds. An indexed picture cannot blend, so a fade is a handful of
# steps rather than a slope -- which at a second or two apart reads as a
# fade all the same.
FADE_STEPS = ((0.66, RAMP), (0.40, TRAIL), (0.18, OLD), (0.0, FAINT))
# Below this the label goes: a name at a tenth of its colour is not
# information, it is something in the way of the aircraft still flying.
LABEL_WHILE = 0.40
# Which colour the halo around each drawn colour is, a ring at a time. The
# aircraft families already have their dimmed copies -- the trail shades --
# so a glowing aeroplane spreads into the colour its own trail is drawn in,
# which is the same colour a phosphor would have spread into.
_NO_HALO = TRANSPARENT
def _halo_tables() -> tuple:
near = np.full(256, _NO_HALO, dtype=np.uint8)
far = np.full(256, _NO_HALO, dtype=np.uint8)
for step in range(RAMP_STEPS):
for base in (RAMP, TRAIL, OLD, FAINT):
near[base + step] = TRAIL + step
far[base + step] = OLD + step
for i, colour in enumerate((AIRPORT, INK, WHITE)):
near[colour] = GLOW + i * 2
far[colour] = GLOW + i * 2 + 1
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.
near[DIM] = LABEL_INK + 1
far[DIM] = LABEL_INK + 2
for level in range(3):
near[LABEL_INK + level] = LABEL_INK + level + 1
far[LABEL_INK + level] = LABEL_INK + min(3, level + 2)
return near, far
_HALO_NEAR, _HALO_FAR = _halo_tables()
def _spread(values: np.ndarray) -> np.ndarray:
"""One pixel of halo in every direction, first writer wins.
Done as two one-dimensional passes rather than eight shifts of the whole
picture: the second pass spreads what the first one already spread, so
the corners come out with it, at half the work.
"""
def moved(values, axis, step):
# Rolled and then cut, because a roll wraps: without this a line
# down the left edge would glow on the right edge of the picture.
shifted = np.roll(values, step, axis=axis)
edge = [slice(None), slice(None)]
edge[axis] = slice(0, 1) if step > 0 else slice(-1, None)
shifted[tuple(edge)] = _NO_HALO
return shifted
out = values.copy()
for step in (1, -1):
out = np.where(out == _NO_HALO, moved(values, 1, step), out)
across = out.copy()
for step in (1, -1):
out = np.where(out == _NO_HALO, moved(across, 0, step), out)
return out
def bloom(img: np.ndarray, theme=None) -> np.ndarray:
"""Put a halo around everything bright, the way a phosphor does.
Only over the ground, the grid and the empty background: a halo is what
light does to the dark around a line, and painting it over another line
would be light doing something light does not do.
The near ring goes down after the far one, so where two rings meet the
brighter wins -- which is what happens on the tube as well.
"""
theme = THEME if theme is None else theme
if not theme.glow:
return img
empty = ((img == BG) | (img == GRID)
| ((img >= GROUND) & (img < GROUND + GROUND_SHADES)))
near = _spread(_HALO_NEAR[img])
far = near if theme.glow < 2 else _spread(_spread(_HALO_FAR[img]))
out = np.where(empty & (far != _NO_HALO), far, img)
return np.where(empty & (near != _NO_HALO), near, out)
def fade_ramp(strength: float) -> int:
"""Which set of colours an aircraft at this strength is drawn from."""
for above, base in FADE_STEPS:
if strength > above:
return base
return FAINT
def altitude_step(feet: float) -> int:
"""Which of the 32 altitude colours a height falls in."""
if feet <= 0:
return 0
return int(min(RAMP_STEPS - 1,
max(0, round(feet / CEILING_FT * (RAMP_STEPS - 1)))))
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
def _line(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
colour: int) -> None:
"""A straight line, clipped to the canvas. Bresenham, no smoothing."""
height, width = img.shape
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
# A line between two points a long way off the canvas would otherwise be
# walked pixel by pixel for its whole imaginary length.
if max(abs(x1 - x0), abs(y1 - y0)) > 8 * (width + height):
return
while True:
if 0 <= x0 < width and 0 <= y0 < height:
img[y0, x0] = colour
if x0 == x1 and y0 == y1:
return
step = 2 * err
if step >= dy:
err += dy
x0 += sx
if step <= dx:
err += dx
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))
y0, y1 = max(0, y - r), min(height, y + r + 1)
x0, x1 = max(0, x - r), min(width, x + r + 1)
if y0 >= y1 or x0 >= x1:
return
ys = np.arange(y0, y1)[:, None] - y
xs = np.arange(x0, x1)[None, :] - x
img[y0:y1, x0:x1][ys * ys + xs * xs <= r * r] = colour
def _triangle(img: np.ndarray, points, colour: int) -> None:
"""A filled triangle: the aircraft, pointing where it is going."""
height, width = img.shape
(ax, ay), (bx, by), (cx, cy) = points
x0, x1 = max(0, int(min(ax, bx, cx))), min(width, int(max(ax, bx, cx)) + 1)
y0, y1 = max(0, int(min(ay, by, cy))), min(height, int(max(ay, by, cy)) + 1)
if x0 >= x1 or y0 >= y1:
return
ys, xs = np.mgrid[y0:y1, x0:x1]
area = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay)
if abs(area) < 1e-9:
return
w0 = ((bx - ax) * (ys - ay) - (xs - ax) * (by - ay)) / area
w1 = ((cx - bx) * (ys - by) - (xs - bx) * (cy - by)) / area
w2 = ((ax - cx) * (ys - cy) - (xs - cx) * (ay - cy)) / area
img[y0:y1, x0:x1][(w0 >= 0) & (w1 >= 0) & (w2 >= 0)] = colour
def _box(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
colour: int, fill: bool = False) -> None:
height, width = img.shape
x0, x1 = max(0, x0), min(width - 1, x1)
y0, y1 = max(0, y0), min(height - 1, y1)
if x0 > x1 or y0 > y1:
return
if fill:
img[y0:y1 + 1, x0:x1 + 1] = colour
return
img[y0, x0:x1 + 1] = colour
img[y1, x0:x1 + 1] = colour
img[y0:y1 + 1, x0] = colour
img[y0:y1 + 1, x1] = colour
# ---------------------------------------------------------------------------
# Where things go on the picture
# ---------------------------------------------------------------------------
TITLE_H = 26
LEGEND_H = 32
MARGIN = 10
@dataclass
class Projection:
"""A box of the world, and where it lands on the canvas.
Equirectangular, with longitude squeezed by the cosine of the middle
latitude so that a mile across looks like a mile up the picture. Over the
couple of hundred miles a receiver can hear, that is a map; pretending to
a projection with a name would not make it more true.
"""
south: float
west: float
north: float
east: float
left: int
top: int
width: int
height: int
@property
def mid_lat(self) -> float:
return (self.south + self.north) / 2.0
@property
def lon_squeeze(self) -> float:
return max(0.1, math.cos(math.radians(self.mid_lat)))
def xy(self, lat: float, lon: float) -> tuple[int, int]:
span_lon = max(1e-9, self.east - self.west)
span_lat = max(1e-9, self.north - self.south)
x = self.left + (lon - self.west) / span_lon * (self.width - 1)
y = self.top + (self.north - lat) / span_lat * (self.height - 1)
return int(round(x)), int(round(y))
def inside(self, lat: float, lon: float) -> bool:
return self.south <= lat <= self.north and self.west <= lon <= self.east
@property
def width_nm(self) -> float:
return distance_nm(self.mid_lat, self.west, self.mid_lat, self.east)
def bounds_of(tracks: list[Track], margin: float = 0.06):
"""The box every track fits in, with a little air around it."""
lats, lons = [], []
for track in tracks:
for fix in track.fixes:
lats.append(fix.latitude)
lons.append(fix.longitude)
if not lats:
return None
south, north = min(lats), max(lats)
west, east = min(lons), max(lons)
# A single aircraft heard once needs a box anyway, or the map is a point.
pad_lat = max((north - south) * margin, 0.02)
pad_lon = max((east - west) * margin, 0.02)
return (south - pad_lat, west - pad_lon, north + pad_lat, east + pad_lon)
def fit(tracks: list[Track], width: int = 960, max_height: int = 1200,
box=None) -> Projection | None:
"""Choose the canvas: as wide as asked, as tall as the area needs.
``box`` is a (south, west, north, east) to draw instead of the one the
tracks happen to fill -- a fixed frame around the receiver, so that the
scale of the picture does not change with whatever flew past.
"""
box = bounds_of(tracks) if box is None else box
if box is None:
return None
south, west, north, east = box
body_w = max(120, width - 2 * MARGIN)
mid = (south + north) / 2.0
across = max(1e-6, (east - west) * math.cos(math.radians(mid)))
down = max(1e-6, north - south)
body_h = int(round(body_w * down / across))
body_h = max(160, min(max_height, body_h))
return Projection(south=south, west=west, north=north, east=east,
left=MARGIN, top=TITLE_H, width=body_w, height=body_h)
def canvas_size(view: Projection) -> tuple[int, int]:
"""The whole picture, body plus the strips above and below it."""
width = view.width + 2 * MARGIN
height = view.top + view.height + LEGEND_H
return width + width % 2, height + height % 2 # even, for the video
# ---------------------------------------------------------------------------
# The parts that never move
# ---------------------------------------------------------------------------
_LADDER = (0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0)
def _grid_step(span: float, wanted: int = 5) -> float:
for step in _LADDER:
if span / step <= wanted:
return step
return _LADDER[-1]
def _degrees(value: float, axis: str) -> str:
"""A latitude or longitude as a label, without a degree sign to draw."""
hemisphere = ("N" if value >= 0 else "S") if axis == "lat" \
else ("E" if value >= 0 else "W")
return f"{abs(value):.2f}{hemisphere}"
def dim_ground(levels, brightness: float = GROUND_BRIGHTNESS):
"""Squeeze the map into the part of its range being used.
The palette holds the whole range; a drawing uses as much of it as the
brightness asks for. Scaling the shades rather than rebuilding the
palette means the animation's colour table stays the same table from one
frame to the next, which is the whole basis of the frame differencing.
"""
# The theme shapes how the setting is felt, without capping it. A
# screen made of lines wants the ground well out of the way, so those
# themes bend the middle of the range down hard -- but the top of the
# setting is still a full-brightness map on every one of them, which a
# plain multiplier could never give.
part = max(0.05, min(1.0, float(brightness))) ** THEME.ground_gamma
part = max(0.02, part)
top = max(1, int(round((GROUND_SHADES - 1) * part)))
return np.clip((np.asarray(levels, dtype=np.float64)
* top / (GROUND_SHADES - 1)).round(),
0, GROUND_SHADES - 1).astype(np.uint8)
def background(view: Projection, title: str = "", airports=(),
unit: str = DEFAULT_SPEED_UNIT, ground=None,
attribution: str = "",
brightness: float = GROUND_BRIGHTNESS,
home=None, rings: float = 0.0) -> np.ndarray:
"""The map without anything flying on it: ground, grid, scale, key, title.
``home`` is where the receiver is standing, and here it only says where
the range rings are centred. The flag that marks it is drawn by the
frame rather than by the ground, so that nothing can be drawn over it
afterwards.
``ground`` is the real map underneath, as brightness levels covering the
body of the picture, or None for the plain grid. Where there is one the
graticule is drawn lighter over it, because the coastline is doing the
work the grid was there to do.
"""
width, height = canvas_size(view)
img = np.full((height, width), BG, dtype=np.uint8)
if ground is not None:
patch = dim_ground(ground, brightness)
rows = min(view.height, patch.shape[0])
cols = min(view.width, patch.shape[1])
img[view.top:view.top + rows, view.left:view.left + cols] = \
GROUND + np.clip(patch[:rows, :cols], 0, GROUND_SHADES - 1)
_box(img, view.left - 1, view.top - 1, view.left + view.width,
view.top + view.height, GRID)
step_lat = _grid_step(view.north - view.south)
step_lon = _grid_step(view.east - view.west)
dashes = 6 if ground is not None else 3
lat = math.ceil(view.south / step_lat) * step_lat
while lat <= view.north:
_, y = view.xy(lat, view.west)
img[y, view.left:view.left + view.width:dashes] = GRID
draw_text(img, view.left + 3, y - GLYPH_H - 1, _degrees(lat, "lat"), DIM)
lat += step_lat
lon = math.ceil(view.west / step_lon) * step_lon
while lon <= view.east:
x, _ = view.xy(view.south, lon)
img[view.top:view.top + view.height:dashes, x] = GRID
draw_text(img, x + 3, view.top + view.height - GLYPH_H - 3,
_degrees(lon, "lon"), DIM)
lon += step_lon
for name, lat, lon in airports:
if not view.inside(lat, lon):
continue
x, y = view.xy(lat, lon)
_box(img, x - 3, y - 3, x + 3, y + 3, AIRPORT)
_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)
if title:
draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK)
if attribution:
# Whoever's tiles those are gets their name on the picture: a GIF
# travels without the readme that would otherwise carry it.
draw_text(img, MARGIN, view.top + view.height + 22, attribution, GRID)
_scale_bar(img, view, unit)
_key(img, view)
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.
In whatever unit the speeds are in: miles an hour beside a scale in
nautical miles would be two different miles on one picture.
"""
across = in_distance(view.width_nm, unit)
per_unit = view.width / max(1e-9, across)
for miles in (500, 200, 100, 50, 20, 10, 5, 2, 1):
pixels = int(round(miles * per_unit))
if pixels <= view.width * 0.32:
break
else:
return
y = view.top + view.height + 12
x = view.left
_line(img, x, y, x + pixels, y, DIM)
_line(img, x, y - 3, x, y + 3, DIM)
_line(img, x + pixels, y - 3, x + pixels, y + 3, DIM)
draw_text(img, x + pixels + 6, y - 3,
f"{miles} {distance_label(unit).upper()}", DIM)
def _key(img: np.ndarray, view: Projection) -> None:
"""The altitude ramp, with the numbers that go with the colours."""
height, width = img.shape
bar_w = min(220, max(80, view.width // 4))
x0 = width - MARGIN - bar_w
y = view.top + view.height + 8
for i in range(bar_w):
img[y:y + 7, x0 + i] = RAMP + min(RAMP_STEPS - 1,
i * RAMP_STEPS // bar_w)
draw_text(img, x0 - text_width("ALTITUDE") - 6, y, "ALTITUDE", DIM)
for part, label in ((0.0, "0"), (0.5, "22K"), (1.0, "45K FT")):
x = x0 + int(part * (bar_w - 1))
# Left-aligned at the cold end, centred in the middle, right-aligned
# at the hot end, so no label hangs off either end of the bar.
draw_text(img, x - int(text_width(label) * part), y + 9, label, DIM)
# ---------------------------------------------------------------------------
# One frame
# ---------------------------------------------------------------------------
# How long a label takes to swing across when it has to move, in seconds of
# the animation as it plays. The same half-second the window uses, so the
# two look like the same program.
LABEL_GLIDE = 0.45
class LabelPlaces:
"""Where each aircraft's label is, and where it is going.
A frame on its own has no reason to put a label anywhere in particular,
and the placer is happy to answer differently on the next one -- so a
label hops from one side of its aircraft to the other and back as the
aeroplane two along moves a pixel. Watching that is worse than reading
a label in an awkward place.
So the same rule the window uses: a label keeps the spot it has for as
long as that spot still works, and the moves that are left are eased
rather than jumped. Kept as an offset from the aircraft, so one
crossing the picture carries its label with it and only a real clash
asks for a new place.
"""
def __init__(self, glide: float = LABEL_GLIDE):
self.glide = glide
self.want: dict[str, tuple[int, int]] = {}
self.at: dict[str, tuple[float, float]] = {}
self._seen: set[str] = set()
def begin(self) -> None:
self._seen = set()
def end(self) -> None:
"""Forget the labels that were not drawn this time.
An aircraft that has gone must not glide in from where it stood
half an hour ago when it comes back.
"""
for gone in set(self.at) - self._seen:
self.at.pop(gone, None)
self.want.pop(gone, None)
def kept(self, icao: str, x: int, y: int, fits):
"""The spot this label already holds, if it still works.
``fits`` is asked whether a place is free and returns it, or None.
A label is only sent looking for somewhere new when the place it has
has actually been taken.
"""
self._seen.add(icao)
held = self.want.get(icao)
if held is None:
return None
return fits(x + held[0], y + held[1])
def settle(self, icao: str, x: int, y: int, spot) -> tuple[int, int]:
"""Remember where this label belongs, and say where to draw it."""
self._seen.add(icao)
self.want[icao] = (spot[0] - x, spot[1] - y)
return spot
def drawn(self, icao: str, x: int, y: int, spot,
step: float) -> tuple[int, int]:
"""Where the label actually goes this frame, on its way to `spot`."""
target = (float(spot[0] - x), float(spot[1] - y))
here = self.at.get(icao)
if here is None: # first sight: no swing to make
self.at[icao] = target
return spot
here = _glide(here, target, step, self.glide)
self.at[icao] = here
return int(round(x + here[0])), int(round(y + here[1]))
def _glide(current, target, elapsed: float, seconds: float):
"""One step of a label on its way to where it now belongs.
Eased by the distance left rather than by a count of frames, so the
swing takes the same time whatever the frame rate is set to.
"""
cx, cy = current
tx, ty = target
if seconds <= 0.0:
return (float(tx), float(ty))
if elapsed <= 0.0:
return (float(cx), float(cy))
part = 1.0 - math.exp(-3.0 * elapsed / seconds)
nx, ny = cx + (tx - cx) * part, cy + (ty - cy) * part
if abs(tx - nx) < 0.4 and abs(ty - ny) < 0.4:
return (float(tx), float(ty))
return (nx, ny)
def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
when: float, *, trail_seconds: float = 0.0,
stale: float = 300.0, labels: bool = True,
clock: str = "", unit: str = DEFAULT_SPEED_UNIT,
project: bool = True, known=None,
fade: float = 0.0, places: "LabelPlaces | None" = None,
step: float = 0.0, home=None,
box_opacity: float = 0.0, beat: float = 0.0,
pulse: float = 0.0, echo: float = 0.0,
echo_reach: float = ECHO_REACH) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been.
``project`` is what makes an animation an animation: between reports an
aircraft is dead-reckoned from the speed and heading it last gave. A
still picture of a whole evening turns it off, because there the moment
being drawn is hours after most of the aircraft stopped transmitting,
and flying each of them on for those hours would scatter the lot of them
across three states.
"""
img = base.copy()
flying = 0
# Counted before anything is drawn, so that every label on one frame says
# the same amount and the picture does not change its mind halfway down.
crowded = sum(1 for track in tracks
if track.at(when, stale=stale) is not None) > CROWDED
taken: list[tuple[int, int, int, int]] = []
if places is not None:
places.begin()
for track in tracks:
if project:
seen = showing(track, when, stale, fade)
else:
seen = ((track.fixes[-1], 1.0) if when >= track.fixes[-1].at
else showing(track, when, stale, fade))
if seen is None:
continue
now, strength = seen
flying += 1
trail = track.trail(when, trail_seconds)
recent = when - (trail_seconds or 120.0) / 2.0
faded = fade_ramp(strength)
for a, b in zip(trail, trail[1:]):
base_shade = TRAIL if b.at >= recent else OLD
# A fading aircraft takes its trail with it: the two are one
# thing on the picture and half of it lingering would be worse
# than either.
shade = max(base_shade, faded) + altitude_step(b.altitude_ft)
x0, y0 = view.xy(a.latitude, a.longitude)
x1, y1 = view.xy(b.latitude, b.longitude)
_line(img, x0, y0, x1, y1, shade)
colour = faded + altitude_step(now.altitude_ft)
x, y = view.xy(now.latitude, now.longitude)
beats = phase_of(track.icao)
# The echo goes down before the aircraft, so the ring passes under
# the thing that sent it out rather than over it.
if echo > 0 and strength >= 1.0:
_echo(img, x, y, beat, echo, echo_reach, beats,
altitude_step(now.altitude_ft))
_marker(img, x, y, now.track_deg, colour)
# Only an aircraft still being heard pulses. One that has gone
# quiet is fading, and a thing that is fading and beating at once
# says two contradictory things about itself.
if pulse > 0 and strength >= 1.0:
_pulse(img, x, y, now.track_deg, now.altitude_ft,
pulse_at(beat, pulse, beats))
if labels and strength > LABEL_WHILE:
_label(img, x, y, track, now, colour, taken, unit,
entry=(known.get(track.icao)
if known and not crowded else None),
places=places, step=step, strength=strength,
brief=crowded, opacity=box_opacity)
if places is not None:
places.end()
if clock:
_clock_strip(img, view, clock, flying)
# The flag goes down after everything else, and nothing is drawn after
# it: it says where the receiver is standing, which is the one thing on
# the picture that must never be hidden behind an aeroplane that
# happened to fly over it.
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)
# Last of all, so that everything drawn this frame glows and nothing
# drawn after it paints over the halo. The halo only ever goes on the
# ground, the grid and the background, so it cannot cover the flag.
return bloom(img)
def showing(track: Track, when: float, stale: float = 300.0,
fade: float = 0.0):
"""Where an aircraft is at a moment, and how strongly to draw it.
An aircraft that stops transmitting has not stopped existing, and taking
it off the picture between one frame and the next says it did. So past
the point where its position can still be believed it is left where it
was last actually seen, fading, until it is gone -- which is what the
watching eye reads as "that one has gone quiet", rather than as a
blink.
Fading happens at the last known position and never at a reckoned one:
the whole reason for giving up on an aircraft is that where it would be
by now is a guess.
"""
now = track.at(when, stale=stale)
if now is not None:
return now, 1.0
if fade <= 0 or not track.fixes:
return None
last = track.fixes[-1]
gone = when - last.at - stale
if gone < 0 or gone > fade:
return None
return last, max(0.0, 1.0 - gone / fade)
def pulse_at(beat: float, rate: float = PULSE_SECONDS,
phase: float = 0.0) -> float:
"""How far up its pulse a thing is now: nought dimmest, one brightest.
A raised cosine rather than a sawtooth, so that the bright end is a
swell and not a flash -- what a phosphor does when the beam lingers, not
what a warning light does.
"""
if rate <= 0:
return 1.0
turn = (beat / rate + phase) * math.tau
return 0.5 - 0.5 * math.cos(turn)
def phase_of(icao: str) -> float:
"""Where in its cycle one aircraft is, so they do not all beat as one.
Taken from the address, which never changes, so an aeroplane keeps its
own rhythm from one frame to the next and from one drawing of the same
log to the next. Aircraft pulsing in step read as one flashing display
rather than as a sky full of separate things.
"""
return (int(icao[-4:], 16) % 997) / 997.0 if icao else 0.0
def echo_age(beat: float, every: float, phase: float = 0.0) -> float:
"""How long ago the echo now travelling outward left the aircraft.
One at a time: a new one leaves as the last reaches the end of its
reach, so the sky has one ring per aircraft rather than a stack of them
to draw and read.
"""
if every <= 0:
return 0.0
return ((beat / every + phase) % 1.0) * every
def hot_step(feet: float) -> int:
"""Which of the sixteen peak colours a height falls in."""
return int(min(HOT_STEPS - 1,
max(0, round(altitude_step(feet)
* (HOT_STEPS - 1) / (RAMP_STEPS - 1)))))
def _ring(img: np.ndarray, cx: int, cy: int, radius: int,
colour: int) -> None:
"""A circle, one pixel thick, clipped to the canvas.
The midpoint algorithm, which is the circle's answer to Bresenham: no
trigonometry per pixel and no gaps, since it steps one pixel at a time
round an eighth of the circle and mirrors that eighth into the other
seven.
"""
height, width = img.shape
r = int(radius)
if r < 1:
return
x, y, err = r, 0, 1 - r
while x >= y:
for px, py in ((cx + x, cy + y), (cx + y, cy + x),
(cx - y, cy + x), (cx - x, cy + y),
(cx - x, cy - y), (cx - y, cy - x),
(cx + y, cy - x), (cx + x, cy - y)):
if 0 <= px < width and 0 <= py < height:
img[py, px] = colour
y += 1
if err < 0:
err += 2 * y + 1
else:
x -= 1
err += 2 * (y - x) + 1
def _pulse(img: np.ndarray, x: int, y: int, heading: float, feet: float,
level: float) -> None:
"""Redraw one aircraft at whatever point of its pulse it has reached.
An indexed picture cannot dim a colour by a fraction, so a swell here is
two things at once: which family of colours the aeroplane is drawn from,
and how far the halo round it reaches. Between them they give six
steps rather than two, which at a couple of seconds a cycle reads as a
swell rather than as a blink.
At the top of it the aeroplane is drawn in the peak colours and carries
two rings of halo, which is the raster burn of a beam that has sat in
one place a little too long.
"""
step = altitude_step(feet)
if level >= 0.72:
_burn(img, x, y, heading, 2, TRAIL + step, OLD + step)
_marker(img, x, y, heading, HOT + hot_step(feet))
elif level >= 0.45:
_burn(img, x, y, heading, 1, TRAIL + step, OLD + step)
_marker(img, x, y, heading, RAMP + step)
elif level >= 0.22:
_marker(img, x, y, heading, RAMP + step)
else:
_marker(img, x, y, heading, TRAIL + step)
def _burn(img: np.ndarray, x: int, y: int, heading: float, rings: int,
near: int, far: int) -> None:
"""The halo round a burning aircraft: the shape itself, spread outwards.
Grown from the aeroplane rather than drawn as a circle round it, so the
glow has the shape of the thing casting it -- which is what a phosphor
does, and what a ring of dots emphatically does not. The marker is
stamped into a scrap of its own, spread a pixel at a time, and only the
spread part is painted back, and only where the picture was still empty.
Never over anything else that was drawn: a halo is what light does to
the dark around a thing, and painting it over a neighbouring aeroplane
would be light doing something light does not do.
"""
height, width = img.shape
pad = rings + 8
left, top = x - pad, y - pad
right, bottom = x + pad + 1, y + pad + 1
if right <= 0 or bottom <= 0 or left >= width or top >= height:
return
scrap = np.zeros((2 * pad + 1, 2 * pad + 1), dtype=np.uint8)
_marker(scrap, pad, pad, heading, 1)
grown = scrap.copy()
for ring in range(1, rings + 1):
spread = grown.copy()
for dy, dx in ((0, 1), (0, -1), (1, 0), (-1, 0)):
shifted = np.roll(grown, (dy, dx), axis=(0, 1))
spread = np.where((spread == 0) & (shifted != 0), ring + 1, spread)
grown = spread
patch = img[max(0, top):min(height, bottom),
max(0, left):min(width, right)]
cut = grown[max(0, -top):max(0, -top) + patch.shape[0],
max(0, -left):max(0, -left) + patch.shape[1]]
empty = ((patch == BG) | (patch == GRID)
| ((patch >= GROUND) & (patch < GROUND + GROUND_SHADES)))
for ring in range(rings, 0, -1):
colour = near if ring == 1 else far
patch[:] = np.where(empty & (cut == ring + 1), colour, patch)
def _echo(img: np.ndarray, x: int, y: int, beat: float, every: float,
reach: float, phase: float, step: int) -> None:
"""One ring travelling outward from an aircraft, dimming as it grows.
What a radar repeater does, and what the eye reads as "this thing is
transmitting" -- which is exactly what an aeroplane on this picture is
doing, twice a second, which is how it got here at all.
"""
age = echo_age(beat, every, phase)
part = age / max(1e-9, every)
radius = int(round(reach * part))
if radius < 3:
return
# Dimmer the further out it has got, in the three steps the palette
# holds: the ring is the aircraft's own colour, spent.
shade = TRAIL if part < 0.34 else (OLD if part < 0.67 else FAINT)
_ring(img, x, y, radius, shade + step)
def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None:
"""A little arrowhead, pointing the way the aircraft is going.
Nothing is drawn for an aircraft that is not on the picture. Said
plainly because the obvious way to write the dot at its centre --
clamping the near edge and letting the far one alone -- reads as a
negative slice when the marker is off the top or the left, which numpy
obligingly interprets as counting back from the far side and fills most
of the frame with one colour.
"""
height, width = img.shape
if not (0 <= x < width and 0 <= y < height):
return
angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle)
def point(ahead: float, side: float) -> tuple[float, float]:
# Screen coordinates: north is up, which is minus y.
return (x + side * cos + ahead * sin, y + side * sin - ahead * cos)
_triangle(img, (point(5.0, 0.0), point(-3.5, 3.0), point(-3.5, -3.0)),
colour)
img[max(0, y - 1):min(height, y + 2),
max(0, x - 1):min(width, x + 2)] = colour
def label_lines(track: Track, now, unit: str = DEFAULT_SPEED_UNIT,
entry=None, brief: bool = False) -> list[tuple[str, str]]:
"""What goes beside one aircraft, a line at a time.
Each row is the text and the country whose flag belongs next to it -- a
two-letter code the drawing turns into twelve pixels of one, or empty for
a row that is only words.
The height is in feet with its unit on it. It used to be the flight
level, hundreds of feet the way it is said on the radio, which is shorter
and is what an aviator reads -- but "376" beside an aircraft is only a
height to somebody who already knows it is one. The airports stay as
their codes, since a full name runs to forty characters beside an
aircraft twelve pixels across.
"""
rows: list[tuple[str, str]] = []
# To the nearest twenty-five feet, which is the step Mode S reports
# altitude in: a real reading is a multiple of it and comes through
# untouched, while a moment between two reports -- which is interpolated,
# and is most of the moments in an animation -- stops claiming to know
# the aircraft's height to the foot.
height = (f"{round(now.altitude_ft / 25) * 25:,} ft"
if now.altitude_ft else "")
# The unit goes on the number, every time: a bare "480" beside an
# aircraft is three different speeds depending on who is reading it.
speed = (f"{in_speed(now.ground_speed_kt, unit):.0f}"
f"{speed_label(unit).upper()}") if now.ground_speed_kt else ""
line = " ".join(x for x in (height, speed) if x)
if line:
rows.append((line, ""))
if brief:
# A crowded picture: the height and the speed and nothing else. A
# flag and a class beside each of three hundred aircraft is not more
# information, it is a page of small pictures with a map behind it.
return rows
from .flags import iso_for
from .flights import aircraft_class, describe_address
# The country of registration, off the address block, so it is there for
# an aircraft no register has ever heard of. Taking it from the
# register's answer instead left the flag off exactly the aircraft that
# had nothing else beside them either.
home = iso_for(getattr(entry, "owner_country", "")
or getattr(entry, "country", "")
or describe_address(track.icao))
# What sort of aircraft it is: what it broadcast about itself, and
# whether its address is a military one.
kind_of = aircraft_class(track.icao, getattr(track, "category", ""))
if kind_of:
rows.append((kind_of, home))
if entry is None:
# Nothing but the flag left to say, and it is worth a row of its
# own: it says where the aeroplane is from, which is more than the
# rest of an unknown aircraft's box says put together.
if home and not kind_of:
rows.append(("", home))
return rows
kind = entry.type_code or entry.model
identity = " ".join(x for x in (kind, entry.registration) if x)
if identity:
rows.append((identity, "" if kind_of else home))
elif home and not kind_of:
rows.append(("", home))
# Only when the aircraft could actually be flying it. A callsign is a
# flight number and an airline runs the same number over several legs in
# a day, so a register's one route for it is quite often somebody else's
# leg -- and a route drawn beside an aircraft reads as a statement about
# that aircraft.
from .flights import route_fits
if not route_fits(entry, now.latitude, now.longitude):
return rows
if entry.origin_code or entry.origin:
rows.append((_short_place(entry.origin_code, entry.origin),
entry.origin_country))
if entry.destination_code or entry.destination:
rows.append((_short_place(entry.destination_code, entry.destination),
entry.destination_country))
return rows
def _short_place(code: str, name: str) -> str:
"""An airport in as few characters as still say which one."""
if code:
return code
return " ".join((name or "").split()[:2])[:18]
def _label(img: np.ndarray, x: int, y: int, track: Track, now,
colour: int, taken: list | None = None,
unit: str = DEFAULT_SPEED_UNIT, entry=None,
places: "LabelPlaces | None" = None, step: float = 0.0,
strength: float = 1.0, brief: bool = False,
opacity: float = 0.0) -> None:
"""Who it is, and everything else known about it, beside the aircraft.
Two aircraft that pass close together would otherwise have their labels
written over each other, which is exactly the moment somebody is looking
at that part of the picture. So the four places a label can go are tried
in turn and the first clear one is used; when they are all taken the
label goes to the right anyway, because a label somewhere beats none.
``places`` carries what was decided on the frame before, so that a label
keeps its spot instead of being re-decided from nothing every frame, and
swings to a new one rather than jumping. Without it -- a still picture,
where there is no frame before -- the placing is exactly as it was.
"""
from .flags import FLAG_W
height, width = img.shape
rows = label_lines(track, now, unit, entry, brief=brief)
indent = FLAG_W + 3 if any(country for _, country in rows) else 0
span = max([text_width(track.name)]
+ [indent + text_width(text) for text, _ in rows])
tall = GLYPH_H + (GLYPH_H + 2) * len(rows) + 1
def clear(at_x, at_y):
if at_x < 2 or at_x + span > width - 2:
return None
if at_y < 1 or at_y + tall > height - 1:
return None
box = (at_x, at_y, at_x + span, at_y + tall)
if taken is not None and any(_overlaps(box, other) for other in taken):
return None
return int(at_x), int(at_y)
beside = ((x + 8, y - GLYPH_H - 1), # right, the usual place
(x - 8 - span, y - GLYPH_H - 1), # left
(x - span // 2, y + 9), # under it
(x - span // 2, y - tall - 6)) # over it
# The place it had, first of all, and only if something has taken it is
# anywhere else considered.
found = None if places is None else places.kept(track.icao, x, y, clear)
if found is None:
found = next((spot for spot in map(lambda p: clear(*p), beside)
if spot), None)
if found is None:
# The four places beside it are taken, which happens as soon as a few
# aircraft are close together. Working outwards buys a readable
# label for the price of a longer look, which is the right trade.
for reach in (1.3, 1.8, 2.5, 3.4):
for step in range(12):
angle = math.tau * step / 12
found = clear(x + math.cos(angle) * span * reach - span / 2,
y + math.sin(angle) * tall * reach - tall / 2)
if found:
break
if found:
break
left, top = found if found else beside[0]
left = max(2, min(int(left), width - 2 - span))
top = max(1, min(int(top), height - 1 - tall))
# What is spoken for is where the label is settling, not where it has
# got to: laying the next one out against a label in mid-swing would
# move that one as well, and move it back when the first arrived.
if taken is not None:
taken.append((left, top, left + span, top + tall))
if places is not None:
places.settle(track.icao, x, y, (left, top))
left, top = places.drawn(track.icao, x, y, (left, top), step)
left = max(2, min(left, width - 2 - span))
top = max(1, min(top, height - 1 - tall))
# The box fades with the aircraft: the name already did, because it is
# drawn in the aircraft's own colour, but the rows and the flag were
# fixed colours and stayed at full brightness on a label that was on its
# 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 card behind the words, as translucent as the setting asks for. An
# indexed picture cannot blend, so this darkens the ground under the
# label towards the panel colour instead: at nothing it is untouched and
# the words sit straight on the map as they always did, and at the whole
# way it is the panel and nothing of the map shows through.
if opacity > 0.0:
_panel(img, left - 3, top - 2, left + span + 3, top + tall,
opacity * 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:
if country:
draw_flag(img, left, at - 1, country, level)
draw_text(img, left + indent, at, text, LABEL_INK + level)
at += GLYPH_H + 2
def _panel(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
opacity: float) -> None:
"""Darken a rectangle towards the panel colour, by however much.
The ground has thirty-two shades and this walks them down: at a tenth
the map is very nearly untouched, at nine tenths it is a dark card with
the coastline just showing through, and at the whole way it is the panel
colour with nothing behind it at all. The background, which is already
as dark as the picture goes, is left alone until the very top of the
range, where the whole rectangle becomes the panel.
"""
height, width = img.shape
x0, x1 = max(0, int(x0)), min(width, int(x1))
y0, y1 = max(0, int(y0)), min(height, int(y1))
if x1 <= x0 or y1 <= y0:
return
part = max(0.0, min(1.0, float(opacity)))
if part <= 0.0:
return
patch = img[y0:y1, x0:x1]
if part >= 0.98:
patch[:] = PANEL
return
ground = (patch >= GROUND) & (patch < GROUND + GROUND_SHADES)
shade = (patch.astype(np.int16) - GROUND) * (1.0 - part)
patch[:] = np.where(ground, (GROUND + shade.round()).astype(np.uint8),
patch)
def draw_flag(img: np.ndarray, x: int, y: int, country: str,
level: int = 0) -> None:
"""Twelve pixels by eight of a flag, or the country's letters instead.
A country with no flag here is named rather than approximated: two
letters are never wrong, and a flag that is nearly another country's is
worse than no flag at all.
"""
from .flags import FLAG_W, flag_for
rows = flag_for(country) if THEME.flags else None
if rows is None:
# Named in the theme's own colour rather than the grid's when the
# theme has no flags at all: there it is not a fallback, it is what
# every country gets, and it has to be as readable as the row it
# sits beside.
plain = DIM if not THEME.flags else GRID
draw_text(img, x, y + 1, country[:2].upper(),
plain if level <= 0 else LABEL_INK + min(level, 3))
return
height, width = img.shape
for row, line in enumerate(rows):
yy = y + row
if not 0 <= yy < height:
continue
for column, letter in enumerate(line[:FLAG_W]):
xx = x + column
if 0 <= xx < width:
img[yy, xx] = flag_index(letter, level)
def _overlaps(a, b) -> bool:
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
def _clock_strip(img: np.ndarray, view: Projection, clock: str,
flying: int) -> None:
"""The time and the count, top right, where they do not cover the map."""
text = f"{clock} {flying} FLYING"
width = img.shape[1]
x = width - MARGIN - text_width(text)
_box(img, x - 4, 1, width - MARGIN + 2, TITLE_H - 4, PANEL, fill=True)
draw_text(img, x, (TITLE_H - GLYPH_H) // 2 - 1, text, INK)
# ---------------------------------------------------------------------------
# GIF
# ---------------------------------------------------------------------------
class _Bits:
"""Least-significant-bit-first bit packing, which is what GIF wants."""
def __init__(self):
self.out = bytearray()
self._value = 0
self._held = 0
def write(self, code: int, width: int) -> None:
self._value |= code << self._held
self._held += width
while self._held >= 8:
self.out.append(self._value & 0xFF)
self._value >>= 8
self._held -= 8
def flush(self) -> bytes:
if self._held:
self.out.append(self._value & 0xFF)
self._value, self._held = 0, 0
return bytes(self.out)
def _lzw(data: bytes, code_bits: int) -> bytes:
"""GIF's variable-width LZW.
Straight from the specification: codes start one bit wider than the
palette, the table grows a code at a time, the width goes up when the
next code would not fit and the whole table is thrown away and started
again when it fills.
"""
clear, end = 1 << code_bits, (1 << code_bits) + 1
roots = {bytes([i]): i for i in range(clear)}
table = dict(roots)
width = code_bits + 1
nxt = end + 1
bits = _Bits()
bits.write(clear, width)
run = b""
for byte in data:
longer = run + bytes([byte])
if longer in table:
run = longer
continue
bits.write(table[run], width)
if nxt < 4096:
table[longer] = nxt
nxt += 1
if nxt > (1 << width) and width < 12:
width += 1
else:
bits.write(clear, width)
table = dict(roots)
nxt = end + 1
width = code_bits + 1
run = bytes([byte])
if run:
bits.write(table[run], width)
bits.write(end, width)
return bits.flush()
def _blocks(data: bytes) -> bytes:
"""GIF carries its data in sub-blocks of at most 255 bytes."""
out = bytearray()
for at in range(0, len(data), 255):
chunk = data[at:at + 255]
out.append(len(chunk))
out += chunk
out.append(0)
return bytes(out)
def _frame_chunk(indices: np.ndarray, left: int, top: int, delay_cs: int,
transparent: int | None) -> bytes:
height, width = indices.shape
out = bytearray()
flags = 0x04 | (0x01 if transparent is not None else 0) # disposal: keep
out += b"\x21\xf9\x04" + bytes([flags]) + struct.pack("<H", delay_cs) \
+ bytes([transparent or 0, 0])
out += b"\x2c" + struct.pack("<HHHH", left, top, width, height) + b"\x00"
out.append(8)
out += _blocks(_lzw(indices.astype(np.uint8).tobytes(), 8))
return bytes(out)
def write_gif(path, frames, palette: np.ndarray = PALETTE,
delay_cs: int = 8, loop: bool = True) -> Path:
"""Write an animated GIF from an iterator of index arrays.
Only what changed is written after the first frame: an aircraft moves a
few pixels between frames and the map underneath it does not move at all,
so the difference is a small box and the file is a fraction of the size.
Unchanged pixels inside that box are transparent, which in GIF means
"leave whatever was there".
"""
path = Path(path)
previous: np.ndarray | None = None
with path.open("wb") as out:
for index, frame in enumerate(frames):
frame = np.asarray(frame, dtype=np.uint8)
if previous is None:
height, width = frame.shape
out.write(b"GIF89a" + struct.pack("<HH", width, height)
+ bytes([0xF7, 0, 0]))
out.write(palette.astype(np.uint8).tobytes())
if loop:
out.write(b"\x21\xff\x0bNETSCAPE2.0\x03\x01\x00\x00\x00")
out.write(_frame_chunk(frame, 0, 0, delay_cs, None))
previous = frame
continue
changed = frame != previous
if not changed.any():
# Nothing moved. Repeat the shortest possible sub-image
# rather than the whole picture, so a still moment costs a
# dozen bytes and the clock still runs.
out.write(_frame_chunk(frame[:1, :1], 0, 0, delay_cs, None))
previous = frame
continue
rows = np.flatnonzero(changed.any(axis=1))
cols = np.flatnonzero(changed.any(axis=0))
top, bottom = int(rows[0]), int(rows[-1]) + 1
left, right = int(cols[0]), int(cols[-1]) + 1
patch = frame[top:bottom, left:right].copy()
patch[~changed[top:bottom, left:right]] = TRANSPARENT
out.write(_frame_chunk(patch, left, top, delay_cs, TRANSPARENT))
previous = frame
out.write(b"\x3b")
return path
def ffmpeg_available() -> bool:
return shutil.which("ffmpeg") is not None
def write_mp4(path, frames, palette: np.ndarray = PALETTE,
fps: float = 12.0, size: tuple[int, int] | None = None) -> Path:
"""Write an MP4 by feeding raw frames to ffmpeg, where there is one."""
path = Path(path)
first = None
iterator = iter(frames)
if size is None:
first = np.asarray(next(iterator), dtype=np.uint8)
size = (first.shape[1], first.shape[0])
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{size[0]}x{size[1]}", "-r", f"{fps:g}", "-i", "-",
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(path)]
process = subprocess.Popen(command, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
try:
if first is not None:
process.stdin.write(palette[first].tobytes())
for frame in iterator:
process.stdin.write(
palette[np.asarray(frame, dtype=np.uint8)].tobytes())
process.stdin.close()
except BrokenPipeError:
pass
code = process.wait()
if code != 0:
raise RuntimeError(f"ffmpeg could not write {path.name}")
return path
# ---------------------------------------------------------------------------
# Putting it together
# ---------------------------------------------------------------------------
@dataclass
class Animation:
"""What was drawn, for saying so afterwards."""
path: Path
kind: str = "gif"
frames: int = 0
fps: float = 0.0
speed: float = 1.0
width: int = 0
height: int = 0
aircraft: int = 0
covers: float = 0.0 # seconds of real time in the picture
ground: bool = False # whether a real map went under it
def summary(self) -> str:
real = _span(self.covers)
played = _span(self.frames / self.fps if self.fps else 0.0)
return (f"{self.aircraft} aircraft, {real} of flying in {played} "
f"({self.speed:.0f}x), {self.frames} frames at "
f"{self.width}x{self.height}"
+ (", on the map" if self.ground else ""))
def _span(seconds: float) -> str:
if seconds < 90:
return f"{seconds:.0f} s"
minutes, secs = divmod(int(seconds), 60)
if minutes < 90:
return f"{minutes} min {secs:02d} s"
hours, minutes = divmod(minutes, 60)
return f"{hours} h {minutes:02d} min"
def local_airports(view: Projection, ask=None) -> list:
"""Every aerodrome inside the picture, from the map data itself.
A route only names the airports its aircraft are flying between, and
those are almost never the ones underneath: a receiver hears aircraft
over its own county, and the county's airports are exactly what says
where on the map you are looking.
"""
from . import basemap
try:
extra = {"ask": ask} if ask is not None else {}
found = basemap.airports_in(view.south, view.west, view.north,
view.east, **extra)
except Exception:
return []
return [(a["code"], a["latitude"], a["longitude"]) for a in found]
def _known_from(book, tracks: list[Track]) -> dict:
"""What a register says about each aircraft, looked up once.
Once, rather than once a frame: a five-hundred-frame animation would
otherwise ask the same question five hundred times, and the book is
thread-safe rather than free.
"""
if book is None:
return {}
out = {}
for track in tracks:
entry = book.get(track.icao, track.callsign)
if track.located and hasattr(book, "resolve"):
# Where a source listed a whole day's stops rather than a leg,
# the aircraft's own position says which leg it is on.
here = track.fixes[len(track.fixes) // 2]
entry = book.resolve(entry, here.latitude, here.longitude)
out[track.icao] = entry
return out
def _airports_from(known: dict, book=None):
"""Every airport named by a route, wherever its position can be had.
A route names two airports and often gives a position for neither, so
the codes are looked up as well -- once each, and remembered. An
airport that cannot be placed cannot be drawn, and a map of an evening's
flying with no airports on it is missing the two ends of every flight.
"""
seen: dict[str, tuple[str, float, float]] = {}
wanted: set[str] = set()
for entry in known.values():
for code, lat, lon in ((getattr(entry, "origin_code", ""),
getattr(entry, "origin_lat", 0.0),
getattr(entry, "origin_lon", 0.0)),
(getattr(entry, "destination_code", ""),
getattr(entry, "destination_lat", 0.0),
getattr(entry, "destination_lon", 0.0))):
if not code:
continue
if lat or lon:
seen[code] = (code, lat, lon)
else:
wanted.add(code)
if book is not None and hasattr(book, "airports"):
try:
for found in book.airports(sorted(wanted - set(seen))):
seen[found["code"]] = (found["code"], found["latitude"],
found["longitude"])
except Exception:
pass # a map with fewer airports on it
return list(seen.values())
def ground_for(view: Projection, fetch=None, url: str = "") -> tuple:
"""The real map under a picture, and the credit that has to go with it.
Returns ``(levels, attribution)``, or ``(None, "")`` when there is no
network, no tile server or nothing usable came back -- in which case the
picture is drawn the way it always was, on its own grid.
"""
from . import basemap
try:
extra = {"fetch": fetch} if fetch is not None else {}
if url:
extra["url"] = url
levels = basemap.ground_under(
view.south, view.west, view.north, view.east,
view.width, view.height, shades=GROUND_SHADES, **extra)
except Exception:
return None, ""
return (levels, basemap.ATTRIBUTION) if levels is not None else (None, "")
def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
seconds: float = 30.0, speed: float = 0.0, width: int = 960,
trail_seconds: float = 0.0, stale: float = 300.0,
title: str = "", book=None, labels: bool = True,
kind: str = "", unit: str = DEFAULT_SPEED_UNIT,
ground: bool = False, fetch=None, tile_url: str = "",
radius_nm: float = 0.0, centre=None,
brightness: float = GROUND_BRIGHTNESS,
airports: bool = False, ask=None, rings: bool = False,
box_opacity: float = 0.0, pulse: float = 0.0,
echo: float = 0.0, echo_reach: float = ECHO_REACH,
fade: float = 0.0) -> Animation | None:
"""Draw the whole log as a moving map.
``speed`` is how many seconds of real flying go by in one second of
animation; given ``seconds`` instead, it is worked out so the whole log
plays in about that long. The clock in the corner is the real time of
day, so a fast animation is still readable as an evening.
``ground`` and ``airports`` both reach for a network and are therefore
both off here, and both on in the settings the program itself uses: a
library call should not go out to the world unless it was asked to.
"""
located = [t for t in tracks if t.located]
if not located:
return None
# A receiver hears a hundred miles on a good day and a wrong position can
# come from anywhere, so a map drawn to fit everything heard is drawn to
# fit the errors: the aircraft end up a pixel across in the middle of an
# empty continent. Framing it on the receiver instead keeps the scale
# the same from one evening to the next, and leaves the mistakes off the
# edge where they belong.
box = None
if radius_nm > 0:
middle = centre or centre_of(located)
if middle is not None:
located = [t for t in within(located, middle[0], middle[1],
radius_nm) if t.located]
if not located:
return None
box = box_around(middle[0], middle[1], radius_nm)
view = fit(located, width=width, box=box)
if view is None:
return None
start = min(t.fixes[0].at for t in located)
finish = max(t.fixes[-1].at for t in located)
covers = max(1.0, finish - start)
if speed <= 0:
speed = covers / max(1.0, seconds)
frame_count = max(2, int(round(covers / speed * fps)) + 1)
if frame_count > MAX_FRAMES:
# An all-night log at one second per second is a hundred thousand
# frames and a file nobody can open. The animation runs faster
# instead of running out of disk, and says so afterwards.
frame_count = MAX_FRAMES
speed = covers / max(1e-9, (frame_count - 1) / fps)
day = datetime.fromtimestamp(start).strftime("%Y-%m-%d")
heading = title or f"{len(located)} AIRCRAFT {day}"
known = _known_from(book, located)
levels, credit = ground_for(view, fetch, tile_url) if ground \
else (None, "")
marked = _airports_from(known, book)
if airports:
# The ones underneath as well as the ones flown between, and the
# nearer ones last so they win where two share a name.
seen = {code for code, _, _ in marked}
marked += [one for one in local_airports(view, ask)
if one[0] not in seen]
base = background(view, title=heading, airports=marked,
unit=unit, ground=levels, attribution=credit,
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)
def frames():
# One of these for the whole run: it is what remembers where each
# label was on the frame before, so a label can keep its place and
# swing to a new one instead of being re-decided from nothing. The
# step is a frame of the animation as it plays, not of the flying,
# so the swing takes half a second to watch however fast the evening
# is being run through.
places = LabelPlaces()
step = 1.0 / fps if fps else 0.0
for i in range(frame_count):
when = start + i * speed / fps
clock = datetime.fromtimestamp(when).strftime("%H:%M:%S")
yield _pad_to(render_frame(base, view, located, when,
trail_seconds=trail_seconds,
stale=stale, labels=labels,
clock=clock, unit=unit, known=known,
fade=fade, places=places, step=step,
home=centre,
box_opacity=box_opacity,
# Seconds of watching, not of flying:
# a pulse is meant to look the same
# whatever speed the evening is being
# run through.
beat=i / fps if fps else 0.0,
pulse=pulse, echo=echo,
echo_reach=echo_reach),
canvas_w, canvas_h)
path = Path(out_path)
kind = (kind or path.suffix.lstrip(".") or "gif").lower()
if kind == "png":
# Not an animation at all: the whole log at once, every path drawn.
still = render_frame(base, view, located, finish, stale=covers + 1,
labels=labels, unit=unit, project=False,
known=known, home=centre,
box_opacity=box_opacity,
clock=datetime.fromtimestamp(finish)
.strftime("%H:%M:%S"))
write_png(path, PALETTE[still])
return Animation(path=path, kind="png", frames=1, fps=0.0, speed=speed,
width=canvas_w, height=canvas_h,
aircraft=len(located), covers=covers,
ground=levels is not None)
if kind in ("mp4", "mov", "m4v"):
write_mp4(path, frames(), PALETTE, fps=fps, size=(canvas_w, canvas_h))
else:
delay = max(2, int(round(100.0 / fps)))
fps = 100.0 / delay # what the file will actually play at
speed = covers / max(1e-9, (frame_count - 1) / fps)
write_gif(path, frames(), PALETTE, delay_cs=delay)
return Animation(path=path, kind=kind, frames=frame_count, fps=fps,
speed=speed, width=canvas_w, height=canvas_h,
aircraft=len(located), covers=covers,
ground=levels is not None)
def _pad_to(img: np.ndarray, width: int, height: int) -> np.ndarray:
"""Make a frame exactly the size the file was told it would be."""
if img.shape == (height, width):
return img
out = np.full((height, width), BG, dtype=np.uint8)
rows = min(height, img.shape[0])
cols = min(width, img.shape[1])
out[:rows, :cols] = img[:rows, :cols]
return out