feat: make spatial entry optional and authoritative
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-26 03:39:50 +02:00
parent 5efcf94d82
commit 25b6f1ab39
21 changed files with 462 additions and 104 deletions
+18 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Area
from app.schemas import Envelope
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
from app.services.area_service import AreaService
from app.utils.response import envelope
@@ -33,6 +33,23 @@ def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get
return envelope(AreaService.serialize_area(area))
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
def search_municipalities(
project_id: UUID,
query: str = Query(default="", max_length=120),
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
):
items, total = AreaService.search_municipalities(db, project_id, query, limit)
return envelope({"items": items, "total": total})
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
area = AreaService.activate_municipality(db, project_id, niscode)
return envelope(AreaService.serialize_area(area))
@router.get("/{area_id}", response_model=Envelope[AreaRead])
def get_area(
project_id: UUID,
+13
View File
@@ -39,3 +39,16 @@ class AreaList(BaseModel):
total: int
limit: int
offset: int
class MunicipalitySearchItem(BaseModel):
niscode: str
name: str
name_nl: str | None = None
name_fr: str | None = None
name_de: str | None = None
class MunicipalitySearchList(BaseModel):
items: list[MunicipalitySearchItem]
total: int
+79 -1
View File
@@ -7,12 +7,90 @@ from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import mapping
from app.core.errors import AppError
from app.models import Area, Project
from app.models import Area, Dataset, Project, VectorFeature
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon
class AreaService:
@staticmethod
def _municipality_dataset(db: Session, project_id: uuid.UUID) -> Dataset | None:
return (
db.query(Dataset)
.filter(
Dataset.project_id == project_id,
Dataset.reference_layer_name == "belgium_municipalities",
Dataset.status == "ready",
)
.order_by(Dataset.created_at.desc())
.first()
)
@staticmethod
def _filter_municipality_properties(properties_items: list[dict], query: str, limit: int) -> tuple[list[dict], int]:
normalized = query.strip().casefold()
matches: list[dict] = []
for properties in properties_items:
names = [str(properties.get(key) or "").strip() for key in ("namedut", "namefre", "nameger")]
niscode = str(properties.get("niscode") or "").strip()
if normalized and normalized not in " ".join([niscode, *names]).casefold():
continue
display_name = next((name for name in names if name), niscode)
matches.append({
"niscode": niscode,
"name": display_name,
"name_nl": names[0] or None,
"name_fr": names[1] or None,
"name_de": names[2] or None,
})
matches.sort(key=lambda item: (item["name"].casefold(), item["niscode"]))
return matches[:limit], len(matches)
@staticmethod
def search_municipalities(db: Session, project_id: uuid.UUID, query: str, limit: int = 20) -> tuple[list[dict], int]:
dataset = AreaService._municipality_dataset(db, project_id)
if dataset is None:
return [], 0
property_rows = (
db.query(VectorFeature.properties_json)
.filter(VectorFeature.dataset_id == dataset.id)
.all()
)
properties_items = [row[0] for row in property_rows if isinstance(row[0], dict)]
return AreaService._filter_municipality_properties(properties_items, query, limit)
@staticmethod
def activate_municipality(db: Session, project_id: uuid.UUID, niscode: str) -> Area:
normalized_code = niscode.strip()
dataset = AreaService._municipality_dataset(db, project_id)
if dataset is None:
raise AppError(code="MUNICIPALITY_NOT_FOUND", message="Municipality is not available in the official NGI administrative layer", status_code=404)
feature = (
db.query(VectorFeature)
.filter(
VectorFeature.dataset_id == dataset.id,
VectorFeature.properties_json["niscode"].as_string() == normalized_code,
)
.first()
)
if feature is not None:
properties = feature.properties_json if isinstance(feature.properties_json, dict) else {}
display_name = next(
(str(properties.get(key) or "").strip() for key in ("namedut", "namefre", "nameger") if str(properties.get(key) or "").strip()),
normalized_code,
)
area_name = f"Gemeente {display_name} - NIS {normalized_code}"
existing = db.query(Area).filter(Area.project_id == project_id, Area.name == area_name).first()
if existing is not None:
return existing
geometry = to_shape(feature.geometry)
return AreaService.create_area(
db,
project_id,
AreaCreate(name=area_name, geometry=mapping(geometry), crs="EPSG:4326"),
)
raise AppError(code="MUNICIPALITY_NOT_FOUND", message="Municipality is not available in the official NGI administrative layer", status_code=404)
@staticmethod
def serialize_area(area: Area) -> dict:
geometry = to_shape(area.geometry) if area.geometry else None
@@ -0,0 +1,81 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from app.db.session import get_db
from app.main import create_app
from app.services.area_service import AreaService
def test_municipality_service_matches_all_official_names_and_nis(monkeypatch):
records = [
{"niscode": "63004", "namedut": "Baelen", "namefre": "Baelen", "nameger": "Balen"},
{"niscode": "13025", "namedut": "Mol", "namefre": "Mol", "nameger": "Mol"},
]
by_german_name, name_total = AreaService._filter_municipality_properties(records, "Balen", 20)
by_nis, nis_total = AreaService._filter_municipality_properties(records, "13025", 20)
assert name_total == 1
assert by_german_name[0]["niscode"] == "63004"
assert nis_total == 1
assert by_nis[0]["name"] == "Mol"
def test_municipality_search_uses_authoritative_catalog(monkeypatch):
project_id = uuid4()
monkeypatch.setattr(
AreaService,
"search_municipalities",
staticmethod(lambda _db, requested_project_id, query, limit: (
[{"niscode": "13025", "name": "Mol", "name_nl": "Mol", "name_fr": "Mol", "name_de": "Mol"}],
1,
) if requested_project_id == project_id and query == "mol" and limit == 12 else ([], 0)),
)
app = create_app()
app.dependency_overrides[get_db] = lambda: object()
try:
response = TestClient(app).get(f"/api/v1/projects/{project_id}/areas/municipalities?query=mol&limit=12")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["data"] == {
"items": [{"niscode": "13025", "name": "Mol", "name_nl": "Mol", "name_fr": "Mol", "name_de": "Mol"}],
"total": 1,
}
def test_municipality_activation_returns_persisted_area(monkeypatch):
project_id = uuid4()
area_id = uuid4()
area = SimpleNamespace(id=area_id, project_id=project_id)
monkeypatch.setattr(AreaService, "activate_municipality", staticmethod(lambda _db, requested_project_id, niscode: area))
monkeypatch.setattr(
AreaService,
"serialize_area",
staticmethod(lambda _area: {
"id": area_id,
"project_id": project_id,
"name": "Gemeente Mol - NIS 13025",
"original_crs": "EPSG:4326",
"area_m2": 114000000.0,
"created_at": datetime(2026, 7, 26, tzinfo=timezone.utc),
"geometry_type": "MultiPolygon",
"geometry": {"type": "MultiPolygon", "coordinates": []},
}),
)
app = create_app()
app.dependency_overrides[get_db] = lambda: object()
try:
response = TestClient(app).post(f"/api/v1/projects/{project_id}/areas/municipalities/13025/activate", json={})
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["data"]["id"] == str(area_id)
assert response.json()["data"]["name"] == "Gemeente Mol - NIS 13025"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 140 KiB

+4 -4
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">GeoIntel Atlas</title>
<desc id="desc">Een geografische lens met contourlijnen, kompasrichting en locatiepunt.</desc>
<defs><linearGradient id="bg" x1="30" y1="18" x2="222" y2="238" gradientUnits="userSpaceOnUse"><stop stop-color="#10564E"/><stop offset="1" stop-color="#062824"/></linearGradient><linearGradient id="land" x1="69" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse"><stop stop-color="#D9FFF7"/><stop offset="1" stop-color="#62D5C0"/></linearGradient><filter id="shadow" x="-25%" y="-25%" width="150%" height="150%"><feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#001C19" flood-opacity=".35"/></filter></defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/><path d="M26 76C63 57 92 68 120 56s59-33 110-14M20 116c41-17 70-9 101-22s68-29 116-9M20 158c38-16 73-6 108-21s67-22 109-7M33 202c40-16 65-5 99-18s57-17 90-8" fill="none" stroke="#A9EBDD" stroke-opacity=".16" stroke-width="8" stroke-linecap="round"/><g filter="url(#shadow)"><path d="M128 35c51 0 93 41 93 93 0 51-42 93-93 93s-93-42-93-93c0-52 42-93 93-93Z" fill="#082F2B" stroke="#86E4D2" stroke-width="9"/><path d="M128 58c38 0 70 31 70 70 0 38-32 70-70 70s-70-32-70-70c0-39 32-70 70-70Z" fill="none" stroke="#D8FFF7" stroke-opacity=".22" stroke-width="5" stroke-dasharray="2 12" stroke-linecap="round"/><path d="M86 156c18-8 29-4 44-11 16-8 24-22 43-23M79 135c19-8 33-4 48-12 16-8 24-21 42-24M83 113c13-6 27-4 39-10 15-8 25-19 38-22" fill="none" stroke="url(#land)" stroke-width="8" stroke-linecap="round"/><path d="M128 72v14M128 170v14M72 128h14M170 128h14" stroke="#E9FFF9" stroke-width="7" stroke-linecap="round"/><path d="m128 92 12 25 25 11-25 12-12 25-12-25-25-12 25-11 12-25Z" fill="#EFCB72" stroke="#FFF5CE" stroke-width="4" stroke-linejoin="round"/><circle cx="128" cy="128" r="9" fill="#082F2B" stroke="#FFF" stroke-width="4"/></g>
<title id="title">GeoIntel</title>
<desc id="desc">Een geometrische G als geografische lens met een gemarkeerd coördinaatpunt.</desc>
<defs><linearGradient id="surface" x1="28" y1="20" x2="226" y2="236" gradientUnits="userSpaceOnUse"><stop stop-color="#123F3B"/><stop offset="1" stop-color="#061F20"/></linearGradient><linearGradient id="geo" x1="68" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse"><stop stop-color="#B9FFF0"/><stop offset="1" stop-color="#48C9B1"/></linearGradient><filter id="lift" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#001615" flood-opacity=".45"/></filter></defs>
<rect width="256" height="256" rx="54" fill="url(#surface)"/><path d="M28 74c36-17 62-9 91-20 32-12 61-27 108-13M20 117c39-17 69-8 100-21 33-13 65-24 116-7M20 163c42-17 72-7 105-19 31-12 59-20 111-5M31 207c37-14 66-5 96-16 31-12 56-17 96-7" fill="none" stroke="#A6E8DC" stroke-opacity=".10" stroke-width="7" stroke-linecap="round"/><g filter="url(#lift)"><path d="M179 78a72 72 0 1 0 9 83" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round"/><path d="M137 128h62v50" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round" stroke-linejoin="round"/><circle cx="128" cy="128" r="23" fill="#082927" stroke="#DFFFF8" stroke-width="7"/><circle cx="128" cy="128" r="10" fill="#F2C864"/></g><path d="M203 52v18M194 61h18" stroke="#DFFFF8" stroke-opacity=".55" stroke-width="4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+13
View File
@@ -262,6 +262,19 @@ area list endpoint and includes persisted GeoJSON geometry for map display.
Updates the area name and/or geometry. Geometry updates follow the same
validation, repair and metric-calculation rules as area creation.
### GET `/api/v1/projects/{project_id}/areas/municipalities`
Searches the persisted authoritative NGI AdminVector municipality layer by
Dutch, French or German name and NIS code. `query` is optional and `limit` is
bounded to 50. Results contain names and NIS identity but no fabricated or
browser-fetched geometry.
### POST `/api/v1/projects/{project_id}/areas/municipalities/{niscode}/activate`
Idempotently creates or returns a project Area from the exact persisted NGI
municipality geometry. The resulting Area can be used by all existing bounded
selection, acquisition, analysis and export contracts.
## Datasets
### POST `/api/v1/projects/{project_id}/datasets/upload`
+20
View File
@@ -11326,3 +11326,23 @@ Validation:
Known limitation:
- the authenticated live workbench still requires a user session for visual browser verification after deployment; public landing and runtime health remain independently testable.
## 2026-07-26 - Optional spatial entry and authoritative municipality activation
Implemented:
- corrected the false assumption that municipalities were already project Areas: the national workspace contains 565 official NGI AdminVector municipality features instead;
- added bounded search across the persisted Dutch, French and German NGI names plus NIS code, and an idempotent activation route that creates a normal project Area from the exact persisted geometry;
- replaced the non-functional Area-only datalist with an asynchronous official municipality search, explicit result selection, loading/error states and active-area feedback;
- repositioned municipality search as an optional map shortcut alongside free drawing rather than a mandatory first step;
- reframed themes as optional map focus: they select the visible layer and primary metric while the insights workflow continues to report broader source availability;
- removed misleading step numbers, clarified the spatial research flow and reduced both the application rail and map focus panel;
- constrained narrow theme cards so titles, descriptions and availability labels remain inside their bounds;
- replaced the compass illustration with a new vector GeoIntel mark: a geometric G/geo-lens, coordinate point and subtle contour field, applied to every existing favicon and application-icon derivative.
Validation:
- verified the production database contains 565 authoritative municipality features and inspected the actual multilingual NGI property contract read-only;
- added backend API/service tests for multilingual/NIS search and persisted-area activation;
- added frontend tests for live search, activation and explicit optional/free-selection semantics;
- visually inspected the 512 px application mark and 32 px favicon derivative.
+10
View File
@@ -908,3 +908,13 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Replace passive management naming with an actionable system/source monitor.
- [x] Preserve existing persisted Area, source, analysis and QA contracts.
- [ ] Add more direct quality-review actions only when a persisted candidate/reference pair exists; never synthesize a check.
# Sprint 228 - Optional spatial entry and municipality activation
- [x] Search the real persisted NGI municipality catalog instead of existing project Areas.
- [x] Match Dutch, French and German names plus NIS codes.
- [x] Activate the exact official geometry idempotently as a reusable project Area.
- [x] Present municipality search as an optional shortcut beside free map drawing.
- [x] Explain theme choice as map and primary-metric focus rather than a mandatory workflow gate.
- [x] Keep narrow theme-card copy and status labels inside their cards.
- [x] Replace the former compass icon with a scale-safe GeoIntel geo-lens mark.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 140 KiB

+18 -19
View File
@@ -1,27 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
<title id="title">GeoIntel Atlas</title>
<desc id="desc">Een geografische lens met contourlijnen, kompasrichting en locatiepunt.</desc>
<title id="title">GeoIntel</title>
<desc id="desc">Een geometrische G als geografische lens met een gemarkeerd coördinaatpunt.</desc>
<defs>
<linearGradient id="bg" x1="30" y1="18" x2="222" y2="238" gradientUnits="userSpaceOnUse">
<stop stop-color="#10564E"/>
<stop offset="1" stop-color="#062824"/>
<linearGradient id="surface" x1="28" y1="20" x2="226" y2="236" gradientUnits="userSpaceOnUse">
<stop stop-color="#123F3B"/>
<stop offset="1" stop-color="#061F20"/>
</linearGradient>
<linearGradient id="land" x1="69" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse">
<stop stop-color="#D9FFF7"/>
<stop offset="1" stop-color="#62D5C0"/>
<linearGradient id="geo" x1="68" y1="54" x2="190" y2="207" gradientUnits="userSpaceOnUse">
<stop stop-color="#B9FFF0"/>
<stop offset="1" stop-color="#48C9B1"/>
</linearGradient>
<filter id="shadow" x="-25%" y="-25%" width="150%" height="150%">
<feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#001C19" flood-opacity=".35"/>
<filter id="lift" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#001615" flood-opacity=".45"/>
</filter>
</defs>
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
<path d="M26 76C63 57 92 68 120 56s59-33 110-14M20 116c41-17 70-9 101-22s68-29 116-9M20 158c38-16 73-6 108-21s67-22 109-7M33 202c40-16 65-5 99-18s57-17 90-8" fill="none" stroke="#A9EBDD" stroke-opacity=".16" stroke-width="8" stroke-linecap="round"/>
<g filter="url(#shadow)">
<path d="M128 35c51 0 93 41 93 93 0 51-42 93-93 93s-93-42-93-93c0-52 42-93 93-93Z" fill="#082F2B" stroke="#86E4D2" stroke-width="9"/>
<path d="M128 58c38 0 70 31 70 70 0 38-32 70-70 70s-70-32-70-70c0-39 32-70 70-70Z" fill="none" stroke="#D8FFF7" stroke-opacity=".22" stroke-width="5" stroke-dasharray="2 12" stroke-linecap="round"/>
<path d="M86 156c18-8 29-4 44-11 16-8 24-22 43-23M79 135c19-8 33-4 48-12 16-8 24-21 42-24M83 113c13-6 27-4 39-10 15-8 25-19 38-22" fill="none" stroke="url(#land)" stroke-width="8" stroke-linecap="round"/>
<path d="M128 72v14M128 170v14M72 128h14M170 128h14" stroke="#E9FFF9" stroke-width="7" stroke-linecap="round"/>
<path d="m128 92 12 25 25 11-25 12-12 25-12-25-25-12 25-11 12-25Z" fill="#EFCB72" stroke="#FFF5CE" stroke-width="4" stroke-linejoin="round"/>
<circle cx="128" cy="128" r="9" fill="#082F2B" stroke="#FFF" stroke-width="4"/>
<rect width="256" height="256" rx="54" fill="url(#surface)"/>
<path d="M28 74c36-17 62-9 91-20 32-12 61-27 108-13M20 117c39-17 69-8 100-21 33-13 65-24 116-7M20 163c42-17 72-7 105-19 31-12 59-20 111-5M31 207c37-14 66-5 96-16 31-12 56-17 96-7" fill="none" stroke="#A6E8DC" stroke-opacity=".10" stroke-width="7" stroke-linecap="round"/>
<g filter="url(#lift)">
<path d="M179 78a72 72 0 1 0 9 83" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round"/>
<path d="M137 128h62v50" fill="none" stroke="url(#geo)" stroke-width="27" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="128" cy="128" r="23" fill="#082927" stroke="#DFFFF8" stroke-width="7"/>
<circle cx="128" cy="128" r="10" fill="#F2C864"/>
</g>
<path d="M203 52v18M194 61h18" stroke="#DFFFF8" stroke-opacity=".55" stroke-width="4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+6
View File
@@ -125,6 +125,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
loadProjectData,
createProject,
createArea,
activateMunicipality,
archiveProject,
resetProjectData,
setSelectedProjectId,
@@ -1014,6 +1015,11 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
selectedMapDatasetId={selectedDataset && availableMapDatasets.some((dataset) => dataset.id === selectedDataset.id) ? selectedDataset.id : ''}
selectedFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId}
onActivateMunicipality={async (niscode) => {
const area = await activateMunicipality(niscode)
if (area) setSelectedMapAreaId(area.id)
return area
}}
onSetContextSourceLabel={setMapContextSourceLabel}
onSetContextLayerLabel={setMapContextLayerLabel}
onOpenDatasetInMap={openDatasetInMap}
+12 -13
View File
@@ -762,6 +762,7 @@ interface MapWorkspaceProps {
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => void
onActivateMunicipality: (niscode: string) => Promise<AreaRead | null>
onSetContextSourceLabel: (label: string | null) => void
onSetContextLayerLabel: (label: string | null) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
@@ -855,6 +856,7 @@ export function MapWorkspace({
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
onActivateMunicipality,
onSetContextSourceLabel,
onSetContextLayerLabel,
onOpenDatasetInMap,
@@ -2178,7 +2180,7 @@ export function MapWorkspace({
<div>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
<h2>Gebied analyseren</h2>
<p>Kies een gemeente, selecteer een thema en analyseer het volledige gebied of een eigen rechthoek.</p>
<p>Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.</p>
</div>
<div className="geo-explorer-header-tools">
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
@@ -2225,10 +2227,10 @@ export function MapWorkspace({
</header>
<MunicipalitySearch
areas={areas}
selectedAreaId={selectedMapAreaId}
projectId={selectedProjectId}
activeArea={selectedMapArea ?? null}
disabled={workspaceLoading}
onSelect={handleSelectMapArea}
onActivate={onActivateMunicipality}
/>
{workspaceLoading ? (
@@ -2251,10 +2253,9 @@ export function MapWorkspace({
<div className="geo-explorer-layout">
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
<div className="geo-panel-heading">
<span>2</span>
<div>
<h3>Thema</h3>
<p>Kies welke gegevens u wilt meten.</p>
<h3>Focus op de kaart <small>optioneel</small></h3>
<p>Dit bepaalt de zichtbare laag en hoofdmeting; Inzichten controleert ook de overige beschikbare themas.</p>
</div>
</div>
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
@@ -2264,8 +2265,8 @@ export function MapWorkspace({
{workspaceLoading
? 'Gebieden en bronnen worden geladen'
: municipalityAreaCount > 0
? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar`
: 'Geen gemeentelijke onderverdeling in deze werkruimte'}
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
</small>
</div>
<div className="geo-theme-list">
@@ -2517,9 +2518,8 @@ export function MapWorkspace({
<div className="geo-map-stage">
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
<div className="geo-panel-heading geo-map-step">
<span>3</span>
<div>
<h3>Selecteer een gebied</h3>
<h3>Baken uw onderzoeksvraag af</h3>
<p>
{bboxSelectionMode
? 'Sleep nu een rechthoek op de kaart.'
@@ -2640,10 +2640,9 @@ export function MapWorkspace({
aria-live="polite"
>
<div className="geo-panel-heading">
<span>3</span>
<div>
<h3>Inzichten</h3>
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
<p>Gemeten resultaten en beschikbaarheid voor de volledige selectie.</p>
</div>
</div>
@@ -1,32 +1,36 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { areasApi } from '../../services/api/areas'
import { MunicipalitySearch } from './MunicipalitySearch'
import type { AreaRead } from '../../types'
const areas = [
{ id: 'mol', project_id: 'project', name: 'Gemeente Mol', geometry: { type: 'Polygon', coordinates: [] } },
{ id: 'gent', project_id: 'project', name: 'Gemeente Gent', geometry: { type: 'Polygon', coordinates: [] } },
{ id: 'flanders', project_id: 'project', name: 'Vlaanderen', geometry: { type: 'Polygon', coordinates: [] } },
] satisfies AreaRead[]
vi.mock('../../services/api/areas', () => ({
areasApi: { searchMunicipalities: vi.fn() },
}))
describe('MunicipalitySearch', () => {
beforeEach(() => {
vi.mocked(areasApi.searchMunicipalities).mockResolvedValue({
items: [{ niscode: '13025', name: 'Mol', name_nl: 'Mol', name_fr: 'Mol', name_de: 'Mol' }],
total: 1,
})
})
afterEach(() => cleanup())
it('selects an exact municipality while excluding regional areas', () => {
const onSelect = vi.fn()
render(<MunicipalitySearch areas={areas} selectedAreaId="" onSelect={onSelect} />)
it('searches the official municipality catalog and activates a result', async () => {
const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
const onActivate = vi.fn().mockResolvedValue(activated)
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={onActivate} />)
const input = screen.getByTestId('municipality-search-input')
fireEvent.change(input, { target: { value: 'Mol' } })
fireEvent.click(screen.getByRole('button', { name: 'Gemeente laden' }))
expect(onSelect).toHaveBeenCalledWith('mol')
expect(screen.getByText('2 gemeenten beschikbaar')).toBeTruthy()
fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
})
it('shows the active municipality without the technical prefix', () => {
render(<MunicipalitySearch areas={areas} selectedAreaId="gent" onSelect={vi.fn()} />)
expect(screen.getByDisplayValue('Gent')).toBeTruthy()
expect(screen.getByText('Gent actief')).toBeTruthy()
it('presents municipality search as optional and keeps free selection visible', () => {
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={vi.fn()} />)
expect(screen.getByText('optioneel')).toBeTruthy()
expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
})
})
@@ -1,53 +1,109 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { MapPin, Search } from 'lucide-react'
import type { AreaRead } from '../../types'
import { useEffect, useRef, useState } from 'react'
import { MapPin, Search, X } from 'lucide-react'
import { areasApi } from '../../services/api/areas'
import type { AreaRead, MunicipalitySearchItem } from '../../types'
interface MunicipalitySearchProps {
areas: AreaRead[]
selectedAreaId: string
projectId: string | null
activeArea: AreaRead | null
disabled?: boolean
onSelect: (areaId: string) => void
onActivate: (niscode: string) => Promise<AreaRead | null>
}
function municipalityLabel(area: AreaRead): string {
return area.name.replace(/^Gemeente\s+/i, '')
function areaDisplayName(area: AreaRead | null): string | null {
return area?.name.replace(/^Gemeente\s+/i, '').replace(/\s+-\s+NIS\s+\d+$/i, '') ?? null
}
export function MunicipalitySearch({ areas, selectedAreaId, disabled = false, onSelect }: MunicipalitySearchProps): JSX.Element {
const municipalities = useMemo(
() => areas.filter((area) => /^Gemeente\s/i.test(area.name)).sort((a, b) => a.name.localeCompare(b.name, 'nl-BE')),
[areas],
)
const selectedArea = municipalities.find((area) => area.id === selectedAreaId) ?? null
const [query, setQuery] = useState(selectedArea ? municipalityLabel(selectedArea) : '')
const exactMatch = municipalities.find(
(area) => municipalityLabel(area).toLocaleLowerCase('nl-BE') === query.trim().toLocaleLowerCase('nl-BE'),
)
export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
const [query, setQuery] = useState('')
const [results, setResults] = useState<MunicipalitySearchItem[]>([])
const [searching, setSearching] = useState(false)
const [activatingCode, setActivatingCode] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const searchSequence = useRef(0)
const activeMunicipality = areaDisplayName(activeArea)
useEffect(() => {
if (selectedArea) setQuery(municipalityLabel(selectedArea))
}, [selectedArea])
const normalized = query.trim()
const requestId = ++searchSequence.current
if (!projectId || normalized.length < 2) {
setResults([])
setSearching(false)
setError(null)
return
}
setSearching(true)
setError(null)
const timeout = window.setTimeout(() => {
void areasApi.searchMunicipalities(projectId, normalized)
.then((response) => {
if (requestId === searchSequence.current) setResults(response.items)
})
.catch((caught: unknown) => {
if (requestId === searchSequence.current) setError(caught instanceof Error ? caught.message : 'Gemeenten zoeken is niet gelukt')
})
.finally(() => {
if (requestId === searchSequence.current) setSearching(false)
})
}, 220)
return () => window.clearTimeout(timeout)
}, [projectId, query])
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (exactMatch) onSelect(exactMatch.id)
const activate = async (municipality: MunicipalitySearchItem) => {
setActivatingCode(municipality.niscode)
setError(null)
const area = await onActivate(municipality.niscode)
if (area) {
setQuery('')
setResults([])
} else {
setError('De officiële gemeentegrens kon niet worden geactiveerd.')
}
setActivatingCode(null)
}
return (
<form className="municipality-search" onSubmit={submit} aria-label="Gemeente zoeken">
<section className="municipality-search" aria-label="Optioneel een gemeente zoeken">
<div className="municipality-search-copy">
<span className="municipality-step"><MapPin aria-hidden="true" /> Start hier</span>
<div><strong>Kies een gemeente</strong><small>De grens en beschikbare gegevens worden meteen als werkgebied geladen.</small></div>
<span className="municipality-shortcut"><MapPin aria-hidden="true" /> Snelkeuze</span>
<div>
<strong>Ga naar een gemeente <small>optioneel</small></strong>
<p>Zoek een officiële grens, of teken straks vrij op de kaart.</p>
</div>
</div>
<label>
<span className="sr-only">Zoek gemeente</span><Search aria-hidden="true" />
<input type="search" list="municipality-options" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Typ bijvoorbeeld Mol, Gent of Namen" disabled={disabled || municipalities.length === 0} autoComplete="off" data-testid="municipality-search-input" />
</label>
<datalist id="municipality-options">
{municipalities.map((area) => <option key={area.id} value={municipalityLabel(area)} />)}
</datalist>
<button className="primary-action" type="submit" disabled={disabled || !exactMatch}>Gemeente laden</button>
<span className="municipality-search-status" aria-live="polite">{selectedArea ? `${municipalityLabel(selectedArea)} actief` : `${municipalities.length} gemeenten beschikbaar`}</span>
</form>
<div className="municipality-search-control">
<label>
<span className="sr-only">Zoek op gemeentenaam of NIS-code</span>
<Search aria-hidden="true" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Gemeentenaam of NIS-code"
disabled={disabled || !projectId}
autoComplete="off"
data-testid="municipality-search-input"
/>
{query ? <button type="button" className="municipality-search-clear" onClick={() => setQuery('')} aria-label="Zoekterm wissen"><X aria-hidden="true" /></button> : null}
</label>
{query.trim().length >= 2 ? (
<div className="municipality-search-results" aria-label="Gevonden gemeenten">
{searching ? <p role="status">Gemeenten zoeken</p> : null}
{!searching && results.length === 0 && !error ? <p>Geen officiële gemeente gevonden.</p> : null}
{results.map((municipality) => (
<button key={municipality.niscode} type="button" onClick={() => void activate(municipality)} disabled={activatingCode !== null}>
<span><strong>{municipality.name}</strong><small>NIS {municipality.niscode}</small></span>
<em>{activatingCode === municipality.niscode ? 'Laden…' : 'Gebruik grens'}</em>
</button>
))}
{error ? <p className="error" role="alert">{error}</p> : null}
</div>
) : null}
</div>
<div className="municipality-search-status">
<span>Actief werkgebied</span>
<strong>{activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}</strong>
</div>
</section>
)
}
+20
View File
@@ -217,6 +217,25 @@ export function useProjectWorkspace() {
}
}
const activateMunicipality = async (niscode: string): Promise<AreaRead | null> => {
if (!selectedProjectId) {
setErrorMessage('Kies eerst een werkruimte')
return null
}
setLoadingAreas(true)
setErrorMessage(null)
try {
const area = await areasApi.activateMunicipality(selectedProjectId, niscode)
await loadProjectData(selectedProjectId)
return area
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'De gemeente kon niet als werkgebied worden geladen')
return null
} finally {
setLoadingAreas(false)
}
}
const archiveProject = async (projectId: string) => {
setArchivingProjectId(projectId)
setErrorMessage(null)
@@ -257,6 +276,7 @@ export function useProjectWorkspace() {
loadProjectData,
createProject,
createArea,
activateMunicipality,
archiveProject,
resetProjectData,
setSelectedProjectId,
+5 -1
View File
@@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPatch } from './client'
import type { AreaCreate, AreaListResponse, AreaRead } from '../../types'
import type { AreaCreate, AreaListResponse, AreaRead, MunicipalitySearchResponse } from '../../types'
const AREA_PAGE_SIZE = 200
@@ -36,4 +36,8 @@ export const areasApi = {
apiGet<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`),
update: (projectId: string, areaId: string, payload: Partial<AreaCreate>): Promise<AreaRead> =>
apiPatch<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`, payload),
searchMunicipalities: (projectId: string, query: string): Promise<MunicipalitySearchResponse> =>
apiGet<MunicipalitySearchResponse>(`/api/v1/projects/${projectId}/areas/municipalities?query=${encodeURIComponent(query)}&limit=12`),
activateMunicipality: (projectId: string, niscode: string): Promise<AreaRead> =>
apiPost<AreaRead>(`/api/v1/projects/${projectId}/areas/municipalities/${encodeURIComponent(niscode)}/activate`, {}),
}
+34 -9
View File
@@ -998,7 +998,7 @@ body {
/* Senior UX audit: compact navigation, safe text containment and task-first map flow. */
@media (min-width: 1361px) {
.workbench-layout { grid-template-columns: 13.25rem minmax(0, 1fr); }
.workbench-layout { grid-template-columns: 11.5rem minmax(0, 1fr); }
}
.workbench-shell :where(.entity-card, .dataset-card, .quality-check-card, .system-provider-card, .data-selection-summary) { min-width: 0; }
@@ -1013,11 +1013,13 @@ body {
.geo-analysis-mode { align-items: stretch; grid-template-columns: repeat(2, minmax(6.5rem, 1fr)); }
.geo-analysis-mode button { display: grid; place-items: center; white-space: nowrap; }
.geo-explorer { grid-template-rows: auto auto minmax(0, 1fr) auto; }
.geo-explorer-layout { grid-template-columns: 14rem minmax(28rem, 1fr) 19rem; }
.municipality-search {
position: relative;
display: grid;
min-width: 0;
grid-template-columns: minmax(17rem, 0.75fr) minmax(17rem, 1fr) auto auto;
grid-template-columns: minmax(16rem, 0.85fr) minmax(18rem, 1.15fr) minmax(10rem, auto);
gap: 0.75rem;
align-items: center;
border-bottom: 1px solid var(--atlas-line);
@@ -1027,13 +1029,33 @@ body {
.municipality-search-copy { display: flex; min-width: 0; gap: 0.7rem; align-items: center; }
.municipality-search-copy > div { display: grid; min-width: 0; gap: 0.12rem; }
.municipality-search-copy strong { font-size: 0.78rem; }
.municipality-search-copy small { color: var(--atlas-muted); font-size: 0.62rem; overflow-wrap: anywhere; }
.municipality-step { display: inline-flex; flex: 0 0 auto; gap: 0.3rem; align-items: center; border-radius: 999px; padding: 0.35rem 0.52rem; background: var(--atlas-900); color: #fff; font-size: 0.57rem; font-weight: 750; text-transform: uppercase; }
.municipality-step svg { width: 0.8rem; height: 0.8rem; }
.municipality-search label { position: relative; min-width: 0; }
.municipality-search label > svg { position: absolute; top: 50%; left: 0.72rem; width: 0.95rem; height: 0.95rem; color: var(--atlas-muted); transform: translateY(-50%); pointer-events: none; }
.municipality-search-copy strong small { margin-left: 0.35rem; color: var(--atlas-600); font-size: 0.56rem; font-weight: 750; text-transform: uppercase; }
.municipality-search-copy p { margin: 0; color: var(--atlas-muted); font-size: 0.62rem; overflow-wrap: anywhere; }
.municipality-shortcut { display: inline-flex; flex: 0 0 auto; gap: 0.3rem; align-items: center; border-radius: 999px; padding: 0.35rem 0.52rem; background: #e0f3ee; color: var(--atlas-800); font-size: 0.57rem; font-weight: 750; text-transform: uppercase; }
.municipality-shortcut svg { width: 0.8rem; height: 0.8rem; }
.municipality-search-control { position: relative; min-width: 0; }
.municipality-search-control label { position: relative; display: block; min-width: 0; }
.municipality-search-control label > svg { position: absolute; top: 50%; left: 0.72rem; width: 0.95rem; height: 0.95rem; color: var(--atlas-muted); transform: translateY(-50%); pointer-events: none; }
.municipality-search input { width: 100%; min-width: 0; padding-left: 2.15rem; }
.municipality-search-status { color: var(--atlas-muted); font-size: 0.62rem; white-space: nowrap; }
.municipality-search-clear { position: absolute; top: 50%; right: 0.35rem; display: grid; width: 1.8rem; height: 1.8rem; min-height: 0; place-items: center; border: 0; border-radius: 50%; padding: 0; background: transparent; color: var(--atlas-muted); transform: translateY(-50%); }
.municipality-search-clear svg { width: 0.9rem; height: 0.9rem; }
.municipality-search-results { position: absolute; z-index: 70; top: calc(100% + 0.35rem); right: 0; left: 0; max-height: 18rem; overflow: auto; border: 1px solid var(--atlas-line-strong); border-radius: 0.75rem; padding: 0.35rem; background: #fff; box-shadow: var(--atlas-shadow-floating); }
.municipality-search-results > p { margin: 0; padding: 0.75rem; color: var(--atlas-muted); font-size: 0.7rem; }
.municipality-search-results > button { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; border: 0; border-radius: 0.55rem; padding: 0.65rem 0.7rem; background: transparent; text-align: left; }
.municipality-search-results > button:hover { background: var(--atlas-50); }
.municipality-search-results > button span { display: grid; min-width: 0; gap: 0.12rem; }
.municipality-search-results > button strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.municipality-search-results > button small { color: var(--atlas-muted); font-size: 0.58rem; }
.municipality-search-results > button em { flex: 0 0 auto; color: var(--atlas-700); font-size: 0.6rem; font-style: normal; font-weight: 750; }
.municipality-search-status { display: grid; min-width: 0; gap: 0.15rem; border-left: 1px solid var(--atlas-line); padding-left: 0.85rem; }
.municipality-search-status span { color: var(--atlas-muted); font-size: 0.56rem; text-transform: uppercase; }
.municipality-search-status strong { overflow: hidden; font-size: 0.7rem; text-overflow: ellipsis; white-space: nowrap; }
.geo-panel-heading h3 small { margin-left: 0.35rem; color: var(--atlas-600); font-size: 0.54rem; font-weight: 750; text-transform: uppercase; }
.geo-theme-option { height: auto; min-height: 4.35rem; align-items: start; }
.geo-theme-option > span:nth-child(2) { min-width: 0; align-self: start; }
.geo-theme-option small { display: -webkit-box; overflow: hidden; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.25; }
.geo-theme-option i { align-self: center; white-space: nowrap; }
.quality-user-empty-state .button-row { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.9rem; }
.system-command-surface { display: grid; grid-template-columns: repeat(2, minmax(10rem, 0.5fr)) minmax(22rem, 1.4fr); gap: 0.75rem; align-items: center; margin-bottom: 0.9rem; border: 1px solid var(--atlas-line); border-radius: var(--atlas-radius-md); padding: 0.9rem; background: var(--atlas-50); }
@@ -1043,7 +1065,7 @@ body {
.system-command-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-end; }
@media (max-width: 1180px) and (min-width: 921px) {
.municipality-search { grid-template-columns: minmax(13rem, 0.8fr) minmax(15rem, 1fr) auto; }
.municipality-search { grid-template-columns: minmax(13rem, 0.8fr) minmax(15rem, 1fr); }
.municipality-search-status { display: none; }
}
@media (max-width: 920px) {
@@ -1060,6 +1082,9 @@ body {
.municipality-search { grid-template-columns: 1fr; }
.municipality-search-copy { grid-column: auto; }
.municipality-search button { width: 100%; }
.municipality-search-clear { width: 1.8rem; }
.geo-theme-option { grid-template-columns: 0.3rem minmax(0, 1fr); min-height: 4rem; }
.geo-theme-option i { grid-column: 2; justify-self: start; margin-top: 0.2rem; }
.system-command-surface { grid-template-columns: 1fr; }
.system-command-actions { grid-column: auto; }
}
+13
View File
@@ -76,6 +76,19 @@ export interface AreaListResponse {
offset: number
}
export interface MunicipalitySearchItem {
niscode: string
name: string
name_nl?: string | null
name_fr?: string | null
name_de?: string | null
}
export interface MunicipalitySearchResponse {
items: MunicipalitySearchItem[]
total: number
}
export interface DatasetCreateResponse {
id: string
name: string