Files
geointel/frontend/src/components/datasets/DatasetPanel.tsx
T
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

437 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState, type Dispatch, type FormEvent, type SetStateAction } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../../types'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
const DATASET_CATALOG_PAGE_SIZE = 10
interface DatasetFormState {
datasetType: string
source: string
datasetRole: string
sourceName: string
referenceLayerName: string
sourceMetadataJson: string
provenanceMetadataJson: string
areaId: string
file: File | null
}
interface DatasetPanelProps {
selectedProjectId: string | null
selectedDatasetId: string | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
datasetForm: DatasetFormState
loadingDatasets: boolean
onDatasetFormChange: Dispatch<SetStateAction<DatasetFormState>>
onUploadDataset: (event: FormEvent) => void
onLoadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => void
onOpenInspector: () => void
onRefreshMetadata: (datasetId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
onOpenDatasetExport: (dataset: DatasetCreateResponse) => void
}
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n.v.t.'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n.v.t.'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n.v.t.'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
function normalizeDatasetRole(dataset: DatasetCreateResponse): 'reference' | 'candidate' | 'source' {
if (dataset.dataset_role === 'reference') {
return 'reference'
}
if (dataset.dataset_role === 'source') {
return 'source'
}
if (dataset.dataset_role === 'derived' || isVectorDatasetType(dataset.dataset_type)) {
return 'candidate'
}
return 'source'
}
function datasetRoleLabel(role: string): string {
if (role === 'reference') {
return 'Officiële referentie'
}
if (role === 'candidate') {
return 'Analyse-resultaat'
}
return 'Basisbron'
}
function datasetActionHint(dataset: DatasetCreateResponse, role: string): string {
if (role === 'reference') {
return 'Officiële laag voor kaartanalyse en kwaliteitscontrole.'
}
if (role === 'candidate') {
return 'Bewaard resultaat dat opnieuw op de kaart, in kwaliteit of als download kan worden gebruikt.'
}
if (dataset.dataset_type === 'raster') {
return 'Luchtbeeld of rasterbron voor kaart- en beeldanalyse.'
}
return 'Ingeladen gegevensbron voor verdere analyse.'
}
function formatObservationDate(value: string | null | undefined): string {
if (!value) return 'huidige toestand'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('nl-BE', { day: 'numeric', month: 'short', year: 'numeric' }).format(date)
}
function datasetKindLabel(dataset: DatasetCreateResponse): string {
if (dataset.dataset_type === 'raster') return 'Luchtbeeld'
if (isVectorDatasetType(dataset.dataset_type)) return 'Kaartlaag'
return 'Gegevensbron'
}
export function DatasetPanel({
selectedProjectId,
selectedDatasetId,
areas,
datasets,
datasetForm,
loadingDatasets,
onDatasetFormChange,
onUploadDataset,
onLoadDatasetDetails,
onOpenInspector,
onRefreshMetadata,
onOpenDatasetInMap,
onOpenDatasetExport,
}: DatasetPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [page, setPage] = useState(1)
const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId)
const latestDatasetBySeries = new Map<string, DatasetCreateResponse>()
datasets.forEach((dataset) => {
if (!dataset.temporal_series_key) return
const current = latestDatasetBySeries.get(dataset.temporal_series_key)
const currentTimestamp = current?.observed_at ? Date.parse(current.observed_at) : Number.NEGATIVE_INFINITY
const candidateTimestamp = dataset.observed_at ? Date.parse(dataset.observed_at) : Number.NEGATIVE_INFINITY
if (!current || candidateTimestamp > currentTimestamp) {
latestDatasetBySeries.set(dataset.temporal_series_key, dataset)
}
})
const primaryDatasets = datasets.filter(
(dataset) => !dataset.temporal_series_key || latestDatasetBySeries.get(dataset.temporal_series_key)?.id === dataset.id,
)
const historicalDatasets = datasets.filter(
(dataset) => dataset.temporal_series_key && latestDatasetBySeries.get(dataset.temporal_series_key)?.id !== dataset.id,
)
const filteredPrimaryDatasets = useMemo(() => {
const query = searchQuery.trim().toLocaleLowerCase('nl-BE')
if (!query) return primaryDatasets
return primaryDatasets.filter((dataset) => {
const searchable = [
getDatasetDisplayName(dataset),
getDatasetSourceDisplayName(dataset),
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
String(dataset.source_metadata?.['municipality'] ?? ''),
datasetRoleLabel(normalizeDatasetRole(dataset)),
]
return searchable.some((value) => String(value ?? '').toLocaleLowerCase('nl-BE').includes(query))
})
}, [primaryDatasets, searchQuery])
const pageCount = Math.max(1, Math.ceil(filteredPrimaryDatasets.length / DATASET_CATALOG_PAGE_SIZE))
const visiblePrimaryDatasets = filteredPrimaryDatasets.slice(
(page - 1) * DATASET_CATALOG_PAGE_SIZE,
page * DATASET_CATALOG_PAGE_SIZE,
)
const readyDatasets = primaryDatasets.filter((dataset) => dataset.status === 'ready').length
const roleSummaries = [
{
key: 'selected',
label: 'Geselecteerd',
count: selectedDatasetId ? 1 : 0,
className: 'dataset-role-selected',
},
{
key: 'reference',
label: 'Referentie',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'reference').length,
className: 'dataset-role-reference',
},
{
key: 'candidate',
label: 'Resultaat',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'candidate').length,
className: 'dataset-role-candidate',
},
{
key: 'source',
label: 'Basisbron',
count: primaryDatasets.filter((dataset) => normalizeDatasetRole(dataset) === 'source').length,
className: 'dataset-role-source',
},
]
useEffect(() => {
setPage((current) => Math.min(current, pageCount))
}, [pageCount])
useEffect(() => {
setSearchQuery('')
setPage(1)
setHistoryOpen(false)
}, [selectedProjectId])
return (
<section className="workspace-panel" data-testid="dataset-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Ingeladen gegevens</p>
<h2>Bronnen</h2>
</div>
<span className="count-pill">{readyDatasets}/{primaryDatasets.length} bronnen beschikbaar</span>
</div>
<div className="data-selection-summary data-selection-summary-dataset">
<span>Actieve bron</span>
<strong>{selectedDataset ? getDatasetDisplayName(selectedDataset) : 'Geen bron geselecteerd'}</strong>
<small>{selectedDataset ? `${datasetRoleLabel(normalizeDatasetRole(selectedDataset))} · ${selectedDataset.status === 'ready' ? 'beschikbaar' : selectedDataset.status}` : 'Kies een bron om deze op de kaart te openen.'}</small>
</div>
<details className="dataset-upload-block data-panel-form-block">
<summary>Eigen bronbestand toevoegen</summary>
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
<label>
Bestandstype
<select
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
>
<option value="vector">Kaartlaag</option>
<option value="geojson">GeoJSON-kaartlaag</option>
<option value="raster">Luchtbeeld of raster</option>
</select>
</label>
<label>
Naam van de bron
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
placeholder="bijvoorbeeld eigen luchtbeeld"
/>
</label>
<label>
Gebied
<select
value={datasetForm.areaId}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
>
<option value="">Volledige bron</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<label className="file-input-label">
Bestand
<input
type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff"
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
/>
</label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Bron inladen
</button>
</form>
</details>
<div className="dataset-role-summary-grid" aria-label="Overzicht van bronrollen">
{roleSummaries.map((summary) => (
<div className={`dataset-role-summary ${summary.className}`} key={summary.key}>
<strong>{summary.count}</strong>
<span>{summary.label}</span>
</div>
))}
</div>
{loadingDatasets ? <p role="status" aria-live="polite">Bronnen laden...</p> : null}
{!loadingDatasets && datasets.length === 0 ? (
<div className="empty-state">
<strong>Nog geen bronnen beschikbaar</strong>
<p>Voeg een vector-, GeoJSON- of rasterbestand toe aan deze werkruimte.</p>
</div>
) : null}
<div className="data-panel-list-block dataset-catalog-block">
<p className="data-section-label">Beschikbare bronnen</p>
<div className="catalog-browser-toolbar catalog-browser-toolbar-datasets">
<label className="catalog-search-field">
Zoek in beschikbare bronnen
<input
type="search"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
setPage(1)
}}
placeholder="Naam, bron, gemeente of type"
/>
</label>
<div className="catalog-pagination" aria-label="Paginering van bronnen">
<span>{filteredPrimaryDatasets.length} van {primaryDatasets.length}</span>
<button
className="secondary-action"
type="button"
disabled={page === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Vorige
</button>
<strong>{page} / {pageCount}</strong>
<button
className="secondary-action"
type="button"
disabled={page === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
>
Volgende
</button>
</div>
</div>
<ul className="dataset-list">
{visiblePrimaryDatasets.map((dataset) => {
const datasetRole = normalizeDatasetRole(dataset)
const roleLabel = datasetRoleLabel(datasetRole)
return (
<li className={dataset.id === selectedDatasetId ? 'dataset-card dataset-card-active' : 'dataset-card'} key={dataset.id}>
<div className="dataset-card-header">
<div>
<div className="dataset-card-kicker">
<span className={`dataset-role-badge dataset-role-${datasetRole}`}>{roleLabel}</span>
<span>{datasetKindLabel(dataset)}</span>
</div>
<strong className="dataset-card-title">{getDatasetDisplayName(dataset)}</strong>
<div className="entity-meta dataset-card-primary-meta">
<span>{(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
<span>{getDatasetSourceDisplayName(dataset)} · {formatObservationDate(dataset.observed_at)}</span>
</div>
</div>
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{dataset.status === 'ready' ? 'beschikbaar' : dataset.status === 'failed' ? 'mislukt' : 'bezig'}
</span>
</div>
<p className="dataset-action-hint">{datasetActionHint(dataset, datasetRole)}</p>
<div className="dataset-action-grid">
<button
className="primary-action dataset-action-button"
type="button"
onClickCapture={onOpenInspector}
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`}
>
<span>Bekijken</span>
<small>Details en acties</small>
</button>
<button
className="secondary-action dataset-action-button"
type="button"
onClick={() => onOpenDatasetInMap(dataset)}
aria-label="Open op kaart"
>
<span>Kaart</span>
<small>Laag openen</small>
</button>
<button
className="secondary-action dataset-action-button"
type="button"
onClick={() => onOpenDatasetExport(dataset)}
disabled={!(dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson')}
>
<span>Downloaden</span>
<small>{dataset.dataset_type === 'vector' || dataset.dataset_type === 'geojson' ? 'GeoJSON of rapport' : 'Enkel vectorlagen'}</small>
</button>
</div>
<details className="dataset-technical-details">
<summary>Broninformatie</summary>
<div className="entity-meta">
<span>Bestand: {dataset.name}</span>
<span>Bron: {dataset.source_name ?? dataset.source}</span>
{dataset.reference_layer_name ? <span>Laag: {dataset.reference_layer_name}</span> : null}
<span>CRS: {dataset.crs ?? dataset.vector_summary?.crs ?? 'onbekend'}</span>
</div>
<div className="dataset-metrics data-density-grid">
<span>Grootte: {formatBytes(dataset.size_bytes)}</span>
<span>Objecten: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'onbekend'}</span>
<span>Begrenzing: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<button
className="secondary-action"
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
aria-label="Bronmetadata vernieuwen"
>
Metadata vernieuwen
</button>
</details>
</li>
)
})}
</ul>
{visiblePrimaryDatasets.length === 0 && datasets.length > 0 ? (
<p className="muted">Geen bron gevonden voor deze zoekterm.</p>
) : null}
{historicalDatasets.length > 0 ? (
<details
className="dataset-history-disclosure"
onToggle={(event) => setHistoryOpen(event.currentTarget.open)}
>
<summary>
<span>Historische meetmomenten</span>
<strong>{historicalDatasets.length} oudere lagen</strong>
</summary>
{historyOpen ? <ul className="dataset-history-list">
{historicalDatasets.map((dataset) => (
<li key={dataset.id}>
<div>
<strong>{getDatasetDisplayName(dataset)}</strong>
<span>{formatObservationDate(dataset.observed_at)} · {(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten</span>
</div>
<div className="panel-action-row">
<button className="secondary-action" type="button" onClick={() => onOpenDatasetInMap(dataset)}>Kaart</button>
<button className="secondary-action" type="button" onClick={() => onOpenDatasetExport(dataset)}>Downloaden</button>
</div>
</li>
))}
</ul> : null}
</details>
) : null}
</div>
</section>
)
}