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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user