feat: add governed hydrology and historical imagery
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 12:17:45 +02:00
parent 5b1156e989
commit fb38eb3e91
32 changed files with 1646 additions and 55 deletions
+7
View File
@@ -404,6 +404,13 @@ their real observation ranges. A collapsed follow-up catalogue distinguishes
official sources that exist from datasets that are already persisted in the
active project.
The Map result panel also loads a governed orthophoto product list. The most
recent product retains the configured-YOLO plus current-GRB QA action. Official
historical years/periods use the same bounded rectangle, persist as raster
Datasets and render as MapLibre image overlays, but expose no misleading
current-state AI/QA action. The Sources inventory moves Waterinfo and historical
orthophotos from follow-up to loaded evidence only after such datasets exist.
Evolution mode compares the exact selected persisted Area when the full
municipality/region action is used. It shows the selected before/after values,
all compatible supporting metrics and a chart/table for every observation in
+5
View File
@@ -1018,6 +1018,10 @@ function App(): JSX.Element {
orthophotoAnalysisRunning={mapOrthophotoAnalysis.running}
orthophotoAnalysisQuality={mapOrthophotoAnalysis.lastQuality}
orthophotoAnalysisDetectionCount={mapOrthophotoAnalysis.lastDetectionCount}
orthophotoProducts={mapOrthophotoAnalysis.products}
selectedOrthophotoProductKey={mapOrthophotoAnalysis.selectedProductKey}
orthophotoResult={mapOrthophotoAnalysis.lastResult}
orthophotoImageUrl={mapOrthophotoAnalysis.imageUrl}
availableMapDatasets={availableMapDatasets}
selectedMapDatasetId={selectedDataset && isVectorDatasetType(selectedDataset.dataset_type) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
@@ -1039,6 +1043,7 @@ function App(): JSX.Element {
onRunMapSelectionQa={runMapSelectionQa}
onOpenMapSelectionQualityEvidence={openMapSelectionQualityEvidence}
onRunOrthophotoAnalysis={mapOrthophotoAnalysis.run}
onSelectOrthophotoProduct={mapOrthophotoAnalysis.setSelectedProductKey}
onClearQualityEvidence={clearQualityEvidenceGeoJson}
/>
) : null}
+40 -1
View File
@@ -3,7 +3,7 @@ import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapViewportState, VectorSelectionBBox } from '../types'
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
@@ -13,6 +13,7 @@ interface GeoMapProps {
selectedFeature?: GeoJSON.Feature | null
selectionData?: GeoJSON.FeatureCollection | null
qaEvidenceData?: GeoJSON.FeatureCollection | null
imageOverlay?: MapImageOverlay | null
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
bboxSelectionMode?: boolean
visible?: boolean
@@ -153,6 +154,7 @@ function GeoMap({
selectedFeature = null,
selectionData = null,
qaEvidenceData = null,
imageOverlay = null,
selectionBbox = null,
bboxSelectionMode = false,
visible = true,
@@ -346,6 +348,43 @@ function GeoMap({
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
return
}
if (map.getLayer('bounded-orthophoto')) {
map.removeLayer('bounded-orthophoto')
}
if (map.getSource('bounded-orthophoto')) {
map.removeSource('bounded-orthophoto')
}
if (!imageOverlay) {
return
}
const [minX, minY, maxX, maxY] = imageOverlay.bbox
map.addSource('bounded-orthophoto', {
type: 'image',
url: imageOverlay.url,
coordinates: [
[minX, maxY],
[maxX, maxY],
[maxX, minY],
[minX, minY],
],
})
const beforeLayer = ['area-fill', 'dataset-fill', 'selection-bbox-fill'].find((layerId) => map.getLayer(layerId))
map.addLayer(
{
id: 'bounded-orthophoto',
type: 'raster',
source: 'bounded-orthophoto',
paint: { 'raster-opacity': imageOverlay.opacity ?? 0.88 },
},
beforeLayer,
)
}, [imageOverlay, mapStyleReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
@@ -15,13 +15,15 @@ const THEME_LABELS: Record<string, string> = {
const AVAILABLE_SOURCES = [
{
key: 'historical_orthophoto',
name: 'Historische orthofotos',
owner: 'Digitaal Vlaanderen',
coverage: '1971 en 1979-1990; aanvullende jaargangen bestaan afzonderlijk',
value: 'Visuele evolutie en toekomstige beeldvergelijking',
coverage: '1971, 1979-1990, 2000-2025',
value: 'Visuele evolutie via begrensde officiële luchtbeelden',
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/orthofotomozaiek-kleinschalig-zomeropnamen',
},
{
key: 'bwk',
name: 'Biologische Waarderingskaart / Natura 2000',
owner: 'INBO',
coverage: 'Toestand 2025',
@@ -29,6 +31,7 @@ const AVAILABLE_SOURCES = [
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/biologische-waarderingskaart-en-natura-2000-habitatkaart-toestand-2025',
},
{
key: 'agriculture',
name: 'Landbouwgebruikspercelen',
owner: 'Agentschap Landbouw en Zeevisserij',
coverage: 'Jaarlijkse bestanden',
@@ -36,6 +39,7 @@ const AVAILABLE_SOURCES = [
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/open-geodata-landbouwgebruikspercelen',
},
{
key: 'buildings_register',
name: 'Gebouwen- en adressenregister',
owner: 'Digitaal Vlaanderen',
coverage: 'Continu geactualiseerd',
@@ -43,6 +47,7 @@ const AVAILABLE_SOURCES = [
url: 'https://www.vlaanderen.be/datavindplaats/catalogus/gebouwen-en-adressenregister',
},
{
key: 'elevation',
name: 'Digitaal Hoogtemodel Vlaanderen II',
owner: 'Digitaal Vlaanderen',
coverage: 'LiDAR-opname 2013-2015, DTM/DSM 1 m en 5 m',
@@ -50,6 +55,7 @@ const AVAILABLE_SOURCES = [
url: 'https://www.vlaanderen.be/digitaal-vlaanderen/onze-diensten-en-platformen/earth-observation-data-science-eodas/het-digitaal-hoogtemodel/digitaal-hoogtemodel-vlaanderen-ii',
},
{
key: 'waterinfo',
name: 'Waterinfo en VMM-metingen',
owner: 'Vlaamse Milieumaatschappij',
coverage: 'Meetpunten en tijdreeksen voor waterstand, debiet en neerslag',
@@ -91,6 +97,17 @@ function timelineSummary(
export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.Element {
const ready = datasets.filter((dataset) => dataset.status === 'ready')
const waterinfoDatasets = ready.filter((dataset) => dataset.source_name === 'waterinfo')
const historicalOrthophotos = ready.filter(
(dataset) =>
dataset.source_name === 'digitaal_vlaanderen_orthophoto' &&
String(dataset.source_metadata?.['product_key'] ?? 'most_recent') !== 'most_recent',
)
const pendingSources = AVAILABLE_SOURCES.filter((source) => {
if (source.key === 'waterinfo') return waterinfoDatasets.length === 0
if (source.key === 'historical_orthophoto') return historicalOrthophotos.length === 0
return true
})
const themes = Object.keys(THEME_LABELS).map((theme) => {
const matches = ready.filter((dataset) => datasetTheme(dataset) === theme)
const temporal = matches.filter((dataset) => dataset.temporal_series_key && dataset.observed_at)
@@ -148,10 +165,29 @@ export function SourceCatalogPanel({ datasets }: SourceCatalogPanelProps): JSX.E
))}
</div>
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 ? (
<div className="source-catalog-loaded" aria-label="Aanvullende ingeladen bronnen">
{waterinfoDatasets.length > 0 ? (
<article>
<strong>Waterinfo meetreeksen</strong>
<span>{new Set(waterinfoDatasets.map((dataset) => dataset.temporal_series_key).filter(Boolean)).size} stationsreeksen · {waterinfoDatasets.length} jaarmetingen</span>
<p>Puntmetingen blijven afzonderlijk per station en worden niet als gebiedsgemiddelde of watervolume voorgesteld.</p>
</article>
) : null}
{historicalOrthophotos.length > 0 ? (
<article>
<strong>Historische luchtbeelden</strong>
<span>{historicalOrthophotos.length} begrensde kaartselecties bewaard</span>
<p>Officiële jaargangen en periodes zijn via de kaart beschikbaar zonder actuele GRB-validatie.</p>
</article>
) : null}
</div>
) : null}
<details className="source-opportunity-list">
<summary>Officiële bronnen die hierna kunnen worden ingeladen</summary>
<div>
{AVAILABLE_SOURCES.map((source) => (
{pendingSources.map((source) => (
<article key={source.name}>
<div>
<strong>{source.name}</strong>
+42 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
@@ -459,6 +459,10 @@ interface MapWorkspaceProps {
orthophotoAnalysisRunning: boolean
orthophotoAnalysisQuality: DetectionQaResult | null
orthophotoAnalysisDetectionCount: number | null
orthophotoProducts: OrthophotoProductRead[]
selectedOrthophotoProductKey: string
orthophotoResult: OrthophotoAcquisitionResult | null
orthophotoImageUrl: string | null
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
@@ -479,6 +483,7 @@ interface MapWorkspaceProps {
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onSelectOrthophotoProduct: (productKey: string) => void
onClearQualityEvidence?: () => void
}
@@ -534,6 +539,10 @@ export function MapWorkspace({
orthophotoAnalysisRunning,
orthophotoAnalysisQuality,
orthophotoAnalysisDetectionCount,
orthophotoProducts,
selectedOrthophotoProductKey,
orthophotoResult,
orthophotoImageUrl,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
@@ -554,6 +563,7 @@ export function MapWorkspace({
onRunMapSelectionQa,
onOpenMapSelectionQualityEvidence,
onRunOrthophotoAnalysis,
onSelectOrthophotoProduct,
onClearQualityEvidence,
}: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false)
@@ -615,6 +625,15 @@ export function MapWorkspace({
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null
const orthophotoImageOverlay = orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4
? {
url: orthophotoImageUrl,
bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number],
label: orthophotoResult.display_name,
opacity: 0.9,
}
: null
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
@@ -1173,6 +1192,7 @@ export function MapWorkspace({
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={analysisMode === 'current' ? mapSelectionResult?.geojson ?? null : null}
imageOverlay={orthophotoImageOverlay}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
@@ -1188,6 +1208,7 @@ export function MapWorkspace({
/>
<div className="geo-map-legend" aria-label="Kaartlegende">
<span><i className="geo-legend-area" /> Werkgebied</span>
{orthophotoImageOverlay ? <span><i className="geo-legend-imagery" /> {orthophotoImageOverlay.label}</span> : null}
{analysisOverlayActive ? (
<>
<span><i className="geo-legend-layer geo-legend-layer-buildings" /> AI-kandidaten</span>
@@ -1233,9 +1254,25 @@ export function MapWorkspace({
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
<strong>Gebouwen herkennen op luchtbeeld</strong>
<small>Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.</small>
<strong>{selectedOrthophotoProduct?.supports_detection ? 'Gebouwen herkennen op luchtbeeld' : 'Historisch luchtbeeld bekijken'}</strong>
<small>
{selectedOrthophotoProduct?.supports_detection
? 'Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.'
: 'Officieel historisch mozaïek. Geen vergelijking met de actuele GRB-toestand.'}
</small>
</div>
<label className="geo-orthophoto-product">
<span>Luchtbeeld</span>
<select
value={selectedOrthophotoProductKey}
onChange={(event) => onSelectOrthophotoProduct(event.target.value)}
disabled={orthophotoAnalysisRunning}
>
{orthophotoProducts.map((product) => (
<option key={product.key} value={product.key}>{product.display_name}</option>
))}
</select>
</label>
<button
className="primary-action"
disabled={orthophotoAnalysisRunning}
@@ -1249,8 +1286,8 @@ export function MapWorkspace({
: orthophotoAnalysisStage === 'validating'
? 'Controleren...'
: orthophotoAnalysisStage === 'complete'
? 'Opnieuw analyseren'
: 'Herken gebouwen'}
? selectedOrthophotoProduct?.supports_detection ? 'Opnieuw analyseren' : 'Opnieuw tonen'
: selectedOrthophotoProduct?.supports_detection ? 'Herken gebouwen' : 'Toon luchtbeeld'}
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
+44 -1
View File
@@ -5,6 +5,7 @@ import type {
DetectionQaResult,
DetectionRunResponse,
OrthophotoAcquisitionResult,
OrthophotoProductRead,
VectorSelectionBBox,
} from '../types'
import { formatError } from '../lib/formatError'
@@ -87,6 +88,33 @@ export function useMapOrthophotoAnalysis({
const [lastQuality, setLastQuality] = useState<DetectionQaResult | null>(null)
const [lastDetectionCount, setLastDetectionCount] = useState<number | null>(null)
const [lastAnalysisRunId, setLastAnalysisRunId] = useState<string | null>(null)
const [products, setProducts] = useState<OrthophotoProductRead[]>([])
const [selectedProductKey, setSelectedProductKey] = useState('most_recent')
useEffect(() => {
if (!selectedProjectId) {
setProducts([])
return
}
let cancelled = false
void datasetsApi.listOrthophotoProducts(selectedProjectId)
.then((response) => {
if (!cancelled) {
setProducts(response.items)
setSelectedProductKey((current) =>
response.items.some((item) => item.key === current) ? current : response.items[0]?.key ?? 'most_recent',
)
}
})
.catch((caught) => {
if (!cancelled) {
setError(formatError(caught, 'De luchtbeeldcatalogus kon niet worden geladen'))
}
})
return () => {
cancelled = true
}
}, [selectedProjectId])
useEffect(() => {
setStage('idle')
@@ -96,7 +124,7 @@ export function useMapOrthophotoAnalysis({
setLastQuality(null)
setLastDetectionCount(null)
setLastAnalysisRunId(null)
}, [selectionBbox?.min_x, selectionBbox?.min_y, selectionBbox?.max_x, selectionBbox?.max_y])
}, [selectionBbox?.min_x, selectionBbox?.min_y, selectionBbox?.max_x, selectionBbox?.max_y, selectedProductKey])
const run = async (bbox: VectorSelectionBBox): Promise<boolean> => {
if (!selectedProjectId) {
@@ -115,6 +143,7 @@ export function useMapOrthophotoAnalysis({
const job = await datasetsApi.acquireOrthophoto(selectedProjectId, {
bbox,
area_id: selectedAreaId || undefined,
product_key: selectedProductKey,
})
const acquisition = job.result_json as unknown as OrthophotoAcquisitionResult | null
const datasetId = job.output_dataset_id || acquisition?.output_dataset_id
@@ -124,6 +153,14 @@ export function useMapOrthophotoAnalysis({
setLastResult(acquisition)
await loadProjectData(selectedProjectId)
if (!acquisition.supports_detection) {
setStatus(
`${acquisition.display_name} is ingeladen en op de kaart geplaatst. ${acquisition.limitation_message}`,
)
setStage('complete')
return true
}
setStage('detecting')
setStatus('2/3 Lokaal AI-model herkent gebouwen...')
const detection = await prepareAndRunDetection(datasetId)
@@ -169,9 +206,15 @@ export function useMapOrthophotoAnalysis({
status,
error,
lastResult,
imageUrl: lastResult && selectedProjectId
? datasetsApi.orthophotoImageUrl(selectedProjectId, lastResult.output_dataset_id)
: null,
lastQuality,
lastDetectionCount,
lastAnalysisRunId,
products,
selectedProductKey,
setSelectedProductKey,
running: stage === 'acquiring' || stage === 'detecting' || stage === 'validating',
run,
}
+5
View File
@@ -17,6 +17,7 @@ import type {
RasterNdwiRequest,
RasterNdbiRequest,
OrthophotoAcquireRequest,
OrthophotoProductRead,
} from '../../types'
export const datasetsApi = {
@@ -84,6 +85,10 @@ export const datasetsApi = {
},
acquireOrthophoto: (projectId: string, payload: OrthophotoAcquireRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/orthophoto/acquire`, payload),
listOrthophotoProducts: (projectId: string): Promise<{ items: OrthophotoProductRead[]; total: number }> =>
apiGet<{ items: OrthophotoProductRead[]; total: number }>(`/api/v1/projects/${projectId}/datasets/orthophoto/products`),
orthophotoImageUrl: (projectId: string, datasetId: string): string =>
`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/image`,
refreshMetadata: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
inspectRaster: (projectId: string, datasetId: string): Promise<RasterInspectResponse> =>
+46
View File
@@ -5953,6 +5953,11 @@ section {
background: rgba(107, 74, 170, 0.18);
}
.geo-map-legend .geo-legend-imagery {
border-color: #334155;
background: linear-gradient(135deg, #7a9b68 0 33%, #d1b37a 33% 66%, #8eb5cb 66%);
}
.geo-map-legend .geo-legend-added {
border-color: #15803d;
background: rgba(22, 163, 74, 0.2);
@@ -6085,6 +6090,23 @@ section {
min-height: 2.35rem;
}
.geo-orthophoto-product {
display: grid;
gap: 0.3rem;
}
.geo-orthophoto-product select {
width: 100%;
min-height: 2.25rem;
border: 1px solid #b9cec7;
border-radius: 4px;
padding: 0.35rem 0.5rem;
background: #fff;
color: #263a34;
font: inherit;
font-size: 0.72rem;
}
.geo-image-analysis-acquiring,
.geo-image-analysis-detecting,
.geo-image-analysis-validating {
@@ -6675,6 +6697,28 @@ section {
grid-column: 1 / -1;
}
.source-catalog-loaded {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem;
margin-top: 0.75rem;
}
.source-catalog-loaded article {
display: grid;
gap: 0.2rem;
border-left: 3px solid #287051;
padding: 0.6rem 0.75rem;
background: #f2f8f5;
}
.source-catalog-loaded span,
.source-catalog-loaded p {
margin: 0;
color: #5d6d67;
font-size: 0.72rem;
}
.source-opportunity-list {
margin-top: 0.8rem;
border-top: 1px solid #dfe7e4;
@@ -6716,6 +6760,7 @@ section {
@media (max-width: 980px) {
.source-catalog-grid,
.source-catalog-loaded,
.source-opportunity-list > div {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -6723,6 +6768,7 @@ section {
@media (max-width: 640px) {
.source-catalog-grid,
.source-catalog-loaded,
.source-opportunity-list > div {
grid-template-columns: 1fr;
}
+25
View File
@@ -301,13 +301,31 @@ export interface VectorSelectionBBox {
export interface OrthophotoAcquireRequest {
bbox: VectorSelectionBBox
area_id?: string
product_key?: string
force_refresh?: boolean
}
export interface OrthophotoProductRead {
key: string
display_name: string
observation_label: string
temporal_granularity: string
native_resolution_m: number
supports_detection: boolean
color_mode: string
catalog_url: string
limitation_message: string
}
export interface OrthophotoAcquisitionResult {
output_dataset_id: string
reused: boolean
provider: string
product_key: string
display_name: string
observation_label: string
temporal_granularity: string
supports_detection: boolean
layer: string
width: number
height: number
@@ -318,6 +336,13 @@ export interface OrthophotoAcquisitionResult {
limitation_message: string
}
export interface MapImageOverlay {
url: string
bbox: [number, number, number, number]
label: string
opacity?: number
}
export interface MapViewportState {
bbox: VectorSelectionBBox
zoom: number