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:
@@ -1,6 +1,6 @@
|
|||||||
# Story 2.3: Home Marker & Airspace Outlines
|
# Story 2.3: Home Marker & Airspace Outlines
|
||||||
|
|
||||||
Status: ready-for-dev
|
Status: review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -18,35 +18,35 @@ AC3: **Given** `airspace.geojson` does not exist at `AIRSPACE_PATH` **When** air
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [ ] Task 1: Implement `draw_home_marker(image, bounds)` in `src/planemapper/renderer/overlay.py` (AC: #1)
|
- [x] 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`
|
- [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`
|
||||||
- [ ] 1.2 Signature: `def draw_home_marker(image: Image.Image, bounds: MapBounds) -> None:`
|
- [x] 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)`
|
- [x] 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
|
- [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
|
||||||
- [ ] 1.5 Use `ImageDraw.Draw(image).line(...)`
|
- [x] 1.5 Use `ImageDraw.Draw(image).line(...)`
|
||||||
|
|
||||||
- [ ] Task 2: Implement `draw_airspace(image, bounds)` in `src/planemapper/renderer/airspace.py` (AC: #2, #3)
|
- [x] Task 2: Implement `draw_airspace(image, bounds)` in `src/planemapper/renderer/airspace.py` (AC: #2, #3)
|
||||||
- [ ] 2.1 Replace `# stub` with full implementation
|
- [x] 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`
|
- [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`
|
||||||
- [ ] 2.3 `log = logging.getLogger(__name__)`
|
- [x] 2.3 `log = logging.getLogger(__name__)`
|
||||||
- [ ] 2.4 Try to open `AIRSPACE_PATH`; on `FileNotFoundError`: `log.warning("airspace.geojson not found")` and return
|
- [x] 2.4 Try to open `AIRSPACE_PATH`; on `FileNotFoundError`: `log.warning("airspace.geojson not found")` and return
|
||||||
- [ ] 2.5 Parse JSON, iterate `data["features"]`
|
- [x] 2.5 Parse JSON, iterate `data["features"]`
|
||||||
- [ ] 2.6 For each feature with `geometry["type"] == "Polygon"`: get `coords = feature["geometry"]["coordinates"][0]`
|
- [x] 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]`
|
- [x] 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] 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)
|
- [x] 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] 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)
|
- [x] 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
|
- [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
|
||||||
- [ ] 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.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] 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
|
- [x] Task 5: Run quality gates
|
||||||
- [ ] 5.1 `python -m pytest tests/` — all tests pass
|
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||||||
- [ ] 5.2 `python -m ruff check .` — zero violations
|
- [x] 5.2 `python -m ruff check .` — zero violations
|
||||||
- [ ] 5.3 `python -m ruff format --check .` — no formatting issues
|
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ development_status:
|
|||||||
epic-2: in-progress
|
epic-2: in-progress
|
||||||
2-1-aircraft-data-model-and-fetcher: done
|
2-1-aircraft-data-model-and-fetcher: done
|
||||||
2-2-coordinate-projection-and-base-map-loading: 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-4-altitude-colour-bands-and-aircraft-type-icons: backlog
|
||||||
2-5-per-aircraft-drawing: backlog
|
2-5-per-aircraft-drawing: backlog
|
||||||
2-6-stateful-renderer-and-display-interface: backlog
|
2-6-stateful-renderer-and-display-interface: backlog
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user