From 47cd2c6c1e11aec1bc689691824c92dc4f821166 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 00:21:01 +0200 Subject: [PATCH] Add V1 selected area map overlay --- CHANGELOG.md | 8 ++ backend/app/api/routes/areas.py | 10 +- backend/app/services/area_service.py | 25 +++-- .../tests/test_sprint20_area_map_overlay.py | 59 ++++++++++++ docs/API_CONTRACTS.md | 19 +++- docs/CODEX_EXECUTION_LOG.md | 16 ++++ docs/TODO.md | 2 + frontend/README.md | 7 ++ frontend/src/App.tsx | 83 +++++++++++++++- frontend/src/components/GeoMap.tsx | 96 ++++++++++++++++++- frontend/src/components/project/AreaPanel.tsx | 10 +- frontend/src/types.ts | 1 + 12 files changed, 315 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_sprint20_area_map_overlay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2621e717..e806f57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 20 selected area map overlay (2026-06-17) + +- Added persisted AOI GeoJSON to area API responses without changing the database schema. +- Added a dedicated MapLibre area overlay layer with visibility and opacity controls. +- Added area list actions to choose which AOI is shown on the map. +- Added regression tests for area GeoJSON serialization and frontend map overlay wiring. +- No migrations, provider downloads, AI inference, new dependencies or API route renames were introduced. + ## Sprint 19 V1 map workbench controls (2026-06-17) - Added MapLibre layer visibility and opacity controls for the active GeoJSON workbench layer. diff --git a/backend/app/api/routes/areas.py b/backend/app/api/routes/areas.py index 71257610..677d63a5 100644 --- a/backend/app/api/routes/areas.py +++ b/backend/app/api/routes/areas.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.models import Area -from app.schemas.area import AreaCreate, AreaRead, AreaUpdate +from app.schemas.area import AreaCreate, AreaUpdate from app.services.area_service import AreaService from app.utils.response import envelope @@ -23,13 +23,13 @@ def list_areas( db: Session = Depends(get_db), ): areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset) - return envelope({"items": [AreaRead.model_validate(area).model_dump() for area in areas], "total": total, "limit": limit, "offset": offset}) + return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset}) @router.post("", status_code=201, response_model=dict) def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)): area = AreaService.create_area(db, project_id, payload) - return envelope(AreaRead.model_validate(area).model_dump()) + return envelope(AreaService.serialize_area(area)) @router.get("/{area_id}", response_model=dict) @@ -41,7 +41,7 @@ def get_area( area = AreaService.get_area(db, area_id) if area.project_id != project_id: raise HTTPException(status_code=404, detail="Area not found") - return envelope(AreaRead.model_validate(area).model_dump()) + return envelope(AreaService.serialize_area(area)) @router.patch("/{area_id}", response_model=dict) @@ -55,4 +55,4 @@ def update_area( if not existing or existing.project_id != project_id: raise HTTPException(status_code=404, detail="Area not found") area = AreaService.update_area(db, area_id, payload) - return envelope(AreaRead.model_validate(area).model_dump()) + return envelope(AreaService.serialize_area(area)) diff --git a/backend/app/services/area_service.py b/backend/app/services/area_service.py index fada557f..3a0baa59 100644 --- a/backend/app/services/area_service.py +++ b/backend/app/services/area_service.py @@ -3,7 +3,8 @@ from __future__ import annotations import uuid from sqlalchemy.orm import Session -from geoalchemy2.shape import from_shape +from geoalchemy2.shape import from_shape, to_shape +from shapely.geometry import mapping from app.core.errors import AppError from app.models import Area, Project @@ -13,7 +14,13 @@ from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_mult class AreaService: @staticmethod - def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[AreaRead], int]: + def serialize_area(area: Area) -> dict: + payload = AreaRead.model_validate(area).model_dump() + payload["geometry"] = mapping(to_shape(area.geometry)) if area.geometry else None + return payload + + @staticmethod + def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[Area], int]: total = db.query(Area).filter(Area.project_id == project_id).count() areas = ( db.query(Area) @@ -23,10 +30,10 @@ class AreaService: .limit(limit) .all() ) - return [AreaRead.model_validate(area) for area in areas], total + return areas, total @staticmethod - def create_area(db: Session, project_id: uuid.UUID, payload: AreaCreate) -> AreaRead: + def create_area(db: Session, project_id: uuid.UUID, payload: AreaCreate) -> Area: if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -46,17 +53,17 @@ class AreaService: db.add(area) db.commit() db.refresh(area) - return AreaRead.model_validate(area) + return area @staticmethod - def get_area(db: Session, area_id: uuid.UUID) -> AreaRead: + def get_area(db: Session, area_id: uuid.UUID) -> Area: area = db.get(Area, area_id) if not area: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) - return AreaRead.model_validate(area) + return area @staticmethod - def update_area(db: Session, area_id: uuid.UUID, payload: AreaUpdate) -> AreaRead: + def update_area(db: Session, area_id: uuid.UUID, payload: AreaUpdate) -> Area: area = db.get(Area, area_id) if not area: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) @@ -74,4 +81,4 @@ class AreaService: db.add(area) db.commit() db.refresh(area) - return AreaRead.model_validate(area) + return area diff --git a/backend/tests/test_sprint20_area_map_overlay.py b/backend/tests/test_sprint20_area_map_overlay.py new file mode 100644 index 00000000..e3f3638c --- /dev/null +++ b/backend/tests/test_sprint20_area_map_overlay.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from uuid import uuid4 + +from geoalchemy2.shape import from_shape +from shapely.geometry import MultiPolygon, Polygon + +from app.models import Area +from app.services.area_service import AreaService + + +def test_area_serializer_exposes_geojson_geometry_for_map_overlay() -> None: + project_id = uuid4() + area = Area( + id=uuid4(), + project_id=project_id, + name="Map AOI", + original_crs="EPSG:4326", + area_m2=100.0, + geometry=from_shape( + MultiPolygon( + [ + Polygon( + [ + (4.35, 51.28), + (4.36, 51.28), + (4.36, 51.29), + (4.35, 51.29), + (4.35, 51.28), + ], + ), + ], + ), + srid=4326, + ), + ) + + payload = AreaService.serialize_area(area) + + assert payload["id"] == area.id + assert payload["project_id"] == project_id + assert payload["geometry"]["type"] == "MultiPolygon" + assert payload["geometry"]["coordinates"][0][0][0] == (4.35, 51.28) + + +def test_frontend_wires_selected_area_map_overlay_contract() -> None: + root = __import__("pathlib").Path(__file__).resolve().parents[2] + app = (root / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + geomap = (root / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8") + area_panel = (root / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx").read_text(encoding="utf-8") + + assert "selectedMapAreaId" in app + assert "areaFeatureCollection" in app + assert "areaData={areaFeatureCollection}" in app + assert "Area visible" in app + assert "area-fill" in geomap + assert "area-line" in geomap + assert "onSelectMapArea" in area_panel + assert "Show on map" in area_panel diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index f973ef3e..2364de83 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -122,7 +122,24 @@ Soft-delete in V1 preferred. Hard-delete only if storage cleanup is also impleme ### GET `/api/v1/projects/{project_id}/areas` -Returns areas for a project. +Returns areas for a project. Area responses include persisted AOI geometry as +GeoJSON so the frontend can display the selected area in the map workbench. + +```json +{ + "id": "uuid", + "project_id": "uuid", + "name": "Geel Centrum AOI", + "original_crs": "EPSG:4326", + "area_m2": 1234.5, + "created_at": "timestamp", + "geometry_type": null, + "geometry": { + "type": "MultiPolygon", + "coordinates": [] + } +} +``` ### POST `/api/v1/projects/{project_id}/areas` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 1511e39c..43522081 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,19 @@ +## Sprint 20 V1 selected area map overlay (2026-06-17) + +Changed: +- Added GeoJSON geometry serialization for project areas so persisted AOIs can be displayed by the map workbench. +- Added a dedicated MapLibre area overlay layer with separate visibility and opacity controls. +- Added area list actions and map workspace controls to select the active AOI. +- Updated API/frontend docs, changelog and TODO status for selected area display. + +Tested: +- Pending validation in this pass: backend compile, backend pytest, readiness, frontend typecheck/build, Alembic checks and deploy/browser smoke. + +Known limitations: +- Area geometry is displayed as a simple filled/outlined GeoJSON overlay; no drawing/editing workflow is introduced in this pass. + +Next recommended pass: +- Add a small V1 workflow polish pass for richer dataset/area empty states and a fixture-driven end-to-end browser smoke once the new build is deployed. ## Sprint 19 V1 map workbench controls (2026-06-17) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index 5e621281..9fa669b3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -16,6 +16,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Backend FastAPI foundation, health endpoint and service structure. - [x] React/TypeScript frontend foundation and MapLibre workbench. - [x] Map layer visibility, opacity and feature property inspection. +- [x] Selected project area/AOI map overlay with visibility and opacity controls. - [x] SQLAlchemy/PostGIS ORM models and Alembic migration chain through Sprint 9. - [x] Dataset upload, storage metadata and vector feature persistence. - [x] Raster metadata and raster operation service boundaries. @@ -89,6 +90,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Map Workbench basis - [x] Map layer controls - [x] Feature property inspector +- [x] Selected area display ## 4. Project & Area API diff --git a/frontend/README.md b/frontend/README.md index a229e951..574d562e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -144,6 +144,13 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - Added click-to-inspect feature properties from the active MapLibre layer. - Updated the app header to the V1 workbench identity instead of an old sprint label. +## Sprint 20 additions + +- Area API responses now include persisted AOI GeoJSON for map display. +- The Map Workbench renders the selected project area as a dedicated MapLibre GeoJSON layer. +- Added area visibility and opacity controls alongside the existing active vector/result layer controls. +- Area list items can select which AOI is shown on the map. + ## Release hardening updates - Production builds split application code, React vendor code and MapLibre vendor code into separate chunks. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6902e2d1..cebeb0d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -96,6 +96,9 @@ function App(): JSX.Element { const [datasetContent, setDatasetContent] = useState(null) const [mapLayerVisible, setMapLayerVisible] = useState(true) const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4) + const [selectedMapAreaId, setSelectedMapAreaId] = useState('') + const [areaLayerVisible, setAreaLayerVisible] = useState(true) + const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18) const [selectedMapFeature, setSelectedMapFeature] = useState(null) const [jobs, setJobs] = useState([]) const [providerCapabilities, setProviderCapabilities] = useState([]) @@ -243,6 +246,31 @@ function App(): JSX.Element { () => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null, [segmentationModels, selectedSegmentationModelId], ) + const selectedMapArea = useMemo( + () => areas.find((area) => area.id === selectedMapAreaId) ?? null, + [areas, selectedMapAreaId], + ) + const areaFeatureCollection = useMemo(() => { + if (!selectedMapArea?.geometry) { + return null + } + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: selectedMapArea.geometry, + properties: { + layer_type: 'project_area', + area_id: selectedMapArea.id, + name: selectedMapArea.name, + area_m2: selectedMapArea.area_m2 ?? null, + original_crs: selectedMapArea.original_crs ?? null, + }, + }, + ], + } + }, [selectedMapArea]) const mapFeatureCollection = useMemo( () => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent, [changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent], @@ -263,6 +291,7 @@ function App(): JSX.Element { return 'No active vector layer' }, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset]) const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 + const areaFeatureCount = areaFeatureCollection?.features.length ?? 0 const isRasterTileInputValid = useMemo( () => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize, [rasterTileSize, rasterTileOverlap], @@ -313,6 +342,11 @@ function App(): JSX.Element { if (!selectedClipAreaId && areaResponse.items.length > 0) { setSelectedClipAreaId(areaResponse.items[0].id) } + if (areaResponse.items.length === 0) { + setSelectedMapAreaId('') + } else if (!selectedMapAreaId || !areaResponse.items.some((area) => area.id === selectedMapAreaId)) { + setSelectedMapAreaId(areaResponse.items[0].id) + } } catch (error) { setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data') } finally { @@ -645,7 +679,7 @@ function App(): JSX.Element { useEffect(() => { setSelectedMapFeature(null) - }, [mapFeatureCollection]) + }, [mapFeatureCollection, areaFeatureCollection]) useEffect(() => { if (!selectedProjectId) { @@ -653,6 +687,7 @@ function App(): JSX.Element { setDatasets([]) setSelectedDatasetId(null) setSelectedDataset(null) + setSelectedMapAreaId('') setSelectedDatasetSummary(null) setSelectedRasterMetadata(null) setSelectedRasterStats(null) @@ -716,6 +751,7 @@ function App(): JSX.Element { const result = await demoApi.seedWorkflow() setSelectedProjectId(result.project_id) setSelectedDatasetId(result.candidate_dataset_id) + setSelectedMapAreaId(result.area_id) setQaCandidateDatasetId(result.candidate_dataset_id) setQaReferenceDatasetId(result.reference_dataset_id) setQaAreaId(result.area_id) @@ -751,11 +787,12 @@ function App(): JSX.Element { return } try { - await areasApi.create(selectedProjectId, { + const createdArea = await areasApi.create(selectedProjectId, { name: areaForm.name, crs: areaForm.crs, geometry, }) + setSelectedMapAreaId(createdArea.id) await loadProjectData(selectedProjectId) setAreaForm((previous) => ({ ...previous, name: '' })) } catch (error) { @@ -1349,8 +1386,10 @@ function App(): JSX.Element { selectedProjectId={selectedProjectId} loadingAreas={loadingAreas} areaForm={areaForm} + selectedMapAreaId={selectedMapAreaId} onCreateArea={createArea} onUpdateAreaForm={setAreaForm} + onSelectMapArea={setSelectedMapAreaId} /> Map workspace

