# 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`:** ```python 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: ```python # 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: ```python monkeypatch.setattr("planemapper.renderer.airspace.AIRSPACE_PATH", Path("tests/fixtures/airspace_sample.geojson")) ```