From 97be9d9293b87a05a64fb57b4484f5107b95fb6f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 00:08:27 +0200 Subject: [PATCH] Add V1 map workbench controls --- CHANGELOG.md | 8 +++ backend/tests/test_sprint19_map_workbench.py | 29 ++++++++ docs/CODEX_EXECUTION_LOG.md | 21 ++++++ docs/TODO.md | 3 + frontend/README.md | 17 +++++ frontend/src/App.tsx | 73 ++++++++++++++++++-- frontend/src/components/GeoMap.tsx | 41 ++++++++++- frontend/src/styles/app.css | 19 +++++ 8 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_sprint19_map_workbench.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 74102770..2621e717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 19 V1 map workbench controls (2026-06-17) + +- Added MapLibre layer visibility and opacity controls for the active GeoJSON workbench layer. +- Added click-to-inspect feature properties for the active map layer. +- Updated the visible app identity from the stale Sprint 9 label to GeoIntel Kempen V1 Workbench. +- Added regression tests that lock the map control and feature inspection wiring. +- No API contracts, migrations, backend behavior, provider fetching, AI inference or new dependencies were introduced. + ## Sprint 18 vector change detection foundation (2026-06-16) - Added `POST /api/v1/analysis/change-detection` for synchronous comparison of two vector datasets in the same project. diff --git a/backend/tests/test_sprint19_map_workbench.py b/backend/tests/test_sprint19_map_workbench.py new file mode 100644 index 00000000..17c5e36c --- /dev/null +++ b/backend/tests/test_sprint19_map_workbench.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_geomap_exposes_v1_layer_controls_and_feature_inspection_contract() -> None: + geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8") + + assert "visible?: boolean" in geomap + assert "opacity?: number" in geomap + assert "onFeatureSelect?: (feature: GeoJSON.Feature | null) => void" in geomap + assert "queryRenderedFeatures" in geomap + assert "setLayoutProperty('dataset-fill', 'visibility'" in geomap + assert "setPaintProperty('dataset-fill', 'fill-opacity', opacity)" in geomap + + +def test_app_wires_map_workbench_controls_and_property_inspector() -> None: + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "mapLayerVisible" in app + assert "mapLayerOpacity" in app + assert "selectedMapFeature" in app + assert "Layer visible" in app + assert "Layer opacity" in app + assert "Feature inspector" in app + assert "onFeatureSelect={setSelectedMapFeature}" in app diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 81f4f407..1511e39c 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,24 @@ +## Sprint 19 V1 map workbench controls (2026-06-17) + +Changed: +- Added active MapLibre layer visibility and opacity controls. +- Added click-to-inspect feature property display for the active GeoJSON workbench layer. +- Added active layer label and feature count to the Map workspace panel. +- Updated the app header from the stale Sprint 9 label to the GeoIntel Kempen V1 Workbench identity. +- Added regression tests for the frontend map control and feature inspection wiring. + +Limitations: +- The current workbench still shows one active GeoJSON overlay at a time; multi-layer stack ordering remains a later UI enhancement. +- Raster preview display still remains metadata/path-oriented unless the backend exposes a browser-safe raster image/tile URL. +- No API contracts, migrations, backend behavior, provider fetching, AI inference or new dependencies were introduced. + +Validation planned: +- `python -m compileall backend/app` +- `cd backend && python -m pytest -W error::DeprecationWarning` +- `cd frontend && npm run typecheck` +- `cd frontend && npm run build` +- `bash scripts/run_readiness_check.sh` via Git Bash on Windows +- Tower redeploy through `scripts/deploy_tower.ps1` ## Sprint 18 vector change detection foundation (2026-06-16) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index a6d0661a..5e621281 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -15,6 +15,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] 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. @@ -86,6 +87,8 @@ This file now starts with the current implementation status. Older preparation/b - [x] API client - [x] Project pages - [x] Map Workbench basis +- [x] Map layer controls +- [x] Feature property inspector ## 4. Project & Area API diff --git a/frontend/README.md b/frontend/README.md index 38cefcb4..a229e951 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -127,6 +127,23 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow. - The UI does not introduce live provider downloads, a report designer or new AI behavior. - The HTML report is a lightweight artifact built from persisted project, dataset, QA/QC summary and export history state; it is not a PDF/report designer. +## Sprint 18 additions + +- Added a Change Detection panel for comparing two vector datasets in the same project. +- Change Detection uses the backend job envelope and renders added/removed/unchanged GeoJSON on the existing MapLibre workbench map. +- The UI exposes IoU threshold and unchanged-feature inclusion controls. +- The frontend does not infer fake object lifecycle states; it displays only the backend-provided added/removed/unchanged result. + +## Sprint 19 additions + +- Added V1 Map Workbench controls for the active GeoJSON layer: + - visibility toggle + - opacity slider + - active layer label + - loaded feature count +- 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. + ## 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 5026e3bf..6902e2d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,6 +94,9 @@ function App(): JSX.Element { const [selectedRasterMetadata, setSelectedRasterMetadata] = useState(null) const [selectedRasterStats, setSelectedRasterStats] = useState(null) const [datasetContent, setDatasetContent] = useState(null) + const [mapLayerVisible, setMapLayerVisible] = useState(true) + const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4) + const [selectedMapFeature, setSelectedMapFeature] = useState(null) const [jobs, setJobs] = useState([]) const [providerCapabilities, setProviderCapabilities] = useState([]) const [loadingCapabilities, setLoadingCapabilities] = useState(false) @@ -244,6 +247,22 @@ function App(): JSX.Element { () => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent, [changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent], ) + const mapLayerLabel = useMemo(() => { + if (changeDetectionResult) { + return 'Change detection result' + } + if (segmentationGeoJson) { + return 'Segmentation result' + } + if (detectionGeoJson) { + return 'Detection result' + } + if (datasetContent && selectedDataset) { + return selectedDataset.name + } + return 'No active vector layer' + }, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset]) + const mapFeatureCount = mapFeatureCollection?.features.length ?? 0 const isRasterTileInputValid = useMemo( () => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize, [rasterTileSize, rasterTileOverlap], @@ -624,6 +643,10 @@ function App(): JSX.Element { loadSegmentationModels().catch(() => null) }, []) + useEffect(() => { + setSelectedMapFeature(null) + }, [mapFeatureCollection]) + useEffect(() => { if (!selectedProjectId) { setAreas([]) @@ -1300,8 +1323,8 @@ function App(): JSX.Element { return (
-

