Files
planeMapper/_bmad-output/implementation-artifacts/2-3-home-marker-and-airspace-outlines.md
T
2026-04-22 23:13:58 -04:00

5.9 KiB
Raw Blame History

Story 2.3: Home Marker & Airspace Outlines

Status: ready-for-dev

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) in src/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), fill COLOUR_HOME_MARKER, width 3
    • 1.5 Use ImageDraw.Draw(image).line(...)
  • Task 2: Implement draw_airspace(image, bounds) in src/planemapper/renderer/airspace.py (AC: #2, #3)

    • 2.1 Replace # stub with 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; on FileNotFoundError: log.warning("airspace.geojson not found") and return
    • 2.5 Parse JSON, iterate data["features"]
    • 2.6 For each feature with geometry["type"] == "Polygon": get coords = 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
  • Task 3: Update tests/fixtures/airspace_sample.geojson with a sample polygon (AC: #2)

    • 3.1 Replace empty features list with one Polygon feature having 5 [lon, lat] coordinate pairs forming a closed ring near lat=53, lon=-6
  • 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), call draw_home_marker(image, bounds), assert pixel at (400, 240) is red
    • 4.2 Test AC2 (airspace drawn): Monkeypatch AIRSPACE_PATH to tests/fixtures/airspace_sample.geojson path, create image, call draw_airspace(image, bounds), assert no exception raised and function returns normally (drawing occurred without crash)
    • 4.3 Test AC3 (missing geojson): Monkeypatch AIRSPACE_PATH to a nonexistent path, call draw_airspace(image, bounds), assert no exception raised
  • 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

Dev Notes

Critical Context

Module locations:

  • src/planemapper/renderer/overlay.py — NEW file; implement draw_home_marker()
  • src/planemapper/renderer/airspace.py — currently # stub; replace with full draw_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"))