feat: add semantic GIS selection metrics
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 06:07:34 +02:00
parent 3ff2d07f3c
commit 0baa9b069c
16 changed files with 520 additions and 22 deletions
+14
View File
@@ -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
+9
View File
@@ -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
+1 -1
View File
@@ -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(),
+2
View File
@@ -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",
+12
View File
@@ -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):
+183 -7
View File
@@ -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,
}
@@ -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
+39
View File
@@ -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.
+32
View File
@@ -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.
+2
View File
@@ -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.
+8 -5
View File
@@ -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.
+23 -1
View File
@@ -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({
</div>
</div>
{activeSupportingMetrics.length > 0 ? (
<div className="geo-supporting-metrics" aria-label="Aanvullende gebiedsmetingen">
{activeSupportingMetrics.map((metric) => (
<div key={metric.metric_key}>
<span>{metric.metric_label}</span>
<strong>{selectionMetricLabel(metric)}</strong>
</div>
))}
</div>
) : null}
<div className="geo-theme-results">
<div className="geo-results-title-row">
<h4>Alle beschikbare themas</h4>
@@ -1376,6 +1395,9 @@ export function MapWorkspace({
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
) : null}
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
) : null}
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
+27
View File
@@ -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;
+12
View File
@@ -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 {
+5 -3
View File
@@ -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,
}
+23 -5
View File
@@ -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,
}