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
+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;
}