Scale regional data catalogs
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from 'react'
|
||||
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
|
||||
@@ -123,6 +125,9 @@ export function DatasetPanel({
|
||||
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) => {
|
||||
@@ -140,6 +145,27 @@ export function DatasetPanel({
|
||||
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 = [
|
||||
{
|
||||
@@ -172,6 +198,16 @@ export function DatasetPanel({
|
||||
},
|
||||
]
|
||||
|
||||
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">
|
||||
@@ -257,8 +293,42 @@ export function DatasetPanel({
|
||||
) : 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">
|
||||
{primaryDatasets.map((dataset) => {
|
||||
{visiblePrimaryDatasets.map((dataset) => {
|
||||
const datasetRole = normalizeDatasetRole(dataset)
|
||||
const roleLabel = datasetRoleLabel(datasetRole)
|
||||
return (
|
||||
@@ -337,13 +407,19 @@ export function DatasetPanel({
|
||||
)
|
||||
})}
|
||||
</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">
|
||||
<details
|
||||
className="dataset-history-disclosure"
|
||||
onToggle={(event) => setHistoryOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary>
|
||||
<span>Historische meetmomenten</span>
|
||||
<strong>{historicalDatasets.length} oudere lagen</strong>
|
||||
</summary>
|
||||
<ul className="dataset-history-list">
|
||||
{historyOpen ? <ul className="dataset-history-list">
|
||||
{historicalDatasets.map((dataset) => (
|
||||
<li key={dataset.id}>
|
||||
<div>
|
||||
@@ -356,7 +432,7 @@ export function DatasetPanel({
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul> : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import type { AreaRead, ProjectRead } from '../../types'
|
||||
|
||||
const AREA_CATALOG_PAGE_SIZE = 12
|
||||
|
||||
function projectDisplayName(project: ProjectRead | null): string {
|
||||
if (project?.name === 'Kempen Regional Workbench') {
|
||||
return 'Kempen · volledige regio'
|
||||
@@ -40,7 +42,30 @@ export function AreaPanel({
|
||||
onUpdateAreaForm,
|
||||
onSelectMapArea,
|
||||
}: AreaPanelProps): JSX.Element {
|
||||
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const selectedArea = areas.find((area) => area.id === selectedMapAreaId)
|
||||
const filteredAreas = useMemo(() => {
|
||||
const query = searchQuery.trim().toLocaleLowerCase('nl-BE')
|
||||
if (!query) return areas
|
||||
return areas.filter((area) => area.name.toLocaleLowerCase('nl-BE').includes(query))
|
||||
}, [areas, searchQuery])
|
||||
const pageCount = Math.max(1, Math.ceil(filteredAreas.length / AREA_CATALOG_PAGE_SIZE))
|
||||
const visibleAreas = filteredAreas.slice(
|
||||
(page - 1) * AREA_CATALOG_PAGE_SIZE,
|
||||
page * AREA_CATALOG_PAGE_SIZE,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount))
|
||||
}, [pageCount])
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
setCatalogOpen(false)
|
||||
}, [selectedProjectId])
|
||||
|
||||
return (
|
||||
<section data-testid="area-panel">
|
||||
@@ -94,28 +119,73 @@ export function AreaPanel({
|
||||
|
||||
{loadingAreas ? <p>Gebieden laden...</p> : null}
|
||||
{areas.length === 0 ? <p>Nog geen gebieden beschikbaar.</p> : null}
|
||||
<details className="data-panel-list-block area-catalog-disclosure">
|
||||
<details
|
||||
className="data-panel-list-block area-catalog-disclosure"
|
||||
onToggle={(event) => setCatalogOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary>{areas.length} beschikbare gemeenten en regiogrenzen</summary>
|
||||
<ul className="entity-list">
|
||||
{areas.map((area) => (
|
||||
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
|
||||
<strong>{area.name}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span>
|
||||
<span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span>
|
||||
{catalogOpen ? (
|
||||
<>
|
||||
<div className="catalog-browser-toolbar">
|
||||
<label className="catalog-search-field">
|
||||
Zoek gemeente of regio
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Bijvoorbeeld Mol"
|
||||
/>
|
||||
</label>
|
||||
<div className="catalog-pagination" aria-label="Paginering van gebieden">
|
||||
<span>{filteredAreas.length} gevonden</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>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => onSelectMapArea(area.id)}
|
||||
disabled={!area.geometry}
|
||||
data-testid={`area-show-${area.id}`}
|
||||
>
|
||||
{selectedMapAreaId === area.id ? 'Op kaart' : 'Toon op kaart'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{visibleAreas.length > 0 ? (
|
||||
<ul className="entity-list">
|
||||
{visibleAreas.map((area) => (
|
||||
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
|
||||
<strong>{area.name}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span>
|
||||
<span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-action"
|
||||
type="button"
|
||||
onClick={() => onSelectMapArea(area.id)}
|
||||
disabled={!area.geometry}
|
||||
data-testid={`area-show-${area.id}`}
|
||||
>
|
||||
{selectedMapAreaId === area.id ? 'Op kaart' : 'Toon op kaart'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">Geen gebied gevonden voor deze zoekterm.</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
import { apiGet, apiPost, apiPatch } from './client'
|
||||
import type { AreaCreate, AreaListResponse, AreaRead } from '../../types'
|
||||
|
||||
const AREA_PAGE_SIZE = 200
|
||||
|
||||
async function listProjectAreas(projectId: string): Promise<AreaListResponse> {
|
||||
const items: AreaRead[] = []
|
||||
let offset = 0
|
||||
let total: number | null = null
|
||||
do {
|
||||
const page = await apiGet<AreaListResponse>(
|
||||
`/api/v1/projects/${projectId}/areas?limit=${AREA_PAGE_SIZE}&offset=${offset}`,
|
||||
)
|
||||
if (total === null) {
|
||||
total = page.total
|
||||
} else if (page.total !== total) {
|
||||
throw new Error('De gebiedslijst wijzigde tijdens het laden. Vernieuw de werkruimte.')
|
||||
}
|
||||
items.push(...page.items)
|
||||
if (page.items.length === 0) {
|
||||
break
|
||||
}
|
||||
offset += page.items.length
|
||||
} while (offset < (total ?? 0))
|
||||
if (total !== null && items.length !== total) {
|
||||
throw new Error(`Niet alle gebieden konden worden geladen (${items.length}/${total}).`)
|
||||
}
|
||||
return { items, total: total ?? 0, limit: items.length, offset: 0 }
|
||||
}
|
||||
|
||||
export const areasApi = {
|
||||
list: (projectId: string): Promise<AreaListResponse> => apiGet<AreaListResponse>(`/api/v1/projects/${projectId}/areas`),
|
||||
list: listProjectAreas,
|
||||
create: (projectId: string, payload: AreaCreate): Promise<AreaRead> =>
|
||||
apiPost<AreaRead>(`/api/v1/projects/${projectId}/areas`, payload),
|
||||
get: (projectId: string, areaId: string): Promise<AreaRead> =>
|
||||
|
||||
@@ -571,6 +571,54 @@ details.data-panel-form-block > form {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.catalog-browser-toolbar {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.65rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.catalog-browser-toolbar-datasets {
|
||||
margin-bottom: 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.catalog-search-field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: #53616d;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.catalog-search-field input {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.catalog-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.catalog-pagination strong {
|
||||
min-width: 3.5rem;
|
||||
color: var(--ink);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.catalog-pagination .secondary-action {
|
||||
width: auto;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
/* AI and review workspaces */
|
||||
|
||||
.ai-lab-shell {
|
||||
|
||||
Reference in New Issue
Block a user