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
437 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|