Scale regional data catalogs
This commit is contained in:
@@ -121,6 +121,13 @@ canonical regional workspaces cannot be archived through the UI.
|
||||
|
||||
Data and Map are organized around the core daily workflow. Data shows Project, AOI and Dataset columns together on normal desktop widths, bounds populated lists inside their own panels and keeps create/upload forms in explicit disclosures. Map keeps the layer/AOI command surface and MapLibre frame first, then exposes provenance, BBox controls and raw feature inspection only when requested. Existing selection, export and QA actions are unchanged.
|
||||
|
||||
Regional workspaces are not truncated to the first API page. The frontend
|
||||
exhaustively loads paged Area and Dataset inventories, then keeps the Data
|
||||
workspace responsive with searchable 12-Area and 10-Dataset display pages.
|
||||
Collapsed Area catalogs and historical-detail content are mounted only when
|
||||
opened. This makes the 285-municipality Flanders workspace navigable without
|
||||
hiding or discarding any persisted source partition.
|
||||
|
||||
Wide and ultrawide screens keep a readable sidebar and centered work area, expand the MapLibre review frame and use extra horizontal space for Data, Analysis, AI and Export grids. The detail drawer overlays the work area only while open, so it does not permanently consume ultrawide canvas space.
|
||||
|
||||
The Map workspace defaults to an OpenStreetMap road basemap with visible attribution so uploaded vectors, AOIs and QA overlays appear on a real street context. Set `VITE_MAP_STYLE_URL` to a managed MapLibre style URL to override this for production or high-volume deployments.
|
||||
|
||||
@@ -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