bmad: create story 2-3 (home marker and airspace outlines)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
# 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"))
|
||||
```
|
||||
@@ -55,7 +55,7 @@ development_status:
|
||||
epic-2: in-progress
|
||||
2-1-aircraft-data-model-and-fetcher: done
|
||||
2-2-coordinate-projection-and-base-map-loading: done
|
||||
2-3-home-marker-and-airspace-outlines: backlog
|
||||
2-3-home-marker-and-airspace-outlines: ready-for-dev
|
||||
2-4-altitude-colour-bands-and-aircraft-type-icons: backlog
|
||||
2-5-per-aircraft-drawing: backlog
|
||||
2-6-stateful-renderer-and-display-interface: backlog
|
||||
|
||||
Reference in New Issue
Block a user