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
2012 lines
84 KiB
Python
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
|