GeoIntel Kempen Sprint 9

-

Sprint 9: raster/vector workbench with detection and segmentation foundations.

+

GeoIntel Kempen V1 Workbench

+

Raster/vector workbench with QA/QC, exports, change detection and AI foundation layers.

{errorMessage ?

{errorMessage}

: null} @@ -1826,8 +1849,50 @@ function App(): JSX.Element {

Map workspace

-

{selectedDatasetId ? `Showing dataset ${selectedDatasetId}` : 'No vector dataset selected'}

- +

{mapLayerLabel}

+
+ + +
+ {mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'} +
+
+ +
+

Feature inspector

+ {selectedMapFeature ? ( + <> +

Geometry: {selectedMapFeature.geometry?.type ?? 'n/a'}

+
{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}
+ + ) : ( +

Click a visible map feature to inspect its properties.

+ )} +
) diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index 35f1be51..439af6f5 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -4,6 +4,9 @@ import 'maplibre-gl/dist/maplibre-gl.css' interface GeoMapProps { data: GeoJSON.FeatureCollection | null + visible?: boolean + opacity?: number + onFeatureSelect?: (feature: GeoJSON.Feature | null) => void } function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null { @@ -40,9 +43,14 @@ function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): mapli ] } -function GeoMap({ data }: GeoMapProps): JSX.Element { +function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMapProps): JSX.Element { const containerRef = useRef(null) const mapRef = useRef(null) + const onFeatureSelectRef = useRef(onFeatureSelect) + + useEffect(() => { + onFeatureSelectRef.current = onFeatureSelect + }, [onFeatureSelect]) useEffect(() => { if (!containerRef.current || mapRef.current) { @@ -56,6 +64,21 @@ function GeoMap({ data }: GeoMapProps): JSX.Element { zoom: 9, }) map.addControl(new maplibregl.NavigationControl(), 'top-right') + map.on('click', (event) => { + if (!map.getLayer('dataset-fill') || !map.getLayer('dataset-line')) { + onFeatureSelectRef.current?.(null) + return + } + const features = map.queryRenderedFeatures(event.point, { + layers: ['dataset-fill', 'dataset-line'], + }) + if (features.length === 0) { + onFeatureSelectRef.current?.(null) + return + } + const feature = features[0] as unknown as GeoJSON.Feature + onFeatureSelectRef.current?.(feature) + }) mapRef.current = map return () => { @@ -136,6 +159,22 @@ function GeoMap({ data }: GeoMapProps): JSX.Element { } }, [data]) + useEffect(() => { + const map = mapRef.current + if (!map) { + return + } + const visibility = visible ? 'visible' : 'none' + if (map.getLayer('dataset-fill')) { + map.setLayoutProperty('dataset-fill', 'visibility', visibility) + map.setPaintProperty('dataset-fill', 'fill-opacity', opacity) + } + if (map.getLayer('dataset-line')) { + map.setLayoutProperty('dataset-line', 'visibility', visibility) + map.setPaintProperty('dataset-line', 'line-opacity', visible ? 1 : 0) + } + }, [visible, opacity, data]) + return
} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index f9ade1ae..20639436 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -152,3 +152,22 @@ ul { border: 1px solid #94a3b8; border-radius: 8px; } + +.map-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + align-items: end; + margin-bottom: 0.75rem; +} + +.map-status { + color: var(--muted); + font-size: 0.95rem; +} + +.feature-inspector { + margin-top: 0.75rem; + border-top: 1px solid var(--line); + padding-top: 0.75rem; +}