Files
geointel/frontend/src/components/datasets/SourceCatalogPanel.tsx
T
Codex cee6cd05ae
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
feat: complete Wallonia land cover and terrain sources
2026-07-22 06:28:46 +02:00

382 lines
17 KiB
TypeScript

import { useState } from 'react'
import { datasetsApi } from '../../services/api'
import type { BathymetrySourceProbeRead, DatasetCreateResponse } from '../../types'
import { getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
import {
OFFICIAL_SOURCE_PORTFOLIO,
SOURCE_DOMAINS,
operationalSources,
sourcesForDomain,
type SourcePriority,
} from '../../lib/sourcePortfolio'
interface SourceCatalogPanelProps {
datasets: DatasetCreateResponse[]
projectId?: string
loading?: boolean
}
const THEME_LABELS: Record<string, string> = {
buildings: 'Bebouwing',
population: 'Bevolking',
forest: 'Bos',
nature_value: 'Natuurwaarde',
agriculture: 'Landbouw',
water: 'Water',
bathymetry: 'Waterbodem en dwarsprofielen',
flood_hazard: 'Overstromingsgevaar',
elevation: 'Hoogte en reliëf',
roads: 'Wegen en transport',
parcels: 'Percelen',
}
function datasetTheme(dataset: DatasetCreateResponse): string | null {
const configured = String(dataset.source_metadata?.['theme'] ?? dataset.reference_layer_name ?? '').toLowerCase()
if (configured === 'built' || configured === 'building') return 'buildings'
if (configured === 'transport' || configured === 'road') return 'roads'
if (configured in THEME_LABELS) return configured
return null
}
function priorityLabel(priority: SourcePriority): string {
if (priority === 'next') return 'Eerstvolgend'
if (priority === 'planned') return 'Daarna'
return 'Later'
}
function timelineSummary(
temporalCount: number,
temporalSeriesCount: number,
otherMethodCount: number,
firstYear: number | null,
lastYear: number | null,
): string {
if (temporalCount < 2 || !firstYear || !lastYear) {
return 'Alleen de huidige toestand is vergelijkbaar beschikbaar.'
}
const seriesNote = temporalSeriesCount > 1
? ` in ${temporalSeriesCount} afzonderlijke reeksen`
: ''
const methodNote = otherMethodCount > 0
? `plus ${otherMethodCount} andere bronmethode${otherMethodCount === 1 ? '' : 'n'}`
: null
return [
`${temporalCount} vergelijkbare meetmomenten${seriesNote}`,
`${firstYear}-${lastYear}`,
methodNote,
].filter(Boolean).join(' · ')
}
function mdkStatusLabel(status: BathymetrySourceProbeRead['status']): string {
if (status === 'reachable') return 'Service bereikbaar'
if (status === 'tls_error') return 'Geblokkeerd: certificaat'
if (status === 'endpoint_unavailable') return 'Service niet bereikbaar'
if (status === 'invalid_capabilities') return 'Ongeldige service-informatie'
if (status === 'disabled') return 'Controle uitgeschakeld'
return 'Configuratie controleren'
}
export function SourceCatalogPanel({
datasets,
projectId,
loading = false,
}: SourceCatalogPanelProps): JSX.Element {
const [mdkProbe, setMdkProbe] = useState<BathymetrySourceProbeRead | null>(null)
const [mdkProbeLoading, setMdkProbeLoading] = useState(false)
const [mdkProbeError, setMdkProbeError] = useState<string | null>(null)
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 bwkDatasets = ready.filter((dataset) => dataset.source_name === 'inbo_bwk_natura2000')
const largestBwkSnapshot = bwkDatasets.reduce<DatasetCreateResponse | null>(
(largest, dataset) => !largest || (dataset.feature_count ?? 0) > (largest.feature_count ?? 0) ? dataset : largest,
null,
)
const agricultureDatasets = ready.filter((dataset) => dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels')
const buildingsRegisterDatasets = ready.filter(
(dataset) => dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register',
)
const dhmvDatasets = ready.filter((dataset) => ['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? ''))
const floodHazardDatasets = ready.filter((dataset) => dataset.source_name === 'vmm_flood_hazard')
const bathymetryProfileDatasets = ready.filter(
(dataset) => dataset.source_name === 'vmm_vha_bathymetry_profiles',
)
const latestBuildingsRegister = [...buildingsRegisterDatasets].sort(
(left, right) => new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime(),
)[0]
const agricultureYears = agricultureDatasets
.flatMap((dataset) => dataset.observed_at ? [new Date(dataset.observed_at).getUTCFullYear()] : [])
const availableSources = operationalSources(ready)
const availableSourceKeys = new Set(availableSources.map((source) => source.key))
const pendingSources = OFFICIAL_SOURCE_PORTFOLIO.filter((source) => {
if (source.key === 'bwk') return bwkDatasets.length === 0
return !availableSourceKeys.has(source.key)
})
const domains = SOURCE_DOMAINS.map((domain) => {
const sources = sourcesForDomain(domain.key)
const loaded = sources.filter((source) => availableSourceKeys.has(source.key))
const next = sources.filter((source) => source.priority === 'next' && !availableSourceKeys.has(source.key))
return {
...domain,
sourceCount: sources.length,
loadedCount: loaded.length,
nextCount: next.length,
loadedNames: loaded.map((source) => source.name),
}
})
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)
const seriesGroups = new Map<string, DatasetCreateResponse[]>()
temporal.forEach((dataset) => {
const seriesKey = dataset.temporal_series_key as string
seriesGroups.set(seriesKey, [...(seriesGroups.get(seriesKey) ?? []), dataset])
})
const comparableSeries = [...seriesGroups.values()].filter((series) => series.length >= 2)
const comparableObservations = comparableSeries.flat()
const years = comparableObservations.map((dataset) => new Date(dataset.observed_at as string).getUTCFullYear())
const latest = [...matches].sort(
(left, right) => new Date(right.observed_at ?? right.imported_at ?? 0).getTime() - new Date(left.observed_at ?? left.imported_at ?? 0).getTime(),
)[0]
return {
theme,
label: THEME_LABELS[theme],
datasetCount: matches.length,
temporalCount: comparableObservations.length,
temporalSeriesCount: comparableSeries.length,
otherMethodCount: matches.length - comparableObservations.length,
firstYear: years.length ? Math.min(...years) : null,
lastYear: years.length ? Math.max(...years) : null,
source: latest ? getDatasetSourceDisplayName(latest) : null,
}
})
const checkMdkReadiness = async (): Promise<void> => {
if (!projectId || mdkProbeLoading) return
setMdkProbeLoading(true)
setMdkProbeError(null)
try {
setMdkProbe(await datasetsApi.probeMdkBathymetry(projectId))
} catch (error) {
setMdkProbeError(error instanceof Error ? error.message : 'De Noordzee-bron kon niet worden gecontroleerd.')
} finally {
setMdkProbeLoading(false)
}
}
if (loading) {
return (
<section
className="workspace-panel source-catalog-panel"
aria-label="Beschikbare databronnen"
aria-busy="true"
>
<div className="panel-title-row">
<div>
<span className="section-kicker">Broninventaris</span>
<h2>Welke vragen kan GeoIntel beantwoorden?</h2>
</div>
<span className="status-badge">bronnen laden</span>
</div>
<div className="geo-bootstrap-status" role="status" aria-live="polite">
<span className="geo-loading-indicator" aria-hidden="true" />
<div>
<strong>Broninventaris wordt opgebouwd</strong>
<small>Operationele en geplande bronnen worden pas getoond wanneer de projectdata compleet is.</small>
</div>
</div>
</section>
)
}
return (
<section className="workspace-panel source-catalog-panel" aria-label="Beschikbare databronnen">
<div className="panel-title-row">
<div>
<span className="section-kicker">Broninventaris</span>
<h2>Welke vragen kan GeoIntel beantwoorden?</h2>
<p className="muted">Zes domeinen vormen samen het platform. Alleen werkelijk ingeladen bronnen krijgen de status operationeel; de rest blijft een transparante aansluitplanning.</p>
</div>
<span className="status-badge status-badge-ready">{availableSources.length} officiële bronnen operationeel</span>
</div>
<div className="source-domain-grid" aria-label="Platformbrede brondomeinen">
{domains.map((domain) => (
<article className="source-domain-card" key={domain.key}>
<div>
<strong>{domain.label}</strong>
<span>{domain.loadedCount} van {domain.sourceCount} bronnen operationeel</span>
</div>
<b className={domain.loadedCount > 0 ? 'source-status-operational' : 'source-status-planned'}>
{domain.loadedCount > 0 ? 'Operationeel' : 'Aansluiting nodig'}
</b>
<p>{domain.question}</p>
<small>
{domain.loadedNames.length > 0
? domain.loadedNames.slice(0, 2).join(' · ')
: `${domain.nextCount} geverifieerde bron${domain.nextCount === 1 ? '' : 'nen'} eerstvolgend`}
</small>
</article>
))}
</div>
<details className="source-theme-coverage">
<summary>Dekking per kaartthema en tijdreeks</summary>
<div className="source-catalog-grid">
{themes.map((theme) => (
<article className="source-catalog-card" key={theme.theme}>
<div>
<strong>{theme.label}</strong>
<span>{theme.source ?? 'Nog niet ingeladen'}</span>
</div>
<b>{theme.datasetCount > 0 ? 'Beschikbaar' : 'Ontbreekt'}</b>
<p>{timelineSummary(
theme.temporalCount,
theme.temporalSeriesCount,
theme.otherMethodCount,
theme.firstYear,
theme.lastYear,
)}</p>
</article>
))}
</div>
</details>
{waterinfoDatasets.length > 0 || historicalOrthophotos.length > 0 || bwkDatasets.length > 0 || agricultureDatasets.length > 0 || buildingsRegisterDatasets.length > 0 || dhmvDatasets.length > 0 || floodHazardDatasets.length > 0 || bathymetryProfileDatasets.length > 0 ? (
<details className="source-loaded-disclosure">
<summary>Actieve broncollecties en hun beperkingen</summary>
<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}
{bwkDatasets.length > 0 ? (
<article>
<strong>BWK / Natura 2000</strong>
<span>
Toestand 2025 · {bwkDatasets.length} gebiedsdekking{bwkDatasets.length === 1 ? '' : 'en'} ·{' '}
{(largestBwkSnapshot?.feature_count ?? 0).toLocaleString('nl-BE')} kaartvlakken in de grootste dekking
</span>
<p>Biologische waardering en habitataandelen blijven afzonderlijke, brongetrouwe metingen met herkomstinformatie.</p>
</article>
) : null}
{agricultureDatasets.length > 0 ? (
<article>
<strong>Landbouwgebruikspercelen</strong>
<span>{agricultureDatasets.length} definitieve jaargangen{agricultureYears.length ? ` · ${Math.min(...agricultureYears)}-${Math.max(...agricultureYears)}` : ''}</span>
<p>Oppervlakte en officiële hoofdteeltgroepen zijn historisch vergelijkbaar; perceelidentiteiten blijven bewust niet gekoppeld tussen jaren.</p>
</article>
) : null}
{latestBuildingsRegister ? (
<article>
<strong>Gebouwen- en Adressenregister</strong>
<span>
{(latestBuildingsRegister.feature_count ?? 0).toLocaleString('nl-BE')} gebouwen · {' '}
{Number(latestBuildingsRegister.source_metadata?.['building_unit_count'] ?? 0).toLocaleString('nl-BE')} eenheden · {' '}
{Number(latestBuildingsRegister.source_metadata?.['linked_address_count'] ?? 0).toLocaleString('nl-BE')} gekoppelde adressen
</span>
<p>Registerstatus en geaggregeerde koppelingen binnen de werkelijk ingeladen dekking; adreslabels en persoonsgegevens worden niet in de kaartlaag getoond.</p>
</article>
) : null}
{dhmvDatasets.length > 0 ? (
<article>
<strong>Digitaal Hoogtemodel Vlaanderen II</strong>
<span>{dhmvDatasets.length} rasterproducten · DTM/DSM · analyse op 5 m</span>
<p>Hoogte in TAW, reliëf en helling uit opnameperiode 2013-2015. Geen waterdiepte of watervolume.</p>
</article>
) : null}
{floodHazardDatasets.length > 0 ? (
<article>
<strong>VMM-overstromingsgevaarkaarten</strong>
<span>{floodHazardDatasets.length} scenario&apos;s · fluviaal en pluviaal · huidig en 2050</span>
<p>Gemodelleerde maximumdiepte per kansscenario. Geen actuele waterstand, bathymetrie of permanent watervolume.</p>
</article>
) : null}
{bathymetryProfileDatasets.length > 0 ? (
<article>
<strong>VHA-dwarsprofielen waterbodem</strong>
<span>
{bathymetryProfileDatasets.reduce(
(total, dataset) => total + Number(dataset.feature_count ?? 0),
0,
).toLocaleString('nl-BE')} profielpunten · historische meetcampagnes
</span>
<p>Meetvelden en officiële profielbladen per punt. Geen continue actuele bathymetrie of volume zonder een gelijktijdig waterpeil.</p>
</article>
) : null}
</div>
</details>
) : null}
<details className="source-loaded-disclosure">
<summary>Maritieme bronstatus</summary>
<div className="source-catalog-loaded">
<article>
<strong>MDK-dieptemodel Belgische Noordzee</strong>
<span>{mdkProbe ? mdkStatusLabel(mdkProbe.status) : 'Nog niet live gecontroleerd'}</span>
<p>
{mdkProbe?.message ??
'Controleert uitsluitend de officiële service-informatie met geldige TLS. Er wordt geen raster gedownload.'}
</p>
{mdkProbeError ? <small className="error-text">{mdkProbeError}</small> : null}
<button
className="secondary-button"
type="button"
disabled={!projectId || mdkProbeLoading}
onClick={() => void checkMdkReadiness()}
>
{mdkProbeLoading ? 'Controleren...' : 'Controleer Noordzee-bron'}
</button>
</article>
</div>
</details>
<details className="source-opportunity-list">
<summary>Officiële bronnen die hierna kunnen worden ingeladen</summary>
<div className="source-opportunity-domains">
{SOURCE_DOMAINS.map((domain) => {
const sources = pendingSources.filter((source) => source.domain === domain.key)
if (sources.length === 0) return null
return (
<section className="source-opportunity-domain" key={domain.key}>
<header>
<strong>{domain.label}</strong>
<span>{sources.length} gecontroleerde kandidaatbron{sources.length === 1 ? '' : 'nen'}</span>
</header>
<div>
{sources.map((source) => (
<article key={source.name}>
<div>
<strong>{source.name}</strong>
<span>{source.owner} · {source.coverage}</span>
</div>
<span className={`source-priority source-priority-${source.priority}`}>{priorityLabel(source.priority)}</span>
<p>{source.value}</p>
<small>Meetbaar als: {source.metricExamples}</small>
<a href={source.url} target="_blank" rel="noreferrer">Bekijk officiële bron</a>
</article>
))}
</div>
</section>
)
})}
</div>
</details>
</section>
)
}