Add V1 map workbench controls
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 00:08:27 +02:00
parent 539ec71806
commit 97be9d9293
8 changed files with 206 additions and 5 deletions
+8
View File
@@ -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.
@@ -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
+21
View File
@@ -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:
+3
View File
@@ -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
+17
View File
@@ -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.
+69 -4
View File
@@ -94,6 +94,9 @@ function App(): JSX.Element {
const [selectedRasterMetadata, setSelectedRasterMetadata] = useState<RasterMetadataResponse | null>(null)
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
const [mapLayerVisible, setMapLayerVisible] = useState(true)
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
const [jobs, setJobs] = useState<JobRead[]>([])
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
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 (
<div className="app-shell">
<header>
<h1>GeoIntel Kempen Sprint 9</h1>
<p>Sprint 9: raster/vector workbench with detection and segmentation foundations.</p>
<h1>GeoIntel Kempen V1 Workbench</h1>
<p>Raster/vector workbench with QA/QC, exports, change detection and AI foundation layers.</p>
</header>
{errorMessage ? <p className="error">{errorMessage}</p> : null}
@@ -1826,8 +1849,50 @@ function App(): JSX.Element {
<section>
<h2>Map workspace</h2>
<p>{selectedDatasetId ? `Showing dataset ${selectedDatasetId}` : 'No vector dataset selected'}</p>
<GeoMap data={mapFeatureCollection} />
<p>{mapLayerLabel}</p>
<div className="map-controls">
<label className="checkbox-row">
<input
checked={mapLayerVisible}
disabled={!mapFeatureCollection}
type="checkbox"
onChange={(event) => setMapLayerVisible(event.target.checked)}
/>
Layer visible
</label>
<label>
Layer opacity
<input
disabled={!mapFeatureCollection}
max="1"
min="0.05"
step="0.05"
type="range"
value={mapLayerOpacity}
onChange={(event) => setMapLayerOpacity(Number(event.target.value))}
/>
</label>
<div className="map-status">
{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'}
</div>
</div>
<GeoMap
data={mapFeatureCollection}
visible={mapLayerVisible}
opacity={mapLayerOpacity}
onFeatureSelect={setSelectedMapFeature}
/>
<div className="feature-inspector">
<h3>Feature inspector</h3>
{selectedMapFeature ? (
<>
<p>Geometry: {selectedMapFeature.geometry?.type ?? 'n/a'}</p>
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
</>
) : (
<p className="muted">Click a visible map feature to inspect its properties.</p>
)}
</div>
</section>
</div>
)
+40 -1
View File
@@ -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<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(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 <div className="map-container" ref={containerRef} />
}
+19
View File
@@ -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;
}