Files
planeMapper/_bmad-output/implementation-artifacts/2-3-home-marker-and-airspace-outlines.md
T
Matt Edholm 2ba3d03c96 Review story 2.3: home marker and airspace outlines passes all ACs
All 10 review criteria pass without any code fixes required. Two tech-debt
items added to deferred-work: non-Polygon geometry types silently skipped
(intentional for MVP) and null-geometry GeoJSON features would raise
AttributeError (acceptable for controlled OpenAIP input).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:17:42 -04:00

90 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 2.3: Home Marker & Airspace Outlines
Status: done
## 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
- [x] Task 1: Implement `draw_home_marker(image, bounds)` in `src/planemapper/renderer/overlay.py` (AC: #1)
- [x] 1.1 New file; imports: `from PIL import Image, ImageDraw`; `from planemapper.constants import COLOUR_HOME_MARKER`; `from planemapper.renderer.projection import MapBounds, project`
- [x] 1.2 Signature: `def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:`
- [x] 1.3 Project `(bounds.home_lat, bounds.home_lon)` to `(cx, cy)`
- [x] 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
- [x] 1.5 Use `ImageDraw.Draw(image).line(...)`
- [x] Task 2: Implement `draw_airspace(image, bounds)` in `src/planemapper/renderer/airspace.py` (AC: #2, #3)
- [x] 2.1 Replace `# stub` with full implementation
- [x] 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`
- [x] 2.3 `log = logging.getLogger(__name__)`
- [x] 2.4 Try to open `AIRSPACE_PATH`; on `FileNotFoundError`: `log.warning("airspace.geojson not found")` and return
- [x] 2.5 Parse JSON, iterate `data["features"]`
- [x] 2.6 For each feature with `geometry["type"] == "Polygon"`: get `coords = feature["geometry"]["coordinates"][0]`
- [x] 2.7 Reverse GeoJSON `[lon, lat]``(lat, lon)` at parse boundary: `points = [project(lat, lon, bounds) for lon, lat in coords]`
- [x] 2.8 Draw with `ImageDraw.Draw(image).line(points, fill=COLOUR_AIRSPACE, width=2)`; skip features with fewer than 2 points
- [x] Task 3: Update `tests/fixtures/airspace_sample.geojson` with a sample polygon (AC: #2)
- [x] 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
- [x] Task 4: Write tests in `tests/test_airspace.py` (AC: #1, #2, #3)
- [x] 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
- [x] 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)
- [x] 4.3 Test AC3 (missing geojson): Monkeypatch `AIRSPACE_PATH` to a nonexistent path, call `draw_airspace(image, bounds)`, assert no exception raised
- [x] Task 5: Run quality gates
- [x] 5.1 `python -m pytest tests/` — all tests pass
- [x] 5.2 `python -m ruff check .` — zero violations
- [x] 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"))
```