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
+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 orthofoto’s',
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}