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
+5
View File
@@ -17,6 +17,11 @@
without coverage download or insecure fallback. without coverage download or insecure fallback.
- Exposed truthful MDK readiness and bathymetry partition-finalization API - Exposed truthful MDK readiness and bathymetry partition-finalization API
contracts and packaged all operator scripts in the all-in-one image. contracts and packaged all operator scripts in the all-in-one image.
- Provisioned the live Flanders workbench with all 285 current municipalities
and 128,913 VHA profile points across 269 data-bearing partitions; 16
municipalities are explicitly recorded as having no profiles.
- Made frontend Area loading exhaustive and bounded the Area/Dataset catalogs
with search and pagination so complete regional workspaces remain usable.
## Sprint 235 Governed bathymetry profiles and Belgian scale architecture (2026-07-17) ## Sprint 235 Governed bathymetry profiles and Belgian scale architecture (2026-07-17)
@@ -293,3 +293,32 @@ def test_expansion_scripts_are_packaged_and_readiness_checked() -> None:
assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only" assert sources["mdk_bcp_bathymetry"]["integration_status"] == "probe_only"
assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False assert sources["mdk_bcp_bathymetry"]["acquisition_supported"] is False
assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"] assert "EL_wcs" in sources["mdk_bcp_bathymetry"]["service_url"]
def test_frontend_exhaustively_pages_regional_area_inventory() -> None:
area_api = (ROOT / "frontend" / "src" / "services" / "api" / "areas.ts").read_text(
encoding="utf-8"
)
assert "const AREA_PAGE_SIZE = 200" in area_api
assert "while (offset < (total ?? 0))" in area_api
assert "items.length !== total" in area_api
assert "list: listProjectAreas" in area_api
def test_frontend_bounds_large_area_and_dataset_catalogs() -> None:
area_panel = (
ROOT / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx"
).read_text(encoding="utf-8")
dataset_panel = (
ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx"
).read_text(encoding="utf-8")
assert "const AREA_CATALOG_PAGE_SIZE = 12" in area_panel
assert "{catalogOpen ? (" in area_panel
assert "visibleAreas.map" in area_panel
assert "Zoek gemeente of regio" in area_panel
assert "const DATASET_CATALOG_PAGE_SIZE = 10" in dataset_panel
assert "visiblePrimaryDatasets.map" in dataset_panel
assert "Zoek in beschikbare bronnen" in dataset_panel
assert "{historyOpen ? <ul" in dataset_panel
+3 -2
View File
@@ -121,8 +121,9 @@ bed evolution.
## Implementation order ## Implementation order
1. Operate and validate the Mol VHA profile Dataset and map flow. **Done.** 1. Operate and validate the Mol VHA profile Dataset and map flow. **Done.**
2. Add VHA municipal partition orchestration for Flanders. **Implemented; 2. Add VHA municipal partition orchestration for Flanders. **Done and live:
complete live provisioning remains an explicit operator run.** 285/285 municipality partitions accounted for, 269 with data, 16 explicit
no-profile results, 128,913 profile points and zero failures.**
3. Implement a bounded MDK WCS probe, then acquisition behind live evidence. 3. Implement a bounded MDK WCS probe, then acquisition behind live evidence.
**Probe implemented. Acquisition blocked because the live endpoint fails **Probe implemented. Acquisition blocked because the live endpoint fails
strict hostname validation and does not expose usable capabilities.** strict hostname validation and does not expose usable capabilities.**
+29 -2
View File
@@ -10090,5 +10090,32 @@ Validation:
- The complete readiness gate passed 920 backend tests, backend compilation, - The complete readiness gate passed 920 backend tests, backend compilation,
documentation/contract audits, 115 documented API routes, Alembic head documentation/contract audits, 115 documented API routes, Alembic head
`202607160001`, frontend TypeScript typecheck and the production Vite build. `202607160001`, frontend TypeScript typecheck and the production Vite build.
- Live Docker deployment and controlled Flanders provisioning are recorded
after the repository commit below. Final live acceptance:
- Commit `132c4fe` was pushed to Gitea and deployed through the all-in-one
Unraid flow. Container health, PostGIS 3.6, required schema/indexes,
Alembic head `202607160001`, frontend proxy and icon checks passed.
- The dynamic VRBG operator provisioned one exact Flanders land boundary,
all 285 current municipality Areas and both governed boundary Datasets in
`Flanders Regional Workbench`. The live union covers 13,625.73 km2 with
WGS84 bounds `[2.54132923, 50.68749237, 5.9111094, 51.50511313]`.
A repeated run returned the same project, Area and Dataset ids.
- The complete VHA coordinator accounted for all 285 municipality partitions:
269 contain data, 16 explicitly contain no profile and none failed. It
persisted 128,913 exact-Area profile points from 79,398 source documents;
49,628 observations have a structured depth field and the recorded survey
dates range from 1877-01-01 through 2026-04-15.
- Regional finalization became true only after the complete inventory passed.
The checksum-bound manifest is
`7f7fd94ecd64e8fc978b3f14e8f21efb471f9c63f89f16fafc23f9fded89aaa2`.
A second complete run returned the same hash, counts and Dataset ids without
creating duplicate data.
- The live MDK probe returned `tls_error`: the configured official hostname
does not present a matching trusted certificate. Acquisition remains
disabled and no insecure fallback or North Sea raster request was made.
- Live volume exposed a frontend scalability defect: Area loading stopped at
the first API page and the Data workspace rendered every Dataset card at
once. The follow-up now exhaustively pages all Areas, renders bounded
searchable Area/Dataset pages and mounts collapsed Area/history catalogs
only when opened. This preserves the complete regional inventory without
overwhelming the browser DOM.
+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. 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. 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. 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 type { AreaRead, DatasetCreateResponse } from '../../types'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
const DATASET_CATALOG_PAGE_SIZE = 10
interface DatasetFormState { interface DatasetFormState {
datasetType: string datasetType: string
source: string source: string
@@ -123,6 +125,9 @@ export function DatasetPanel({
onOpenDatasetInMap, onOpenDatasetInMap,
onOpenDatasetExport, onOpenDatasetExport,
}: DatasetPanelProps) { }: DatasetPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [page, setPage] = useState(1)
const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId) const selectedDataset = datasets.find((dataset) => dataset.id === selectedDatasetId)
const latestDatasetBySeries = new Map<string, DatasetCreateResponse>() const latestDatasetBySeries = new Map<string, DatasetCreateResponse>()
datasets.forEach((dataset) => { datasets.forEach((dataset) => {
@@ -140,6 +145,27 @@ export function DatasetPanel({
const historicalDatasets = datasets.filter( const historicalDatasets = datasets.filter(
(dataset) => dataset.temporal_series_key && latestDatasetBySeries.get(dataset.temporal_series_key)?.id !== dataset.id, (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 readyDatasets = primaryDatasets.filter((dataset) => dataset.status === 'ready').length
const roleSummaries = [ 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 ( return (
<section className="workspace-panel" data-testid="dataset-panel"> <section className="workspace-panel" data-testid="dataset-panel">
<div className="panel-title-row"> <div className="panel-title-row">
@@ -257,8 +293,42 @@ export function DatasetPanel({
) : null} ) : null}
<div className="data-panel-list-block dataset-catalog-block"> <div className="data-panel-list-block dataset-catalog-block">
<p className="data-section-label">Beschikbare bronnen</p> <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"> <ul className="dataset-list">
{primaryDatasets.map((dataset) => { {visiblePrimaryDatasets.map((dataset) => {
const datasetRole = normalizeDatasetRole(dataset) const datasetRole = normalizeDatasetRole(dataset)
const roleLabel = datasetRoleLabel(datasetRole) const roleLabel = datasetRoleLabel(datasetRole)
return ( return (
@@ -337,13 +407,19 @@ export function DatasetPanel({
) )
})} })}
</ul> </ul>
{visiblePrimaryDatasets.length === 0 && datasets.length > 0 ? (
<p className="muted">Geen bron gevonden voor deze zoekterm.</p>
) : null}
{historicalDatasets.length > 0 ? ( {historicalDatasets.length > 0 ? (
<details className="dataset-history-disclosure"> <details
className="dataset-history-disclosure"
onToggle={(event) => setHistoryOpen(event.currentTarget.open)}
>
<summary> <summary>
<span>Historische meetmomenten</span> <span>Historische meetmomenten</span>
<strong>{historicalDatasets.length} oudere lagen</strong> <strong>{historicalDatasets.length} oudere lagen</strong>
</summary> </summary>
<ul className="dataset-history-list"> {historyOpen ? <ul className="dataset-history-list">
{historicalDatasets.map((dataset) => ( {historicalDatasets.map((dataset) => (
<li key={dataset.id}> <li key={dataset.id}>
<div> <div>
@@ -356,7 +432,7 @@ export function DatasetPanel({
</div> </div>
</li> </li>
))} ))}
</ul> </ul> : null}
</details> </details>
) : null} ) : null}
</div> </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' import type { AreaRead, ProjectRead } from '../../types'
const AREA_CATALOG_PAGE_SIZE = 12
function projectDisplayName(project: ProjectRead | null): string { function projectDisplayName(project: ProjectRead | null): string {
if (project?.name === 'Kempen Regional Workbench') { if (project?.name === 'Kempen Regional Workbench') {
return 'Kempen · volledige regio' return 'Kempen · volledige regio'
@@ -40,7 +42,30 @@ export function AreaPanel({
onUpdateAreaForm, onUpdateAreaForm,
onSelectMapArea, onSelectMapArea,
}: AreaPanelProps): JSX.Element { }: 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 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 ( return (
<section data-testid="area-panel"> <section data-testid="area-panel">
@@ -94,28 +119,73 @@ export function AreaPanel({
{loadingAreas ? <p>Gebieden laden...</p> : null} {loadingAreas ? <p>Gebieden laden...</p> : null}
{areas.length === 0 ? <p>Nog geen gebieden beschikbaar.</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> <summary>{areas.length} beschikbare gemeenten en regiogrenzen</summary>
<ul className="entity-list"> {catalogOpen ? (
{areas.map((area) => ( <>
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}> <div className="catalog-browser-toolbar">
<strong>{area.name}</strong> <label className="catalog-search-field">
<div className="entity-meta"> Zoek gemeente of regio
<span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span> <input
<span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span> 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> </div>
<button </div>
className="secondary-action" {visibleAreas.length > 0 ? (
type="button" <ul className="entity-list">
onClick={() => onSelectMapArea(area.id)} {visibleAreas.map((area) => (
disabled={!area.geometry} <li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
data-testid={`area-show-${area.id}`} <strong>{area.name}</strong>
> <div className="entity-meta">
{selectedMapAreaId === area.id ? 'Op kaart' : 'Toon op kaart'} <span>{area.area_m2 ? `${(area.area_m2 / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2` : 'Oppervlakte onbekend'}</span>
</button> <span>{area.geometry?.type ?? area.geometry_type ?? 'Geometrie onbekend'}</span>
</li> </div>
))} <button
</ul> 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> </details>
</section> </section>
) )
+28 -1
View File
@@ -1,8 +1,35 @@
import { apiGet, apiPost, apiPatch } from './client' import { apiGet, apiPost, apiPatch } from './client'
import type { AreaCreate, AreaListResponse, AreaRead } from '../../types' 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 = { export const areasApi = {
list: (projectId: string): Promise<AreaListResponse> => apiGet<AreaListResponse>(`/api/v1/projects/${projectId}/areas`), list: listProjectAreas,
create: (projectId: string, payload: AreaCreate): Promise<AreaRead> => create: (projectId: string, payload: AreaCreate): Promise<AreaRead> =>
apiPost<AreaRead>(`/api/v1/projects/${projectId}/areas`, payload), apiPost<AreaRead>(`/api/v1/projects/${projectId}/areas`, payload),
get: (projectId: string, areaId: string): Promise<AreaRead> => get: (projectId: string, areaId: string): Promise<AreaRead> =>
+48
View File
@@ -571,6 +571,54 @@ details.data-panel-form-block > form {
background: transparent; 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 and review workspaces */
.ai-lab-shell { .ai-lab-shell {