Scale regional data catalogs
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-17 16:39:28 +02:00
parent 132c4feed8
commit 6fd3ed49b2
9 changed files with 321 additions and 31 deletions
+7
View File
@@ -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>
+91 -21
View File
@@ -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>
)
+28 -1
View File
@@ -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> =>
+48
View File
@@ -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 {