Adds draw_home_marker() in overlay.py (red cross at projected home position) and draw_airspace() in airspace.py (GeoJSON Polygon boundaries in blue, graceful FileNotFoundError handling). Includes airspace fixture and three tests covering all acceptance criteria. All 70 tests pass; ruff check and format clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.9 KiB
Story 2.3: Home Marker & Airspace Outlines
Status: review
Story
As a user glancing at the display, I want to see my home location marked on the map and published airspace boundaries shown as outlines, So that I have immediate spatial context for all aircraft positions.
Acceptance Criteria
AC1: Given a loaded base map image and home lat/lon When the home marker is drawn Then a distinct COLOUR_HOME_MARKER (red) cross/circle marker is drawn at the projected pixel position of the home location
AC2: Given a valid airspace.geojson at AIRSPACE_PATH When airspace outlines are drawn Then each feature's boundary is drawn as an outline in COLOUR_AIRSPACE (blue) on the image And GeoJSON [lon, lat] coordinates are reversed to (lat, lon) at the parse boundary before any projection
AC3: Given airspace.geojson does not exist at AIRSPACE_PATH When airspace draw is called Then no exception is raised — the map renders without airspace outlines and a WARNING is logged
Tasks / Subtasks
-
Task 1: Implement
draw_home_marker(image, bounds)insrc/planemapper/renderer/overlay.py(AC: #1)- 1.1 New file; imports:
from PIL import Image, ImageDraw;from planemapper.constants import COLOUR_HOME_MARKER;from planemapper.renderer.projection import MapBounds, project - 1.2 Signature:
def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None: - 1.3 Project
(bounds.home_lat, bounds.home_lon)to(cx, cy) - 1.4 Draw cross: horizontal line
(cx-10, cy)to(cx+10, cy), vertical line(cx, cy-10)to(cx, cy+10), fillCOLOUR_HOME_MARKER, width 3 - 1.5 Use
ImageDraw.Draw(image).line(...)
- 1.1 New file; imports:
-
Task 2: Implement
draw_airspace(image, bounds)insrc/planemapper/renderer/airspace.py(AC: #2, #3)- 2.1 Replace
# stubwith full implementation - 2.2 Imports:
import json,import logging;from PIL import Image, ImageDraw;from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE;from planemapper.renderer.projection import MapBounds, project - 2.3
log = logging.getLogger(__name__) - 2.4 Try to open
AIRSPACE_PATH; onFileNotFoundError:log.warning("airspace.geojson not found")and return - 2.5 Parse JSON, iterate
data["features"] - 2.6 For each feature with
geometry["type"] == "Polygon": getcoords = feature["geometry"]["coordinates"][0] - 2.7 Reverse GeoJSON
[lon, lat]→(lat, lon)at parse boundary:points = [project(lat, lon, bounds) for lon, lat in coords] - 2.8 Draw with
ImageDraw.Draw(image).line(points, fill=COLOUR_AIRSPACE, width=2); skip features with fewer than 2 points
- 2.1 Replace
-
Task 3: Update
tests/fixtures/airspace_sample.geojsonwith a sample polygon (AC: #2)- 3.1 Replace empty features list with one
Polygonfeature having 5[lon, lat]coordinate pairs forming a closed ring near lat=53, lon=-6
- 3.1 Replace empty features list with one
-
Task 4: Write tests in
tests/test_airspace.py(AC: #1, #2, #3)- 4.1 Test AC1 (home marker): Create 800×480 white RGB image, create
MapBounds(53.0, -6.0, 100.0), calldraw_home_marker(image, bounds), assert pixel at (400, 240) is red - 4.2 Test AC2 (airspace drawn): Monkeypatch
AIRSPACE_PATHtotests/fixtures/airspace_sample.geojsonpath, create image, calldraw_airspace(image, bounds), assert no exception raised and function returns normally (drawing occurred without crash) - 4.3 Test AC3 (missing geojson): Monkeypatch
AIRSPACE_PATHto a nonexistent path, calldraw_airspace(image, bounds), assert no exception raised
- 4.1 Test AC1 (home marker): Create 800×480 white RGB image, create
-
Task 5: Run quality gates
- 5.1
python -m pytest tests/— all tests pass - 5.2
python -m ruff check .— zero violations - 5.3
python -m ruff format --check .— no formatting issues
- 5.1
Dev Notes
Critical Context
Module locations:
src/planemapper/renderer/overlay.py— NEW file; implementdraw_home_marker()src/planemapper/renderer/airspace.py— currently# stub; replace with fulldraw_airspace()implementation
Constants already defined in src/planemapper/constants.py:
COLOUR_HOME_MARKER = COLOUR_RED # (255, 0, 0)
COLOUR_AIRSPACE = COLOUR_BLUE # (0, 0, 255)
AIRSPACE_PATH = Path("/etc/planemapper/airspace.geojson")
Projection: project(lat, lon, bounds) from planemapper.renderer.projection — lat first, lon second. MapBounds is the bounds object.
GeoJSON coordinate convention: GeoJSON uses [lon, lat] — MUST reverse at parse boundary. This is critical and easy to get wrong:
# CORRECT — unpack GeoJSON [lon, lat] order, then pass as (lat, lon) to project()
points = [project(lat, lon, bounds) for lon, lat in coords]
Airspace GeoJSON structure from OpenAIP:
FeatureCollection with features having geometry.type = "Polygon". coordinates[0] is the exterior ring as a list of [lon, lat] pairs. For MVP, only handle Polygon features — skip all others silently.
Home marker drawing detail:
The cross is drawn as two separate line() calls (or one call with both segments). The centre pixel at (400, 240) must be red after drawing when home is at the projected centre — tests assert this directly.
Existing airspace fixture at tests/fixtures/airspace_sample.geojson is currently {"type": "FeatureCollection", "features": []} — must be updated with a real polygon so AC2 test exercises the drawing path.
Sample polygon for fixture: Use 5 points forming a closed square ring near lat=53, lon=-6 (e.g. corners at ±0.1 degrees). Coordinates must be in GeoJSON [lon, lat] order with the first and last point identical to close the ring.
Test file: tests/test_airspace.py is a new file — create from scratch.
Monkeypatching AIRSPACE_PATH: The path constant is imported into planemapper.renderer.airspace, so patch it there:
monkeypatch.setattr("planemapper.renderer.airspace.AIRSPACE_PATH", Path("tests/fixtures/airspace_sample.geojson"))