feat(story-2.3): implement home marker and airspace outline rendering

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>
This commit is contained in:
Matt Edholm
2026-04-22 23:16:03 -04:00
parent 5a18d0867a
commit e8ce0602a4
6 changed files with 144 additions and 29 deletions
@@ -1,6 +1,6 @@
# Story 2.3: Home Marker & Airspace Outlines
Status: ready-for-dev
Status: review
## Story
@@ -18,35 +18,35 @@ AC3: **Given** `airspace.geojson` does not exist at `AIRSPACE_PATH` **When** air
## 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(...)`
- [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(...)`
- [ ] 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
- [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
- [ ] 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
- [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
- [ ] 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
- [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
- [ ] 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
- [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
@@ -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: ready-for-dev
2-3-home-marker-and-airspace-outlines: review
2-4-altitude-colour-bands-and-aircraft-type-icons: backlog
2-5-per-aircraft-drawing: backlog
2-6-stateful-renderer-and-display-interface: backlog
+30 -1
View File
@@ -1 +1,30 @@
# stub
from __future__ import annotations
import json
import logging
from PIL import Image, ImageDraw
from planemapper.constants import AIRSPACE_PATH, COLOUR_AIRSPACE
from planemapper.renderer.projection import MapBounds, project
log = logging.getLogger(__name__)
def draw_airspace(image: Image.Image, bounds: MapBounds) -> None:
try:
data = json.loads(AIRSPACE_PATH.read_text(encoding="utf-8"))
except FileNotFoundError:
log.warning("airspace.geojson not found at %s — skipping airspace overlay", AIRSPACE_PATH)
return
draw = ImageDraw.Draw(image)
for feature in data.get("features", []):
geom = feature.get("geometry", {})
if geom.get("type") != "Polygon":
continue
coords = geom.get("coordinates", [[]])[0]
if len(coords) < 2:
continue
# GeoJSON is [lon, lat] — reverse at parse boundary
points = [project(lat, lon, bounds) for lon, lat in coords]
draw.line(points, fill=COLOUR_AIRSPACE, width=2)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from PIL import Image, ImageDraw
from planemapper.constants import COLOUR_HOME_MARKER
from planemapper.renderer.projection import MapBounds, project
def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:
cx, cy = project(bounds.home_lat, bounds.home_lon, bounds)
draw = ImageDraw.Draw(image)
draw.line([(cx - 10, cy), (cx + 10, cy)], fill=COLOUR_HOME_MARKER, width=3)
draw.line([(cx, cy - 10), (cx, cy + 10)], fill=COLOUR_HOME_MARKER, width=3)
+21 -1
View File
@@ -1 +1,21 @@
{"type": "FeatureCollection", "features": []}
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-6.5, 53.3],
[-5.5, 53.3],
[-5.5, 53.7],
[-6.5, 53.7],
[-6.5, 53.3]
]
]
},
"properties": {"name": "Test Airspace"}
}
]
}
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import pathlib
import pytest
from PIL import Image
from planemapper.renderer.airspace import draw_airspace
from planemapper.renderer.overlay import draw_home_marker
from planemapper.renderer.projection import MapBounds
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
@pytest.fixture
def white_image() -> Image.Image:
return Image.new("RGB", (800, 480), color=(255, 255, 255))
@pytest.fixture
def bounds() -> MapBounds:
return MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
def test_home_marker_draws_red_cross(white_image: Image.Image, bounds: MapBounds) -> None:
draw_home_marker(white_image, bounds)
# Centre pixel should be red (COLOUR_HOME_MARKER)
assert white_image.getpixel((400, 240)) == (255, 0, 0)
def test_airspace_drawn_without_exception(
white_image: Image.Image,
bounds: MapBounds,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"planemapper.renderer.airspace.AIRSPACE_PATH",
FIXTURE_DIR / "airspace_sample.geojson",
)
draw_airspace(white_image, bounds) # should not raise
def test_airspace_missing_file_no_exception(
white_image: Image.Image,
bounds: MapBounds,
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"planemapper.renderer.airspace.AIRSPACE_PATH",
tmp_path / "nonexistent.geojson",
)
draw_airspace(white_image, bounds) # should not raise — logs WARNING instead