fix: query persisted work area geometry
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 19:38:41 +02:00
parent a879c74b12
commit 616424e2a2
9 changed files with 179 additions and 46 deletions
+25 -12
View File
@@ -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))
+4
View File
@@ -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
+31 -24
View File
@@ -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
@@ -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
+4 -1
View File
@@ -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.
+9 -8
View File
@@ -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<VectorSelectionResponse | null>
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
onExportMapSelection: (bbox: VectorSelectionBBox) => Promise<unknown>
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => Promise<DatasetCreateResponse | null>
@@ -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<Promise<unknown>> = [onRunMapSelectionExtract(bbox), loadAllThemeResults(bbox)]
const tasks: Array<Promise<unknown>> = [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({
<label className="geo-scope-select">
Werkgebied
<select value={selectedMapAreaId} onChange={(event) => onSelectMapArea(event.target.value)} disabled={areas.length === 0}>
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => onSelectMapArea(event.target.value)} disabled={areas.length === 0}>
<option value="">Geen werkgebied</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
@@ -1092,7 +1092,7 @@ export function MapWorkspace({
className="secondary-action"
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox)}
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
>
Volledig werkgebied
</button>
@@ -1387,6 +1387,7 @@ export function MapWorkspace({
<label>
Area
<select
aria-label="Werkgebied"
value={selectedMapAreaId}
onChange={(event) => onSelectMapArea(event.target.value)}
disabled={areas.length === 0}
+2 -1
View File
@@ -28,7 +28,7 @@ export function useMapSelectionExtract({
setMapSelectionError(null)
}, [selectedProjectId, selectedDataset?.id])
const runMapSelectionExtract = async (bbox: VectorSelectionBBox) => {
const runMapSelectionExtract = async (bbox: VectorSelectionBBox, areaId?: string) => {
if (!selectedProjectId || !selectedDataset) {
setMapSelectionError('Open a vector dataset before extracting a map area.')
return null
@@ -44,6 +44,7 @@ export function useMapSelectionExtract({
try {
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
bbox: { ...bbox, crs: 'EPSG:4326' },
area_id: areaId,
limit: 1000,
})
setMapSelectionResult(response)
@@ -32,6 +32,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
const loadThemeInsights = async (
bbox: VectorSelectionBBox,
queries: Array<MapThemeQuery<TThemeId>>,
areaId?: string,
): Promise<Array<MapThemeInsight<TThemeId>>> => {
if (!selectedProjectId) {
setThemeInsights([])
@@ -48,6 +49,7 @@ export function useMapThemeSelectionInsights<TThemeId extends string>(
dataset,
result: await datasetsApi.selectVectorFeatures(selectedProjectId, dataset.id, {
bbox,
area_id: areaId,
limit: 1000,
}),
})),
+2
View File
@@ -305,6 +305,7 @@ export interface MapViewportState {
export interface VectorSelectionRequest {
bbox: VectorSelectionBBox
area_id?: string
limit?: number
}
@@ -314,6 +315,7 @@ export interface VectorSelectionDeriveRequest extends VectorSelectionRequest {
export interface VectorSelectionResponse {
selection_bbox: VectorSelectionBBox
selection_area_id?: string | null
feature_count: number
total_feature_count?: number | null
limit: number