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>
6.1 KiB
Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT)
Status: review
Story
As a user looking at the display, I want each aircraft drawn with a heading arrow, callsign/altitude label, a 5-dot position trail with the oldest dot smallest, and MLAT aircraft visually distinct, So that I can read direction, identity, altitude, recent path, and data confidence at a glance.
Acceptance Criteria
AC1: Given an Aircraft with heading=90.0 (due east) When the heading arrow is drawn Then the arrow points east on the display, correctly rotated from north-up reference
AC2: Given an Aircraft with callsign="BAW1" and altitude_ft=28000 When the label is drawn Then callsign and altitude are rendered near the aircraft position And the label colour matches the aircraft's altitude colour band
AC3: Given a trail deque with 3 entries When the trail is drawn Then 3 dots are rendered with decreasing size from most-recent to oldest (interpolated between TRAIL_DOT_SIZE_MAX and TRAIL_DOT_SIZE_MIN) And dot colour is COLOUR_TRAIL
AC4: Given an Aircraft with is_mlat=True When the aircraft is drawn Then it is rendered in a visually distinct style (dashed/dotted outline instead of filled triangle)
AC5: Given an Aircraft with callsign="" When the label is drawn Then altitude only is rendered with no blank callsign prefix, and no exception is raised
Tasks / Subtasks
-
Task 1: Implement drawing functions in
src/planemapper/renderer/aircraft.py(AC: #1–#5)- 1.1 Replace
# stubwith full implementation - 1.2 Implement
_rotate_point(x, y, angle_deg) -> tuple[float, float]:import math def _rotate_point(x, y, angle_deg): r = math.radians(angle_deg) return (x * math.cos(r) - y * math.sin(r), x * math.sin(r) + y * math.cos(r)) - 1.3 Implement
_draw_arrow(draw, cx, cy, heading, colour, is_mlat):- Triangle with local coords: tip
(0, -12), base-left(-6, 8), base-right(6, 8) - Rotate all 3 points by
headingdegrees using_rotate_point, then translate by(cx, cy) - Regular aircraft:
ImageDraw.polygon(points, fill=colour) - MLAT aircraft:
ImageDraw.polygon(points, fill=None, outline=colour)
- Triangle with local coords: tip
- 1.4 Implement
_draw_label(draw, cx, cy, aircraft, colour):- If
aircraft.callsignis non-empty: text =f"{aircraft.callsign}\n{aircraft.altitude_ft}ft" - If
aircraft.callsignis empty: text =f"{aircraft.altitude_ft}ft" - Position:
(cx + 12, cy - 8) - Font:
ImageFont.load_default() - Colour:
altitude_to_colour(aircraft.altitude_ft)
- If
- 1.5 Implement
_draw_trail(draw, trail):- Iterate the trail deque;
i=0is most recent - Size formula:
size = TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / max(len(trail) - 1, 1) - Draw
ImageDraw.ellipsecentred at trail point with half-width/heightsize // 2 - Colour:
COLOUR_TRAIL
- Iterate the trail deque;
- 1.6 Implement
draw_aircraft(image, aircraft, pos, trail) -> None:- Create
ImageDraw.Draw(image) - Resolve colour via
altitude_to_colour(aircraft.altitude_ft) - Unpack
posas(cx, cy) - Call
_draw_trail(draw, trail) - Call
_draw_arrow(draw, cx, cy, aircraft.heading or 0.0, colour, aircraft.is_mlat) - Call
_draw_label(draw, cx, cy, aircraft, colour)
- Create
- 1.1 Replace
-
Task 2: Write tests in
tests/test_aircraft_draw.py(AC: #1–#5)- 2.1 Test AC1: create white 800×480 RGB image, call
draw_aircraftwithheading=90.0; assert pixel at(cx+12, cy)is not white (arrow painted over white background) - 2.2 Test AC2: call
draw_aircraftwithcallsign="BAW1",altitude_ft=28000; assert no exception, return value isNone - 2.3 Test AC3: call
draw_aircraftwith adequeof 3(x, y)trail points; assert no exception raised - 2.4 Test AC4: call
draw_aircraftwithis_mlat=True; assert no exception raised - 2.5 Test AC5: call
draw_aircraftwithcallsign=""; assert no exception raised
- 2.1 Test AC1: create white 800×480 RGB image, call
-
Task 3: Run quality gates
- 3.1
python -m pytest tests/— all tests pass - 3.2
python -m ruff check .— zero violations - 3.3
python -m ruff format --check .— no formatting issues
- 3.1
Implementation Notes
Required imports for aircraft.py
import collections
import math
from PIL import Image, ImageDraw, ImageFont
from planemapper.models import Aircraft
from planemapper.constants import COLOUR_TRAIL, TRAIL_DOT_SIZE_MAX, TRAIL_DOT_SIZE_MIN
from planemapper.renderer.colours import altitude_to_colour
Arrow geometry detail
The arrow is drawn in "north-up" local coordinates before rotation. The tip points north (negative y) and the base is south (positive y):
- Tip:
(0, -12) - Base left:
(-6, 8) - Base right:
(6, 8)
The rotation matrix for clockwise-from-north bearing (matching compass/ADS-B heading convention) is the standard 2D rotation — no sign inversion required. A heading=0 leaves the triangle pointing up (north). A heading=90 rotates the tip to point right (east).
Trail dot size interpolation
With i=0 (most recent):
size = TRAIL_DOT_SIZE_MAX(largest)
With i = len(trail) - 1 (oldest):
size = TRAIL_DOT_SIZE_MIN(smallest)
The formula size = TRAIL_DOT_SIZE_MAX - i * (TRAIL_DOT_SIZE_MAX - TRAIL_DOT_SIZE_MIN) / max(len(trail) - 1, 1) handles the edge case of a single-point trail via max(..., 1).
MLAT visual distinction
MLAT aircraft use fill=None, outline=colour in polygon(), producing a hollow/outlined triangle rather than a filled one. This signals lower positional accuracy to the viewer without requiring a separate icon asset.
Label callsign guard
Guard with if aircraft.callsign: (falsy check covers both None and ""). Do not use is not None alone as an empty string should also suppress the callsign line.
Constants required in src/planemapper/constants.py
Ensure the following are present (add if missing):
TRAIL_MAX_DOTS = 5
TRAIL_DOT_SIZE_MAX = 6
TRAIL_DOT_SIZE_MIN = 2
COLOUR_TRAIL = COLOUR_BLACK