382 lines
17 KiB
TypeScript
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'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>
|
|
)
|
|
}
|