Files
planeMapper/_bmad-output/implementation-artifacts/2-5-per-aircraft-drawing.md
Matt Edholm 48a3a1c7dd review(2-5): code-review pass for per-aircraft drawing — add heading guard, close story
- Add `or 0.0` defensive guard on `aircraft.heading` in `draw_aircraft` per spec (task 1.6)
- Story 2-5 status: review → done
- Sprint status updated: 2-5-per-aircraft-drawing done
- Deferred work: add [2-5] default font 8px readability risk and [2-5] inline arrow geometry constants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:29:22 -04:00

6.1 KiB
Raw Permalink Blame History

Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT)

Status: done

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 # stub with 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 heading degrees using _rotate_point, then translate by (cx, cy)
      • Regular aircraft: ImageDraw.polygon(points, fill=colour)
      • MLAT aircraft: ImageDraw.polygon(points, fill=None, outline=colour)
    • 1.4 Implement _draw_label(draw, cx, cy, aircraft, colour):
      • If aircraft.callsign is non-empty: text = f"{aircraft.callsign}\n{aircraft.altitude_ft}ft"
      • If aircraft.callsign is empty: text = f"{aircraft.altitude_ft}ft"
      • Position: (cx + 12, cy - 8)
      • Font: ImageFont.load_default()
      • Colour: altitude_to_colour(aircraft.altitude_ft)
    • 1.5 Implement _draw_trail(draw, trail):
      • Iterate the trail deque; i=0 is 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.ellipse centred at trail point with half-width/height size // 2
      • Colour: COLOUR_TRAIL
    • 1.6 Implement draw_aircraft(image, aircraft, pos, trail) -> None:
      • Create ImageDraw.Draw(image)
      • Resolve colour via altitude_to_colour(aircraft.altitude_ft)
      • Unpack pos as (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)
  • 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_aircraft with heading=90.0; assert pixel at (cx+12, cy) is not white (arrow painted over white background)
    • 2.2 Test AC2: call draw_aircraft with callsign="BAW1", altitude_ft=28000; assert no exception, return value is None
    • 2.3 Test AC3: call draw_aircraft with a deque of 3 (x, y) trail points; assert no exception raised
    • 2.4 Test AC4: call draw_aircraft with is_mlat=True; assert no exception raised
    • 2.5 Test AC5: call draw_aircraft with callsign=""; assert no exception raised
  • 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

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