Scale regional data catalogs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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> =>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user