From 616424e2a283da57a883faa8063af12cd8fd1e04 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 19:38:41 +0200 Subject: [PATCH] fix: query persisted work area geometry --- backend/app/api/routes/datasets.py | 37 ++++--- backend/app/schemas/operations.py | 4 + .../app/services/vector_feature_service.py | 55 +++++----- .../tests/test_sprint106_map_bbox_extract.py | 100 ++++++++++++++++++ docs/API_CONTRACTS.md | 5 +- frontend/src/components/map/MapWorkspace.tsx | 17 +-- frontend/src/hooks/useMapSelectionExtract.ts | 3 +- .../src/hooks/useMapThemeSelectionInsights.ts | 2 + frontend/src/types.ts | 2 + 9 files changed, 179 insertions(+), 46 deletions(-) diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index 73478131..ea84c3b1 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -237,19 +237,32 @@ def select_vector_features( raise HTTPException(status_code=404, detail="Dataset not found") if dataset.dataset_type not in {"vector", "geojson"}: raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400) - result = VectorFeatureService.select_features_by_bbox( - db, - dataset_id=dataset_id, - bbox=payload.bbox.model_dump(), - limit=payload.limit, - ) - if isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"): - result["summary"] = VectorFeatureService.summarize_features_by_bbox( - db, - dataset=dataset, - bbox=payload.bbox.model_dump(), - total_feature_count=result.get("total_feature_count"), + selection_area = None + if payload.area_id is not None: + selection_area = db.get(Area, payload.area_id) + if selection_area is None or selection_area.project_id != project_id: + raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) + + selection_kwargs = { + "dataset_id": dataset_id, + "bbox": payload.bbox.model_dump(), + "limit": payload.limit, + } + if selection_area is not None: + selection_kwargs.update( + selection_geometry=selection_area.geometry, + selection_area_id=selection_area.id, ) + result = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs) + if isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"): + summary_kwargs = { + "dataset": dataset, + "bbox": payload.bbox.model_dump(), + "total_feature_count": result.get("total_feature_count"), + } + if selection_area is not None: + summary_kwargs["selection_geometry"] = selection_area.geometry + result["summary"] = VectorFeatureService.summarize_features_by_bbox(db, **summary_kwargs) return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True)) diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py index 7a8cae01..0246cfd9 100644 --- a/backend/app/schemas/operations.py +++ b/backend/app/schemas/operations.py @@ -1,5 +1,7 @@ from __future__ import annotations +from uuid import UUID + from pydantic import BaseModel, Field, field_validator @@ -210,6 +212,7 @@ class VectorSelectionBBox(BaseModel): class VectorSelectionRequest(BaseModel): bbox: VectorSelectionBBox + area_id: UUID | None = None limit: int = Field(default=100, ge=1, le=1000) @@ -229,6 +232,7 @@ class VectorSelectionSummary(BaseModel): class VectorSelectionResponse(BaseModel): selection_bbox: VectorSelectionBBox + selection_area_id: UUID | None = None feature_count: int total_feature_count: int | None = None limit: int diff --git a/backend/app/services/vector_feature_service.py b/backend/app/services/vector_feature_service.py index b423c9a1..56d7e00d 100644 --- a/backend/app/services/vector_feature_service.py +++ b/backend/app/services/vector_feature_service.py @@ -124,25 +124,25 @@ class VectorFeatureService: bbox: dict[str, Any], limit: int = 100, dataset: Dataset | None = None, + selection_geometry: Any | None = None, + selection_area_id: UUID | None = None, ) -> dict[str, Any]: normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox) safe_limit = max(1, min(int(limit), 1000)) + selection_shape = selection_geometry + if selection_shape is None: + selection_shape = ST_MakeEnvelope( + normalized_bbox["min_x"], + normalized_bbox["min_y"], + normalized_bbox["max_x"], + normalized_bbox["max_y"], + 4326, + ) query = ( db.query(VectorFeature) .filter(VectorFeature.dataset_id == dataset_id) - .filter( - ST_Intersects( - VectorFeature.geometry, - ST_MakeEnvelope( - normalized_bbox["min_x"], - normalized_bbox["min_y"], - normalized_bbox["max_x"], - normalized_bbox["max_y"], - 4326, - ), - ) - ) + .filter(ST_Intersects(VectorFeature.geometry, selection_shape)) ) if hasattr(query, "count"): total_feature_count = int(query.count()) @@ -164,9 +164,10 @@ class VectorFeatureService: dataset=dataset, bbox=normalized_bbox, total_feature_count=total_feature_count, + selection_geometry=selection_geometry, ) - return { + result = { "selection_bbox": normalized_bbox, "feature_count": len(features), "total_feature_count": total_feature_count, @@ -178,6 +179,9 @@ class VectorFeatureService: }, "summary": summary, } + if selection_area_id is not None: + result["selection_area_id"] = str(selection_area_id) + return result @staticmethod def summarize_features_by_bbox( @@ -186,18 +190,21 @@ class VectorFeatureService: dataset: Dataset, bbox: dict[str, Any], total_feature_count: int | None = None, + selection_geometry: Any | None = None, ) -> dict[str, Any]: normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox) - envelope = ST_MakeEnvelope( - normalized_bbox["min_x"], - normalized_bbox["min_y"], - normalized_bbox["max_x"], - normalized_bbox["max_y"], - 4326, - ) + selection_shape = selection_geometry + if selection_shape is None: + selection_shape = ST_MakeEnvelope( + normalized_bbox["min_x"], + normalized_bbox["min_y"], + normalized_bbox["max_x"], + normalized_bbox["max_y"], + 4326, + ) selection_filter = ( VectorFeature.dataset_id == dataset.id, - ST_Intersects(VectorFeature.geometry, envelope), + ST_Intersects(VectorFeature.geometry, selection_shape), ) feature_count = total_feature_count if feature_count is None: @@ -215,13 +222,13 @@ class VectorFeatureService: metric_value = float(feature_count) if method == "intersection_area": - intersection = func.ST_Intersection(VectorFeature.geometry, envelope) + intersection = func.ST_Intersection(VectorFeature.geometry, selection_shape) area_expression = func.ST_Area(func.ST_Transform(intersection, 31370)) area_m2 = db.query(func.coalesce(func.sum(area_expression), 0.0)).filter(*selection_filter).scalar() divisor = 10_000.0 if unit == "ha" else 1.0 metric_value = float(area_m2 or 0.0) / divisor elif method == "intersection_length": - intersection = func.ST_Intersection(VectorFeature.geometry, envelope) + intersection = func.ST_Intersection(VectorFeature.geometry, selection_shape) length_expression = func.ST_Length(func.ST_Transform(intersection, 31370)) length_m = db.query(func.coalesce(func.sum(length_expression), 0.0)).filter(*selection_filter).scalar() divisor = 1_000.0 if unit == "km" else 1.0 @@ -240,7 +247,7 @@ class VectorFeatureService: if method == "area_weighted_sum": source_area = func.ST_Area(func.ST_Transform(VectorFeature.geometry, 31370)) intersection_area = func.ST_Area( - func.ST_Transform(func.ST_Intersection(VectorFeature.geometry, envelope), 31370) + func.ST_Transform(func.ST_Intersection(VectorFeature.geometry, selection_shape), 31370) ) coverage_ratio = intersection_area / func.nullif(source_area, 0.0) value_expression = numeric_value * coverage_ratio diff --git a/backend/tests/test_sprint106_map_bbox_extract.py b/backend/tests/test_sprint106_map_bbox_extract.py index 5582a0ef..e5dcb6cb 100644 --- a/backend/tests/test_sprint106_map_bbox_extract.py +++ b/backend/tests/test_sprint106_map_bbox_extract.py @@ -162,11 +162,108 @@ def test_vector_select_route_rejects_non_vector_dataset(monkeypatch) -> None: raise AssertionError("Expected DATASET_NOT_VECTOR") +def test_vector_select_route_uses_persisted_area_geometry_when_requested(monkeypatch) -> None: + from app.api.routes import datasets as dataset_routes + + project_id = uuid.uuid4() + dataset_id = uuid.uuid4() + area_id = uuid.uuid4() + dataset = Dataset( + id=dataset_id, + project_id=project_id, + dataset_type="vector", + source="fixture", + name="Regional vector", + source_metadata={"selection_aggregation": {"method": "feature_count"}}, + ) + area_geometry = object() + area = SimpleNamespace(id=area_id, project_id=project_id, geometry=area_geometry) + captured: dict[str, object] = {} + + class _AreaSession: + @staticmethod + def get(model, selected_id): # noqa: ANN001 + assert model is dataset_routes.Area + assert selected_id == area_id + return area + + def select_features(db, **kwargs): # noqa: ANN001 + captured["select"] = kwargs + return { + "selection_bbox": {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"}, + "selection_area_id": str(area_id), + "feature_count": 1, + "total_feature_count": 1, + "limit": 100, + "truncated": False, + "geojson": {"type": "FeatureCollection", "features": []}, + } + + def summarize_features(db, **kwargs): # noqa: ANN001 + captured["summary"] = kwargs + return { + "metric_label": "Gebouwen", + "metric_value": 1, + "metric_unit": "objecten", + "aggregation_method": "feature_count", + "feature_count": 1, + "is_estimate": False, + } + + monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset) + monkeypatch.setattr(dataset_routes.VectorFeatureService, "select_features_by_bbox", select_features) + monkeypatch.setattr(dataset_routes.VectorFeatureService, "summarize_features_by_bbox", summarize_features) + + response = dataset_routes.select_vector_features( + project_id=project_id, + dataset_id=dataset_id, + payload=dataset_routes.VectorSelectionRequest( + bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2), + area_id=area_id, + limit=100, + ), + db=_AreaSession(), + ) + + assert str(response["data"]["selection_area_id"]) == str(area_id) + assert captured["select"]["selection_geometry"] is area_geometry + assert captured["select"]["selection_area_id"] == area_id + assert captured["summary"]["selection_geometry"] is area_geometry + + +def test_vector_select_route_rejects_area_from_another_project(monkeypatch) -> None: + from app.api.routes import datasets as dataset_routes + + project_id = uuid.uuid4() + dataset_id = uuid.uuid4() + area_id = uuid.uuid4() + dataset = Dataset(id=dataset_id, project_id=project_id, dataset_type="vector", source="fixture", name="Vector") + other_area = SimpleNamespace(id=area_id, project_id=uuid.uuid4(), geometry=object()) + monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset) + + try: + dataset_routes.select_vector_features( + project_id=project_id, + dataset_id=dataset_id, + payload=dataset_routes.VectorSelectionRequest( + bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2), + area_id=area_id, + ), + db=SimpleNamespace(get=lambda model, selected_id: other_area), + ) + except AppError as exc: + assert exc.code == "AREA_NOT_FOUND" + else: # pragma: no cover + raise AssertionError("Expected AREA_NOT_FOUND") + + def test_frontend_exposes_map_bbox_selection_contracts() -> None: api_client = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8") map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8") app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + extract_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8") + theme_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts").read_text(encoding="utf-8") assert "selectVectorFeatures" in api_client assert "Area selection" in map_workspace @@ -177,3 +274,6 @@ def test_frontend_exposes_map_bbox_selection_contracts() -> None: assert "selection-bbox" in geomap assert "selection-result" in geomap assert "useMapSelectionExtract" in app + assert "area_id: areaId" in extract_hook + assert "area_id: areaId" in theme_hook + assert "analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in map_workspace diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index b24b1959..c6a67e4e 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -372,6 +372,7 @@ Request: "max_y": 51.1, "crs": "EPSG:4326" }, + "area_id": "optional persisted area UUID", "limit": 250 } ``` @@ -388,6 +389,7 @@ Response: "max_y": 51.1, "crs": "EPSG:4326" }, + "selection_area_id": "present when area_id was requested", "feature_count": 2, "total_feature_count": 2, "limit": 250, @@ -404,8 +406,9 @@ Rules: - Only vector/GeoJSON datasets are supported. - Coordinates are EPSG:4326 longitude/latitude. +- `area_id` is optional and must belong to the route project. When present, the bbox remains the bounded preview extent but PostGIS filtering and configured aggregations use the persisted Area geometry exactly. This prevents a municipal or regional full-work-area query from counting objects in the surrounding bbox corners. - Results are generated from persisted PostGIS `vector_features`, not from client-side map data. -- `feature_count` is the number of GeoJSON features returned in the bounded preview. `total_feature_count` is the exact number of persisted rows intersecting the bbox. +- `feature_count` is the number of GeoJSON features returned in the bounded preview. `total_feature_count` is the exact number of persisted rows intersecting the requested bbox or persisted Area geometry. - The response is capped by `limit` and returns `truncated=true` when `total_feature_count` exceeds the returned preview. - `limit` is bounded to `1..1000`. Municipality-scale clients must page spatially by viewport instead of requesting an unbounded municipality FeatureCollection. - The Map workspace uses this existing endpoint for vector datasets above 5,000 features. It starts delivery at zoom level 14, debounces `moveend` requests and explicitly reports `truncated=true` as a request to zoom further in. This is a client delivery policy, not a second API or persistence path. diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 8a851831..a6c1bec9 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -453,7 +453,7 @@ interface MapWorkspaceProps { onSelectMapFeature: (feature: GeoJSON.Feature | null) => void onMapViewportChange: (viewport: MapViewportState) => void onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void - onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => Promise + onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise onClearMapSelectionExtract: () => void onExportMapSelection: (bbox: VectorSelectionBBox) => Promise onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => Promise @@ -804,17 +804,17 @@ export function MapWorkspace({ clearTemporalComparison() } - const loadAllThemeResults = async (bbox: VectorSelectionBBox) => { + const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => { const availableThemes = DATA_THEMES.flatMap((theme) => { const dataset = themeDatasetMap[theme.id] return dataset ? [{ themeId: theme.id, dataset }] : [] }) - await loadThemeInsights(bbox, availableThemes) + await loadThemeInsights(bbox, availableThemes, areaId) } - const analyzeSelection = async (bbox: VectorSelectionBBox) => { + const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { setSelectionBbox(bbox) - const tasks: Array> = [onRunMapSelectionExtract(bbox), loadAllThemeResults(bbox)] + const tasks: Array> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)] if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) { tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox)) } @@ -844,7 +844,7 @@ export function MapWorkspace({ return } setSelectionBbox(bbox) - void analyzeSelection(bbox) + void analyzeSelection(bbox, selectedAreaBbox ? selectedMapArea?.id : undefined) } const runFullGisWorkflow = async () => { @@ -1061,7 +1061,7 @@ export function MapWorkspace({