diff --git a/backend/app/api/routes/areas.py b/backend/app/api/routes/areas.py index 08f708c6..c2637df5 100644 --- a/backend/app/api/routes/areas.py +++ b/backend/app/api/routes/areas.py @@ -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, diff --git a/backend/app/schemas/area.py b/backend/app/schemas/area.py index 049a59de..084e0102 100644 --- a/backend/app/schemas/area.py +++ b/backend/app/schemas/area.py @@ -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 diff --git a/backend/app/services/area_service.py b/backend/app/services/area_service.py index f809476b..2eaa572c 100644 --- a/backend/app/services/area_service.py +++ b/backend/app/services/area_service.py @@ -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 diff --git a/backend/tests/test_sprint242_municipality_activation.py b/backend/tests/test_sprint242_municipality_activation.py new file mode 100644 index 00000000..f22f87eb --- /dev/null +++ b/backend/tests/test_sprint242_municipality_activation.py @@ -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" diff --git a/deploy/unraid/geointel-icon.png b/deploy/unraid/geointel-icon.png index 3284db52..71258cf0 100644 Binary files a/deploy/unraid/geointel-icon.png and b/deploy/unraid/geointel-icon.png differ diff --git a/deploy/unraid/geointel-icon.svg b/deploy/unraid/geointel-icon.svg index 4139a1d6..3ffc9759 100644 --- a/deploy/unraid/geointel-icon.svg +++ b/deploy/unraid/geointel-icon.svg @@ -1,6 +1,6 @@ - GeoIntel Atlas - Een geografische lens met contourlijnen, kompasrichting en locatiepunt. - - + GeoIntel + Een geometrische G als geografische lens met een gemarkeerd coördinaatpunt. + + diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 2c21a568..02b70e22 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 86ecb5ce..0cc99684 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index ef8cb7de..a1b99163 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/frontend/public/geointel-icon-180.png b/frontend/public/geointel-icon-180.png index 3fc0c3a7..b034f694 100644 Binary files a/frontend/public/geointel-icon-180.png and b/frontend/public/geointel-icon-180.png differ diff --git a/frontend/public/geointel-icon-32.png b/frontend/public/geointel-icon-32.png index 15e2f986..ce162b53 100644 Binary files a/frontend/public/geointel-icon-32.png and b/frontend/public/geointel-icon-32.png differ diff --git a/frontend/public/geointel-icon.png b/frontend/public/geointel-icon.png index 3284db52..71258cf0 100644 Binary files a/frontend/public/geointel-icon.png and b/frontend/public/geointel-icon.png differ diff --git a/frontend/public/geointel-icon.svg b/frontend/public/geointel-icon.svg index 32a026fa..fe84680f 100644 --- a/frontend/public/geointel-icon.svg +++ b/frontend/public/geointel-icon.svg @@ -1,27 +1,26 @@ - GeoIntel Atlas - Een geografische lens met contourlijnen, kompasrichting en locatiepunt. + GeoIntel + Een geometrische G als geografische lens met een gemarkeerd coördinaatpunt. - - - + + + - - - + + + - - + + - - - - - - - - - + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 986f3b61..2e5c6dee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 72aef738..b615490c 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -762,6 +762,7 @@ interface MapWorkspaceProps { availableMapDatasets: DatasetCreateResponse[] selectedMapDatasetId: string onSelectMapArea: (areaId: string) => void + onActivateMunicipality: (niscode: string) => Promise 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({

{activeScopeLabel} · geografische verkenner

Gebied analyseren

-

Kies een gemeente, selecteer een thema en analyseer het volledige gebied of een eigen rechthoek.

+

Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.

@@ -2225,10 +2227,10 @@ export function MapWorkspace({ {workspaceLoading ? ( @@ -2251,10 +2253,9 @@ export function MapWorkspace({