feat(renderer): implement per-aircraft drawing — story 2-5

Implements draw_aircraft() with heading arrow (filled/outlined for MLAT),
callsign+altitude label, and a 5-dot fade trail. Adds 5 tests covering
all ACs; all 94 tests pass, ruff lint and format clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 23:27:36 -04:00
parent e2e4e885d1
commit f530185e31
4 changed files with 162 additions and 21 deletions
+82 -1
View File
@@ -1 +1,82 @@
# stub
from __future__ import annotations
import collections
import math
from PIL import Image, ImageDraw, ImageFont
from planemapper.constants import COLOUR_TRAIL, TRAIL_DOT_SIZE_MAX, TRAIL_DOT_SIZE_MIN
from planemapper.models import Aircraft
from planemapper.renderer.colours import altitude_to_colour
def _rotate_point(x: float, y: float, angle_deg: float) -> tuple[float, float]:
r = math.radians(angle_deg)
return (x * math.cos(r) - y * math.sin(r), x * math.sin(r) + y * math.cos(r))
def _draw_arrow(
draw: ImageDraw.ImageDraw,
cx: int,
cy: int,
heading: float,
colour: tuple[int, int, int],
is_mlat: bool,
) -> None:
tip = _rotate_point(0, -12, heading)
left = _rotate_point(-6, 8, heading)
right = _rotate_point(6, 8, heading)
pts = [
(cx + tip[0], cy + tip[1]),
(cx + left[0], cy + left[1]),
(cx + right[0], cy + right[1]),
]
if is_mlat:
draw.polygon(pts, fill=None, outline=colour)
else:
draw.polygon(pts, fill=colour)
def _draw_label(
draw: ImageDraw.ImageDraw,
cx: int,
cy: int,
aircraft: Aircraft,
colour: tuple[int, int, int],
) -> None:
font = ImageFont.load_default()
if aircraft.callsign:
text = f"{aircraft.callsign}\n{aircraft.altitude_ft}ft"
else:
text = f"{aircraft.altitude_ft}ft"
draw.text((cx + 12, cy - 8), text, fill=colour, font=font)
def _draw_trail(
draw: ImageDraw.ImageDraw,
trail: collections.deque[tuple[int, int]],
) -> None:
n = len(trail)
if n == 0:
return
for i, (tx, ty) in enumerate(trail):
if n > 1:
size = int(TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / (n - 1))
else:
size = TRAIL_DOT_SIZE_MAX
r = max(size // 2, 1)
draw.ellipse((tx - r, ty - r, tx + r, ty + r), fill=COLOUR_TRAIL)
def draw_aircraft(
image: Image.Image,
aircraft: Aircraft,
pos: tuple[int, int],
trail: collections.deque[tuple[int, int]],
) -> None:
cx, cy = pos
colour = altitude_to_colour(aircraft.altitude_ft)
draw = ImageDraw.Draw(image)
_draw_trail(draw, trail)
_draw_arrow(draw, cx, cy, aircraft.heading, colour, aircraft.is_mlat)
_draw_label(draw, cx, cy, aircraft, colour)