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
@@ -1,6 +1,6 @@
# Story 2.5: Per-Aircraft Drawing (Arrow, Label, Trail, MLAT)
Status: ready-for-dev
Status: review
## Story
@@ -22,9 +22,9 @@ AC5: **Given** an `Aircraft` with `callsign=""` **When** the label is drawn **Th
## 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]`:
- [x] Task 1: Implement drawing functions in `src/planemapper/renderer/aircraft.py` (AC: #1#5)
- [x] 1.1 Replace `# stub` with full implementation
- [x] 1.2 Implement `_rotate_point(x, y, angle_deg) -> tuple[float, float]`:
```python
import math
def _rotate_point(x, y, angle_deg):
@@ -32,23 +32,23 @@ AC5: **Given** an `Aircraft` with `callsign=""` **When** the label is drawn **Th
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)`:
- [x] 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)`:
- [x] 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)`:
- [x] 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`:
- [x] 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)`
@@ -56,17 +56,17 @@ AC5: **Given** an `Aircraft` with `callsign=""` **When** the label is drawn **Th
- 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
- [x] Task 2: Write tests in `tests/test_aircraft_draw.py` (AC: #1#5)
- [x] 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)
- [x] 2.2 Test AC2: call `draw_aircraft` with `callsign="BAW1"`, `altitude_ft=28000`; assert no exception, return value is `None`
- [x] 2.3 Test AC3: call `draw_aircraft` with a `deque` of 3 `(x, y)` trail points; assert no exception raised
- [x] 2.4 Test AC4: call `draw_aircraft` with `is_mlat=True`; assert no exception raised
- [x] 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
- [x] Task 3: Run quality gates
- [x] 3.1 `python -m pytest tests/` — all tests pass
- [x] 3.2 `python -m ruff check .` — zero violations
- [x] 3.3 `python -m ruff format --check .` — no formatting issues
## Implementation Notes
@@ -35,7 +35,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
generated: 2026-04-22
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 ready-for-dev, epic-2 in-progress
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 review, epic-2 in-progress
project: planeMapper
project_key: NOKEY
tracking_system: file-system
@@ -57,7 +57,7 @@ development_status:
2-2-coordinate-projection-and-base-map-loading: done
2-3-home-marker-and-airspace-outlines: done
2-4-altitude-colour-bands-and-aircraft-type-icons: done
2-5-per-aircraft-drawing: ready-for-dev
2-5-per-aircraft-drawing: review
2-6-stateful-renderer-and-display-interface: backlog
2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog
epic-2-retrospective: optional