From 0baa9b069c7246db0f22df4014a94506d3d47c95 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 06:07:34 +0200 Subject: [PATCH] feat: add semantic GIS selection metrics --- CHANGELOG.md | 14 ++ backend/README.md | 9 + backend/app/api/routes/datasets.py | 2 +- backend/app/schemas/__init__.py | 2 + backend/app/schemas/operations.py | 12 ++ .../app/services/vector_feature_service.py | 190 +++++++++++++++++- ...st_sprint201_semantic_selection_metrics.py | 128 ++++++++++++ docs/API_CONTRACTS.md | 39 ++++ docs/CODEX_EXECUTION_LOG.md | 32 +++ docs/TODO.md | 2 + frontend/README.md | 13 +- frontend/src/components/map/MapWorkspace.tsx | 24 ++- frontend/src/styles/app.css | 27 +++ frontend/src/types.ts | 12 ++ scripts/provision_regional_grb_buildings.py | 8 +- scripts/provision_regional_grb_context.py | 28 ++- 16 files changed, 520 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_sprint201_semantic_selection_metrics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8db91e..4705b203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ # Changelog +## Sprint 201 Semantic area-selection metrics (2026-07-15) + +- Replaced count-only primary results for known regional themes with meaningful + PostGIS measurements: building footprint, forest, water and parcel area in + hectares; road and watercourse length in kilometres; and population in + inhabitants. +- Kept intersecting feature counts as supporting evidence and added an additive + metric list without removing the existing primary summary fields. +- Added explicit source limitations: building footprint is not floor area or + volume, road length is not traffic capacity and water volume is unavailable + without reliable depth or bathymetry. +- Updated future regional GRB provisioning metadata so new imports persist the + semantic primary aggregation directly. + ## Sprint 200 Operational time-series handoff (2026-07-15) - Removed the dead-end Evolution state that appeared when the current building diff --git a/backend/README.md b/backend/README.md index 3ef0728d..f3161317 100644 --- a/backend/README.md +++ b/backend/README.md @@ -947,6 +947,15 @@ frontend requests at most 1,000 features for the current viewport and surfaces the response `truncated` flag; the backend does not provide or imply an unbounded municipality-wide map response. +Selection summaries expose a primary metric plus an additive `metrics` list. +Known persisted themes are aggregated in `EPSG:31370`: building footprints, +forest, water surfaces and parcels return hectares; roads and linear +watercourses return kilometres; population keeps its configured inhabitant +aggregation. Intersecting feature counts remain available as supporting +evidence. Water volume is deliberately unavailable because the current GRB +source has no reliable depth/bathymetry dimension; GeoIntel does not manufacture +volume from 2D polygons. + `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive` uses the same persisted `vector_features` selection but writes the result as a new derived vector dataset. The created dataset uses diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index d1fd3144..49da8350 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -275,7 +275,7 @@ def select_vector_features( full_dataset_area=full_dataset_area, ) result = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs) - if isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"): + if VectorFeatureService.supports_selection_summary(dataset): summary_kwargs = { "dataset": dataset, "bbox": payload.bbox.model_dump(), diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4c915258..0802c5bf 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -79,6 +79,7 @@ from .operations import ( VectorSelectionDeriveRequest, VectorSelectionRequest, VectorSelectionResponse, + VectorSelectionMetric, VectorSelectionSummary, VectorStatsRequest, VectorStatsResponse, @@ -143,6 +144,7 @@ __all__ = [ "VectorSelectionDeriveRequest", "VectorSelectionRequest", "VectorSelectionResponse", + "VectorSelectionMetric", "VectorSelectionSummary", "RasterClipRequest", "RasterStatsResponse", diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py index 0246cfd9..2e248d18 100644 --- a/backend/app/schemas/operations.py +++ b/backend/app/schemas/operations.py @@ -220,14 +220,26 @@ class VectorSelectionDeriveRequest(VectorSelectionRequest): output_name: str | None = None +class VectorSelectionMetric(BaseModel): + metric_key: str + metric_label: str + metric_value: float + metric_unit: str + aggregation_method: str + is_estimate: bool = False + warning: str | None = None + + class VectorSelectionSummary(BaseModel): metric_label: str metric_value: float metric_unit: str aggregation_method: str + primary_metric_key: str | None = None feature_count: int is_estimate: bool = False warning: str | None = None + metrics: list[VectorSelectionMetric] = Field(default_factory=list) class VectorSelectionResponse(BaseModel): diff --git a/backend/app/services/vector_feature_service.py b/backend/app/services/vector_feature_service.py index c6de273d..ff300996 100644 --- a/backend/app/services/vector_feature_service.py +++ b/backend/app/services/vector_feature_service.py @@ -26,7 +26,110 @@ FULL_AREA_CLIPPED_OPERATOR_TOOLS = { } +SEMANTIC_SELECTION_METRICS: dict[str, tuple[dict[str, Any], ...]] = { + "buildings": ( + { + "metric_key": "footprint_area", + "method": "intersection_area", + "label": "Bebouwde grondoppervlakte", + "unit": "ha", + "geometry_dimension": 2, + "warning": "Dit is de grondoppervlakte van gebouwcontouren, niet de totale vloeroppervlakte of het gebouwvolume.", + }, + ), + "forest": ( + { + "metric_key": "forest_area", + "method": "intersection_area", + "label": "Bosoppervlakte", + "unit": "ha", + "geometry_dimension": 2, + }, + ), + "water": ( + { + "metric_key": "water_area", + "method": "intersection_area", + "label": "Wateroppervlakte", + "unit": "ha", + "geometry_dimension": 2, + "warning": "Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens. De kaartbron levert alleen oppervlakte- en lijngeometrie.", + }, + { + "metric_key": "watercourse_length", + "method": "intersection_length", + "label": "Lengte waterlopen", + "unit": "km", + "geometry_dimension": 1, + }, + ), + "roads": ( + { + "metric_key": "road_length", + "method": "intersection_length", + "label": "Totale weglengte", + "unit": "km", + "geometry_dimension": 1, + "warning": "De lengte volgt de GRB-wegsegmenten en zegt niets over rijstroken, verkeersvolume of verhardingsoppervlakte.", + }, + ), + "parcels": ( + { + "metric_key": "parcel_area", + "method": "intersection_area", + "label": "Perceeloppervlakte", + "unit": "ha", + "geometry_dimension": 2, + "warning": "GRB-percelen zijn een grafische referentie en vormen geen juridische grensopmeting.", + }, + ), +} + +SEMANTIC_COUNT_LABELS = { + "buildings": "Gebouwen", + "population": "Statistische sectoren", + "forest": "Bosvlakken", + "water": "Waterobjecten", + "roads": "Wegsegmenten", + "parcels": "Percelen", +} + + class VectorFeatureService: + @staticmethod + def _dataset_theme(dataset: Dataset) -> str | None: + source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {} + candidates = ( + source_metadata.get("theme"), + dataset.reference_layer_name, + source_metadata.get("layer_type"), + ) + aliases = { + "building": "buildings", + "bebouwing": "buildings", + "population": "population", + "forest": "forest", + "forestry": "forest", + "waterways": "water", + "road": "roads", + "parcel": "parcels", + } + for candidate in candidates: + if not isinstance(candidate, str) or not candidate.strip(): + continue + normalized = candidate.strip().lower() + if normalized.startswith("regional_"): + normalized = normalized.removeprefix("regional_") + normalized = aliases.get(normalized, normalized) + if normalized in {*SEMANTIC_SELECTION_METRICS, "population"}: + return normalized + return None + + @staticmethod + def supports_selection_summary(dataset: Dataset) -> bool: + source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {} + return isinstance(source_metadata.get("selection_aggregation"), dict) or VectorFeatureService._dataset_theme(dataset) is not None + @staticmethod def can_use_full_area_fast_path(dataset: Dataset, selection_area_id: UUID | None) -> bool: if selection_area_id is None or dataset.area_id != selection_area_id: @@ -175,7 +278,7 @@ class VectorFeatureService: selected_rows = rows[:safe_limit] features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows] summary = None - if dataset and isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"): + if dataset and VectorFeatureService.supports_selection_summary(dataset): summary = VectorFeatureService.summarize_features_by_bbox( db, dataset=dataset, @@ -232,13 +335,86 @@ class VectorFeatureService: config = source_metadata.get("selection_aggregation") if not isinstance(config, dict): config = {} + theme = VectorFeatureService._dataset_theme(dataset) + configured_metric = { + "metric_key": str(config.get("metric_key") or config.get("method") or "feature_count"), + "method": str(config.get("method") or "feature_count"), + "label": str(config.get("label") or SEMANTIC_COUNT_LABELS.get(theme or "", "Objecten")), + "unit": str(config.get("unit") or "objecten"), + "warning": str(config["warning"]) if config.get("warning") else None, + "is_estimate": bool(config.get("is_estimate", False)), + **({"property": config.get("property")} if config.get("property") else {}), + } + semantic_metrics = [dict(metric) for metric in SEMANTIC_SELECTION_METRICS.get(theme or "", ())] + primary_config = configured_metric + if configured_metric["method"] == "feature_count" and semantic_metrics: + primary_config = semantic_metrics[0] + + metric_configs = [primary_config] + for semantic_metric in semantic_metrics: + signature = (semantic_metric["method"], semantic_metric["unit"]) + existing = { + (item["method"], item["unit"]) + for item in metric_configs + } + if signature not in existing: + metric_configs.append(semantic_metric) + if not any(item["method"] == "feature_count" for item in metric_configs): + metric_configs.append( + { + "metric_key": "feature_count", + "method": "feature_count", + "label": SEMANTIC_COUNT_LABELS.get(theme or "", "Objecten"), + "unit": "objecten", + } + ) + + metrics = [ + VectorFeatureService._calculate_selection_metric( + db, + dataset=dataset, + config=metric_config, + selection_filter=selection_filter, + selection_shape=selection_shape, + feature_count=feature_count, + full_dataset_area=full_dataset_area, + ) + for metric_config in metric_configs + ] + primary_metric = metrics[0] + return { + "metric_label": primary_metric["metric_label"], + "metric_value": primary_metric["metric_value"], + "metric_unit": primary_metric["metric_unit"], + "aggregation_method": primary_metric["aggregation_method"], + "primary_metric_key": primary_metric["metric_key"], + "feature_count": feature_count, + "is_estimate": primary_metric["is_estimate"], + "warning": primary_metric.get("warning"), + "metrics": metrics, + } + + @staticmethod + def _calculate_selection_metric( + db, + *, + dataset: Dataset, + config: dict[str, Any], + selection_filter: tuple[Any, ...], + selection_shape: Any, + feature_count: int, + full_dataset_area: bool, + ) -> dict[str, Any]: method = str(config.get("method") or "feature_count") - label = str(config.get("label") or "Objecten") unit = str(config.get("unit") or "objecten") warning = str(config["warning"]) if config.get("warning") else None is_estimate = bool(config.get("is_estimate", False)) - metric_value = float(feature_count) + dimension = config.get("geometry_dimension") + metric_filter = selection_filter + if dimension in {1, 2}: + metric_filter += (func.ST_Dimension(VectorFeature.geometry) == int(dimension),) + if method == "intersection_area": measured_geometry = ( VectorFeature.geometry @@ -246,7 +422,7 @@ class VectorFeatureService: else func.ST_Intersection(VectorFeature.geometry, selection_shape) ) area_expression = func.ST_Area(func.ST_Transform(measured_geometry, 31370)) - area_m2 = db.query(func.coalesce(func.sum(area_expression), 0.0)).filter(*selection_filter).scalar() + area_m2 = db.query(func.coalesce(func.sum(area_expression), 0.0)).filter(*metric_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": @@ -256,7 +432,7 @@ class VectorFeatureService: else func.ST_Intersection(VectorFeature.geometry, selection_shape) ) length_expression = func.ST_Length(func.ST_Transform(measured_geometry, 31370)) - length_m = db.query(func.coalesce(func.sum(length_expression), 0.0)).filter(*selection_filter).scalar() + length_m = db.query(func.coalesce(func.sum(length_expression), 0.0)).filter(*metric_filter).scalar() divisor = 1_000.0 if unit == "km" else 1.0 metric_value = float(length_m or 0.0) / divisor elif method in {"sum", "area_weighted_sum"}: @@ -307,11 +483,11 @@ class VectorFeatureService: ) return { - "metric_label": label, + "metric_key": str(config.get("metric_key") or method), + "metric_label": str(config.get("label") or "Objecten"), "metric_value": metric_value, "metric_unit": unit, "aggregation_method": method, - "feature_count": feature_count, "is_estimate": is_estimate, "warning": warning, } diff --git a/backend/tests/test_sprint201_semantic_selection_metrics.py b/backend/tests/test_sprint201_semantic_selection_metrics.py new file mode 100644 index 00000000..e01b8a1f --- /dev/null +++ b/backend/tests/test_sprint201_semantic_selection_metrics.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +from app.models import Dataset +from app.schemas.operations import VectorSelectionSummary +from app.services.vector_feature_service import VectorFeatureService + + +ROOT = Path(__file__).parents[2] +BBOX = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"} + + +class ScalarQuery: + def __init__(self, value: float): + self.value = value + + def filter(self, *args): # noqa: ANN002, ARG002 + return self + + def scalar(self): + return self.value + + +class SequenceScalarSession: + def __init__(self, values: list[float]): + self.values = iter(values) + + def query(self, *args): # noqa: ANN002, ARG002 + return ScalarQuery(next(self.values)) + + +def themed_dataset(theme: str, *, method: str = "feature_count") -> Dataset: + return Dataset( + id=uuid4(), + project_id=uuid4(), + name=f"regional-{theme}.geojson", + dataset_type="vector", + dataset_role="reference", + source_name="grb", + reference_layer_name=theme, + source_metadata={ + "theme": theme, + "selection_aggregation": { + "method": method, + "label": theme.title(), + "unit": "objecten", + }, + }, + ) + + +def test_building_selection_promotes_footprint_area_and_retains_object_count() -> None: + result = VectorFeatureService.summarize_features_by_bbox( + SequenceScalarSession([125_000.0]), + dataset=themed_dataset("buildings"), + bbox=BBOX, + total_feature_count=40, + ) + + assert result["primary_metric_key"] == "footprint_area" + assert result["metric_label"] == "Bebouwde grondoppervlakte" + assert result["metric_value"] == 12.5 + assert result["metric_unit"] == "ha" + assert [(item["metric_key"], item["metric_value"]) for item in result["metrics"]] == [ + ("footprint_area", 12.5), + ("feature_count", 40.0), + ] + assert "niet de totale vloeroppervlakte" in result["warning"] + VectorSelectionSummary(**result) + + +def test_water_selection_reports_surface_length_and_honest_volume_limitation() -> None: + result = VectorFeatureService.summarize_features_by_bbox( + SequenceScalarSession([52_500.0, 12_750.0]), + dataset=themed_dataset("water"), + bbox=BBOX, + total_feature_count=23, + ) + + assert result["metric_value"] == 5.25 + assert result["metric_unit"] == "ha" + assert [(item["metric_key"], item["metric_value"], item["metric_unit"]) for item in result["metrics"]] == [ + ("water_area", 5.25, "ha"), + ("watercourse_length", 12.75, "km"), + ("feature_count", 23.0, "objecten"), + ] + assert "Watervolume is niet berekenbaar" in result["warning"] + + +def test_population_keeps_configured_metric_and_adds_sector_count() -> None: + dataset = themed_dataset("population", method="sum") + dataset.source_metadata["selection_aggregation"].update( + {"metric_key": "population", "property": "population_total", "label": "Inwoners", "unit": "inwoners"} + ) + result = VectorFeatureService.summarize_features_by_bbox( + SequenceScalarSession([86_458.0]), + dataset=dataset, + bbox=BBOX, + total_feature_count=733, + ) + + assert result["primary_metric_key"] == "population" + assert result["metric_value"] == 86_458.0 + assert result["metrics"][1] == { + "metric_key": "feature_count", + "metric_label": "Statistische sectoren", + "metric_value": 733.0, + "metric_unit": "objecten", + "aggregation_method": "feature_count", + "is_estimate": False, + "warning": None, + } + + +def test_future_regional_imports_persist_semantic_aggregation_configuration() -> None: + buildings = (ROOT / "scripts/provision_regional_grb_buildings.py").read_text(encoding="utf-8") + context = (ROOT / "scripts/provision_regional_grb_context.py").read_text(encoding="utf-8") + frontend = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") + + assert '"method": "intersection_area"' in buildings + assert '"label": "Bebouwde grondoppervlakte"' in buildings + assert 'metric_method="intersection_length"' in context + assert 'metric_label="Wateroppervlakte"' in context + assert 'metric_label="Perceeloppervlakte"' in context + assert 'aria-label="Aanvullende gebiedsmetingen"' in frontend + assert "activeSelectionResult.summary.warning" in frontend diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 48a6fc99..35f74018 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -427,6 +427,42 @@ Response: "geojson": { "type": "FeatureCollection", "features": [] + }, + "summary": { + "metric_label": "Wateroppervlakte", + "metric_value": 5.25, + "metric_unit": "ha", + "aggregation_method": "intersection_area", + "primary_metric_key": "water_area", + "feature_count": 23, + "is_estimate": false, + "warning": "Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens.", + "metrics": [ + { + "metric_key": "water_area", + "metric_label": "Wateroppervlakte", + "metric_value": 5.25, + "metric_unit": "ha", + "aggregation_method": "intersection_area", + "is_estimate": false + }, + { + "metric_key": "watercourse_length", + "metric_label": "Lengte waterlopen", + "metric_value": 12.75, + "metric_unit": "km", + "aggregation_method": "intersection_length", + "is_estimate": false + }, + { + "metric_key": "feature_count", + "metric_label": "Waterobjecten", + "metric_value": 23, + "metric_unit": "objecten", + "aggregation_method": "feature_count", + "is_estimate": false + } + ] } } } @@ -439,6 +475,9 @@ Rules: - `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 requested bbox or persisted Area geometry. +- `summary` keeps one backwards-compatible primary metric and exposes all relevant measurements in `metrics`. Known themes use metric PostGIS calculations: building/forest/water/parcel surfaces in hectares, road and watercourse lengths in kilometres, population in inhabitants and intersecting feature counts as supporting evidence. +- Area and length calculations transform geometry to Belgian Lambert 72 (`EPSG:31370`); they are never calculated in geographic degrees. +- Water volume is not inferred from 2D GRB geometry. It remains unavailable until a source provides reliable depth or bathymetry with compatible spatial coverage and provenance. - 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/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 89c3c9ae..f9d6f696 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -8333,3 +8333,35 @@ Next: - Generalize the audited historical-land-use operator to the approved regional scope, partition source retrieval by municipality and provision the three official editions without merging their methodology into current GRB. + +## Sprint 201 - Semantic area-selection metrics (2026-07-15) + +Implemented: +- Extended the canonical persisted-vector selection summary with an additive + metric set while preserving the existing primary metric fields and envelope. +- Mapped known data themes to useful units: building footprint, forest, water + and parcel surfaces in hectares; roads and linear watercourses in kilometres; + population in inhabitants; and intersecting feature counts as supporting + evidence. +- Kept all spatial calculations in PostGIS after transformation to EPSG:31370. + No browser-side area/length calculation or synthetic source value was added. +- Added honest domain limits for building floor area, road capacity and water + volume. The current 2D GRB water source cannot support volume without an + independently governed depth/bathymetry dataset. +- Updated the regional GRB operator metadata for future imports and added a + compact supporting-metric surface to the map-first result panel. + +Validation evidence: +- Focused temporal and semantic-selection regression set passed 16 tests. +- Full readiness passed 605 backend tests, backend compilation, the API + contract audit, one Alembic head, frontend TypeScript typecheck/build and all + shell syntax gates. + +Known limitation: +- Water volume remains unavailable by design. Adding it requires a compatible + depth or bathymetry source, coverage validation, units, observation date and + a documented integration method. + +Next: +- Validate the semantic metrics against live Mol PostGIS data and then continue + the audited regional historical buildings/water/roads import. diff --git a/docs/TODO.md b/docs/TODO.md index fad1733b..444affdf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -16,6 +16,8 @@ - [x] Default Detection Lab to the configured local YOLO asset and present measured model quality and control requirements honestly. - [x] Extend official population and land-use time series from Mol to the approved 28-municipality regional scope. - [x] Make Evolution automatically open an available regional series and distinguish historical themes from current-only snapshots. +- [x] Replace object-count-only map results with semantic PostGIS metrics for hectares, kilometres and inhabitants while retaining counts as supporting evidence. +- [ ] Add a governed depth/bathymetry source before exposing water volume; never infer volume from 2D GRB water geometry. - [ ] Extend the official 1778/1873/1969 historical buildings, water and roads series from Mol to the approved regional scope with partitioned source audits. - [x] Connect a drawn rectangle to bounded official orthophoto acquisition, local configured-YOLO detection and persisted GRB QA. diff --git a/frontend/README.md b/frontend/README.md index 438675ac..5f269b85 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -29,11 +29,14 @@ them explicit. Forest therefore defaults to the official modern 2013-2025 10 m series, while the separate 1778-1969 historical map series remains selectable and is never merged into the same trend. -Selection results use dataset-specific PostGIS summaries. Object layers show -intersecting counts, population shows inhabitants with partial-sector -estimates clearly marked and land-cover sources show intersected hectares. The -advanced workbench remains available but is not required for the primary -choose-theme, draw-area, read-result flow. +Selection results use dataset-specific PostGIS summaries with end-user units. +Building footprints, forest, water surfaces and parcels show intersected +hectares; roads and linear watercourses show kilometres; population shows +inhabitants with partial-sector estimates clearly marked. Intersecting object +counts remain visible as supporting source evidence instead of being the only +result. Water explicitly explains that volume cannot be derived without a +reliable depth or bathymetry source. The advanced workbench remains available +but is not required for the primary choose-theme, draw-area, read-result flow. The primary workflow is deliberately short: choose a municipality or the complete region, choose a data theme, drag a rectangle on the MapLibre map and read the resulting PostGIS evidence. Releasing the drag runs the active theme query and every other available theme query for the same EPSG:4326 bbox. The result panel shows selection area, exact intersection totals, active-theme density, source identity and bounded feature properties. Map rendering remains capped at 1,000 features while `total_feature_count` reports the exact database count. diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 36b52cfd..4154c17c 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import GeoMap from '../GeoMap' -import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types' +import type { AreaRead, DatasetCreateResponse, DetectionQaResult, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' import { featureCollectionBounds } from '../../lib/geojsonBounds' import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights' import { useTemporalComparison } from '../../hooks/useTemporalComparison' @@ -222,6 +222,11 @@ function resultMetricLabel(result: VectorSelectionResponse): string { return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}` } +function selectionMetricLabel(metric: VectorSelectionMetric): string { + const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2 + return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}` +} + function formatTemporalMetric(value: number, unit: string): string { const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2 return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}` @@ -648,6 +653,9 @@ export function MapWorkspace({ const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten' const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel + const activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter( + (metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key, + ) const activeSecondaryMetric = selectedAreaSquareMetres && selectedAreaSquareMetres > 0 ? activeMetricUnit === 'ha' ? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking` @@ -1350,6 +1358,17 @@ export function MapWorkspace({ + {activeSupportingMetrics.length > 0 ? ( +
+ {activeSupportingMetrics.map((metric) => ( +
+ {metric.metric_label} + {selectionMetricLabel(metric)} +
+ ))} +
+ ) : null} +