{mapLayerLabel}

+ + +
+ {areaFeatureCollection ? `${areaFeatureCount} area loaded` : 'No area loaded'} -{' '} {mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'}
diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index 439af6f5..dcecf07b 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -4,8 +4,11 @@ import 'maplibre-gl/dist/maplibre-gl.css' interface GeoMapProps { data: GeoJSON.FeatureCollection | null + areaData?: GeoJSON.FeatureCollection | null visible?: boolean opacity?: number + areaVisible?: boolean + areaOpacity?: number onFeatureSelect?: (feature: GeoJSON.Feature | null) => void } @@ -43,7 +46,20 @@ function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): mapli ] } -function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMapProps): JSX.Element { +function mergeFeatureCollections(collections: Array): GeoJSON.FeatureCollection | null { + const features = collections.flatMap((collection) => collection?.features ?? []) + return features.length > 0 ? { type: 'FeatureCollection', features } : null +} + +function GeoMap({ + data, + areaData = null, + visible = true, + opacity = 0.4, + areaVisible = true, + areaOpacity = 0.18, + onFeatureSelect, +}: GeoMapProps): JSX.Element { const containerRef = useRef(null) const mapRef = useRef(null) const onFeatureSelectRef = useRef(onFeatureSelect) @@ -65,12 +81,13 @@ function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMap }) map.addControl(new maplibregl.NavigationControl(), 'top-right') map.on('click', (event) => { - if (!map.getLayer('dataset-fill') || !map.getLayer('dataset-line')) { + const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId)) + if (layers.length === 0) { onFeatureSelectRef.current?.(null) return } const features = map.queryRenderedFeatures(event.point, { - layers: ['dataset-fill', 'dataset-line'], + layers, }) if (features.length === 0) { onFeatureSelectRef.current?.(null) @@ -159,6 +176,63 @@ function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMap } }, [data]) + useEffect(() => { + const map = mapRef.current + if (!map) { + return + } + + if (map.getSource('area')) { + if (areaData) { + ;(map.getSource('area') as maplibregl.GeoJSONSource).setData(areaData) + } else { + if (map.getLayer('area-fill')) { + map.removeLayer('area-fill') + } + if (map.getLayer('area-line')) { + map.removeLayer('area-line') + } + map.removeSource('area') + return + } + } else if (areaData) { + map.addSource('area', { type: 'geojson', data: areaData }) + map.addLayer( + { + id: 'area-fill', + type: 'fill', + source: 'area', + paint: { + 'fill-color': '#0f766e', + 'fill-opacity': 0.18, + }, + }, + map.getLayer('dataset-fill') ? 'dataset-fill' : undefined, + ) + map.addLayer( + { + id: 'area-line', + type: 'line', + source: 'area', + paint: { + 'line-color': '#0f766e', + 'line-width': 3, + 'line-dasharray': [2, 1], + }, + }, + map.getLayer('dataset-line') ? 'dataset-line' : undefined, + ) + } + + const activeCollection = mergeFeatureCollections([areaData, data]) + if (activeCollection) { + const bounds = collectCoordinates(activeCollection) + if (bounds) { + map.fitBounds(bounds, { padding: 40 }) + } + } + }, [areaData, data]) + useEffect(() => { const map = mapRef.current if (!map) { @@ -175,6 +249,22 @@ function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMap } }, [visible, opacity, data]) + useEffect(() => { + const map = mapRef.current + if (!map) { + return + } + const visibility = areaVisible ? 'visible' : 'none' + if (map.getLayer('area-fill')) { + map.setLayoutProperty('area-fill', 'visibility', visibility) + map.setPaintProperty('area-fill', 'fill-opacity', areaOpacity) + } + if (map.getLayer('area-line')) { + map.setLayoutProperty('area-line', 'visibility', visibility) + map.setPaintProperty('area-line', 'line-opacity', areaVisible ? 1 : 0) + } + }, [areaVisible, areaOpacity, areaData]) + return
} diff --git a/frontend/src/components/project/AreaPanel.tsx b/frontend/src/components/project/AreaPanel.tsx index 842889de..95d86d98 100644 --- a/frontend/src/components/project/AreaPanel.tsx +++ b/frontend/src/components/project/AreaPanel.tsx @@ -13,8 +13,10 @@ interface AreaPanelProps { selectedProjectId: string | null loadingAreas: boolean areaForm: AreaFormState + selectedMapAreaId: string onCreateArea: (event: FormEvent) => void onUpdateAreaForm: (areaForm: AreaFormState) => void + onSelectMapArea: (areaId: string) => void } export function AreaPanel({ @@ -23,8 +25,10 @@ export function AreaPanel({ selectedProjectId, loadingAreas, areaForm, + selectedMapAreaId, onCreateArea, onUpdateAreaForm, + onSelectMapArea, }: AreaPanelProps): JSX.Element { return (
@@ -57,7 +61,11 @@ export function AreaPanel({
    {areas.map((area) => (
  • - {area.name} · {area.area_m2 ? `${area.area_m2.toFixed(2)} m²` : 'n/a'} + {area.name} - {area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'} +
    geometry: {area.geometry?.type ?? area.geometry_type ?? 'n/a'}
    +
  • ))}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 682ee379..1649b99f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -55,6 +55,7 @@ export interface AreaRead { area_m2?: number | null created_at?: string | null geometry_type?: string | null + geometry?: GeoJSON.Polygon | GeoJSON.MultiPolygon | null } export interface AreaCreate {