Alle beschikbare thema’s

@@ -1376,6 +1395,9 @@ export function MapWorkspace({ {analysisMode === 'current' && activeSelectionResult?.truncated ? (

De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.

) : null} + {analysisMode === 'current' && activeSelectionResult?.summary?.warning ? ( +

{activeSelectionResult.summary.warning}

+ ) : null} {mapSelectionError ?

{mapSelectionError}

: null} {themeResultsError ?

{themeResultsError}

: null} {temporalComparisonError ?

{temporalComparisonError}

: null} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 61537608..f9a399a5 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -6135,6 +6135,33 @@ section { white-space: nowrap; } +.geo-supporting-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: 0.35rem; +} + +.geo-supporting-metrics > div { + display: flex; + gap: 0.4rem; + align-items: baseline; + justify-content: space-between; + min-width: 0; + border-bottom: 1px solid #e4ebe8; + padding: 0.3rem 0.1rem; +} + +.geo-supporting-metrics span { + color: #687570; + font-size: 0.64rem; +} + +.geo-supporting-metrics strong { + color: #263832; + font-size: 0.72rem; + white-space: nowrap; +} + .geo-temporal-metrics > div.positive { border-color: #b8d8c5; background: #f1faf4; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ae759a91..de094368 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -349,9 +349,21 @@ export interface VectorSelectionSummary { metric_value: number metric_unit: string aggregation_method: string + primary_metric_key?: string | null feature_count: number is_estimate: boolean warning?: string | null + metrics?: VectorSelectionMetric[] +} + +export interface VectorSelectionMetric { + metric_key: string + metric_label: string + metric_value: number + metric_unit: string + aggregation_method: string + is_estimate: boolean + warning?: string | null } export interface DatasetTemporalUpdate { diff --git a/scripts/provision_regional_grb_buildings.py b/scripts/provision_regional_grb_buildings.py index 2c92b834..a6dfd675 100644 --- a/scripts/provision_regional_grb_buildings.py +++ b/scripts/provision_regional_grb_buildings.py @@ -662,10 +662,12 @@ def provision_dataset( "partition_count": len(partition_paths), "partition_strategy": manifest["partition_strategy"], "selection_aggregation": { - "method": "feature_count", - "label": "Gebouwen", - "unit": "objecten", + "metric_key": "footprint_area", + "method": "intersection_area", + "label": "Bebouwde grondoppervlakte", + "unit": "ha", "is_estimate": False, + "warning": "Dit is de grondoppervlakte van gebouwcontouren, niet de totale vloeroppervlakte of het gebouwvolume.", }, "attribution": GRB_ATTRIBUTION, } diff --git a/scripts/provision_regional_grb_context.py b/scripts/provision_regional_grb_context.py index 2756c1e8..7db22928 100644 --- a/scripts/provision_regional_grb_context.py +++ b/scripts/provision_regional_grb_context.py @@ -69,7 +69,11 @@ class LayerDefinition: reference_layer_name: str layer_type: str geometry_types: tuple[str, ...] + metric_key: str + metric_method: str metric_label: str + metric_unit: str + metric_warning: str | None limitation_message: str @@ -80,7 +84,11 @@ LAYERS = ( reference_layer_name="roads", layer_type="road", geometry_types=("LineString", "MultiLineString"), - metric_label="Wegen", + metric_key="road_length", + metric_method="intersection_length", + metric_label="Totale weglengte", + metric_unit="km", + metric_warning="De lengte volgt de GRB-wegsegmenten en zegt niets over rijstroken, verkeersvolume of verhardingsoppervlakte.", limitation_message="GRB Wegsegment represents road-network line segments, not traffic volume or routing suitability.", ), LayerDefinition( @@ -93,7 +101,11 @@ LAYERS = ( reference_layer_name="water", layer_type="water", geometry_types=("LineString", "MultiLineString", "Polygon", "MultiPolygon"), - metric_label="Waterobjecten", + metric_key="water_area", + metric_method="intersection_area", + metric_label="Wateroppervlakte", + metric_unit="ha", + metric_warning="Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens. De kaartbron levert alleen oppervlakte- en lijngeometrie.", limitation_message="GRB water combines surface-water polygons and water-related line collections; counts are object counts, not water volume.", ), LayerDefinition( @@ -102,7 +114,11 @@ LAYERS = ( reference_layer_name="parcels", layer_type="parcel", geometry_types=("Polygon", "MultiPolygon"), - metric_label="Percelen", + metric_key="parcel_area", + metric_method="intersection_area", + metric_label="Perceeloppervlakte", + metric_unit="ha", + metric_warning="GRB-percelen zijn een grafische referentie en vormen geen juridische grensopmeting.", limitation_message="GRB ADP is a graphical representation of the presumed cadastral parcel location and is not a legal boundary survey.", ), ) @@ -721,10 +737,12 @@ def provision_dataset( "partition_count": len(partition_paths), "partition_strategy": manifest["partition_strategy"], "selection_aggregation": { - "method": "feature_count", + "metric_key": definition.metric_key, + "method": definition.metric_method, "label": definition.metric_label, - "unit": "objecten", + "unit": definition.metric_unit, "is_estimate": False, + "warning": definition.metric_warning, }, "attribution": GRB_ATTRIBUTION, }