extract the map workspace's domain layer out of the component

MapWorkspace.tsx opened with ~590 lines of theme catalogue, dataset matching
and label formatting above a 3.200-line component. None of it is React, all of
it is independently testable, and both render paths read from it, so it belongs
beside the pure helpers that already live in mapWorkspaceUtils.

The contract tests that read MapWorkspace.tsx would have gone red for a move
that changes no behaviour at all — 24 of them. That is the brittleness the
frontend_contract helper exists to remove, so it gains read_map_workspace():
the workspace is one feature spread over several modules, and a contract
belongs to the feature rather than to whichever file currently holds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 18:49:06 +02:00
co-authored by Claude Opus 5
parent e2f586c029
commit 39c12822bc
27 changed files with 728 additions and 643 deletions
+34
View File
@@ -22,12 +22,46 @@ ROOT = Path(__file__).resolve().parents[2]
FRONTEND_SRC = ROOT / "frontend" / "src" FRONTEND_SRC = ROOT / "frontend" / "src"
# The map workspace is one feature split across several modules: the container
# component, its domain layer and its pure helpers. A contract belongs to the
# feature, not to whichever file currently holds it, so splitting a 4.000-line
# component must not red the suite.
MAP_WORKSPACE_SOURCES = (
"components/map/MapWorkspace.tsx",
"components/map/mapWorkspaceThemes.ts",
"components/map/mapWorkspaceUtils.ts",
"components/map/MapExplorerView.tsx",
"components/map/MapAdvancedWorkbench.tsx",
)
def read_frontend(relative_path: str) -> str: def read_frontend(relative_path: str) -> str:
"""Read one frontend source file relative to ``frontend/src``.""" """Read one frontend source file relative to ``frontend/src``."""
return (FRONTEND_SRC / relative_path).read_text(encoding="utf-8") return (FRONTEND_SRC / relative_path).read_text(encoding="utf-8")
def read_frontend_area(*relative_paths: str) -> str:
"""Read several related sources as one body of code.
Files that do not exist are skipped, so this survives a module being split
further or merged back.
"""
parts: list[str] = []
for relative_path in relative_paths:
path = FRONTEND_SRC / relative_path
if path.is_file():
parts.append(path.read_text(encoding="utf-8"))
return chr(10).join(parts)
def read_map_workspace() -> str:
"""The whole map workspace feature, whichever modules it is split into."""
return read_frontend_area(*MAP_WORKSPACE_SOURCES)
def assert_wired(source: str, *identifiers: str, context: str = "frontend source") -> None: def assert_wired(source: str, *identifiers: str, context: str = "frontend source") -> None:
"""Every identifier must appear in ``source``. """Every identifier must appear in ``source``.
+3 -4
View File
@@ -13,6 +13,7 @@ from app.models import Area, Dataset, Project
from app.schemas.coverage import CoverageBBox from app.schemas.coverage import CoverageBBox
from app.services.coverage_registry_service import CoverageRegistryService, THEMES, ZONES from app.services.coverage_registry_service import CoverageRegistryService, THEMES, ZONES
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
class FakeQuery: class FakeQuery:
@@ -151,9 +152,7 @@ def test_national_and_maritime_reference_layers_are_selection_analyzable() -> No
def test_national_scope_operator_assigns_explicit_map_themes() -> None: def test_national_scope_operator_assigns_explicit_map_themes() -> None:
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
operator = (root / "scripts" / "provision_belgium_north_sea_scope.py").read_text(encoding="utf-8") operator = (root / "scripts" / "provision_belgium_north_sea_scope.py").read_text(encoding="utf-8")
map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text( map_workspace = read_map_workspace()
encoding="utf-8"
)
assert '"belgium_municipalities": "administrative"' in operator assert '"belgium_municipalities": "administrative"' in operator
assert '"marine_legal_scopes": "marine_environment"' in operator assert '"marine_legal_scopes": "marine_environment"' in operator
@@ -476,7 +475,7 @@ def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbo
focus = (root / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8") focus = (root / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
workspace_hook = (root / "frontend" / "src" / "hooks" / "useProjectWorkspace.ts").read_text(encoding="utf-8") workspace_hook = (root / "frontend" / "src" / "hooks" / "useProjectWorkspace.ts").read_text(encoding="utf-8")
coverage_hook = (root / "frontend" / "src" / "hooks" / "useCoverageResolver.ts").read_text(encoding="utf-8") coverage_hook = (root / "frontend" / "src" / "hooks" / "useCoverageResolver.ts").read_text(encoding="utf-8")
map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") map_workspace = read_map_workspace()
assert "Belgium and North Sea Workbench" in focus assert "Belgium and North Sea Workbench" in focus
assert "nationalProject" in workspace_hook assert "nationalProject" in workspace_hook
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -20,9 +21,7 @@ def test_rc9_ux_audit_is_wired_into_frontend_and_readiness() -> None:
def test_rc9_loading_and_accessibility_states_are_explicit() -> None: def test_rc9_loading_and_accessibility_states_are_explicit() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
map_workspace = ( map_workspace = read_map_workspace()
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
).read_text(encoding="utf-8")
geo_map = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text( geo_map = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(
encoding="utf-8" encoding="utf-8"
) )
@@ -42,9 +41,7 @@ def test_rc9_performance_budgets_are_documented_and_visible() -> None:
ROOT / "frontend" / "src" / "lib" / "performanceBudget.ts" ROOT / "frontend" / "src" / "lib" / "performanceBudget.ts"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
docs = (ROOT / "docs" / "UX_PERFORMANCE_BUDGETS.md").read_text(encoding="utf-8") docs = (ROOT / "docs" / "UX_PERFORMANCE_BUDGETS.md").read_text(encoding="utf-8")
map_workspace = ( map_workspace = read_map_workspace()
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
).read_text(encoding="utf-8")
assert "COVERAGE_RESPONSE_BUDGET_MS = 4_000" in budget assert "COVERAGE_RESPONSE_BUDGET_MS = 4_000" in budget
assert "MAP_ANALYSIS_BUDGET_MS = 15_000" in budget assert "MAP_ANALYSIS_BUDGET_MS = 15_000" in budget
@@ -1,15 +1,14 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
def test_map_workspace_exposes_feature_extract_actions() -> None: def test_map_workspace_exposes_feature_extract_actions() -> None:
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text( map_workspace = read_map_workspace()
encoding="utf-8"
)
assert "Selectie en extractie" in map_workspace assert "Selectie en extractie" in map_workspace
assert "downloadSelectedMapFeature" in map_workspace assert "downloadSelectedMapFeature" in map_workspace
@@ -11,7 +11,7 @@ from shapely.geometry import Polygon, box
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Dataset, VectorFeature from app.models import Dataset, VectorFeature
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -338,7 +338,7 @@ def test_vector_select_route_rejects_area_from_another_project(monkeypatch) -> N
def test_frontend_exposes_map_bbox_selection_contracts() -> None: def test_frontend_exposes_map_bbox_selection_contracts() -> None:
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8") 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") map_workspace = read_map_workspace()
geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.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") app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
extract_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8") extract_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8")
@@ -3,7 +3,7 @@ from __future__ import annotations
import re import re
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -15,7 +15,7 @@ def read(path: str) -> str:
def test_map_first_explorer_is_the_default_product_flow() -> None: def test_map_first_explorer_is_the_default_product_flow() -> None:
app = read("frontend/src/App.tsx") app = read("frontend/src/App.tsx")
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
assert "useState<WorkspaceKey>('map')" in app assert "useState<WorkspaceKey>('map')" in app
assert "Gebied analyseren" in workspace assert "Gebied analyseren" in workspace
@@ -36,7 +36,7 @@ def test_map_first_explorer_is_the_default_product_flow() -> None:
def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None: def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
geomap = read("frontend/src/components/GeoMap.tsx") geomap = read("frontend/src/components/GeoMap.tsx")
styles = read("frontend/src/styles/app.css") styles = read("frontend/src/styles/app.css")
@@ -16,6 +16,7 @@ from app.schemas.temporal import TemporalComparisonRequest, TemporalObjectChange
from app.services.dataset_service import DatasetService from app.services.dataset_service import DatasetService
from app.services.temporal_analysis_service import TemporalAnalysisService from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).parents[2] ROOT = Path(__file__).parents[2]
@@ -513,7 +514,7 @@ def test_legacy_grb_identity_requires_complete_partition_evidence() -> None:
def test_temporal_frontend_and_official_operator_contracts_exist() -> None: def test_temporal_frontend_and_official_operator_contracts_exist() -> None:
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
temporal_api = (ROOT / "frontend/src/services/api/temporal.ts").read_text(encoding="utf-8") temporal_api = (ROOT / "frontend/src/services/api/temporal.ts").read_text(encoding="utf-8")
population = (ROOT / "scripts/provision_mol_population_history.py").read_text(encoding="utf-8") population = (ROOT / "scripts/provision_mol_population_history.py").read_text(encoding="utf-8")
landuse = (ROOT / "scripts/provision_mol_historical_landuse.py").read_text(encoding="utf-8") landuse = (ROOT / "scripts/provision_mol_historical_landuse.py").read_text(encoding="utf-8")
@@ -11,6 +11,7 @@ import rasterio
from rasterio.transform import from_origin from rasterio.transform import from_origin
from shapely.geometry import Polygon, shape from shapely.geometry import Polygon, shape
from shapely.ops import transform from shapely.ops import transform
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -179,7 +180,7 @@ def test_official_landuse_operator_paginates_within_api_limit() -> None:
def test_official_landuse_operator_is_packaged_and_readiness_checked() -> None: def test_official_landuse_operator_is_packaged_and_readiness_checked() -> None:
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
geo_map = (ROOT / "frontend/src/components/GeoMap.tsx").read_text(encoding="utf-8") geo_map = (ROOT / "frontend/src/components/GeoMap.tsx").read_text(encoding="utf-8")
premium_css = (ROOT / "frontend/src/styles/premium.css").read_text(encoding="utf-8") premium_css = (ROOT / "frontend/src/styles/premium.css").read_text(encoding="utf-8")
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -10,7 +10,7 @@ def read(path: str) -> str:
def test_work_area_change_clears_stale_spatial_results_before_switching_area() -> None: def test_work_area_change_clears_stale_spatial_results_before_switching_area() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
assert "const handleSelectMapArea = (areaId: string) => {" in workspace assert "const handleSelectMapArea = (areaId: string) => {" in workspace
assert "clearAreaSelection()\n onSelectMapArea(areaId)" in workspace assert "clearAreaSelection()\n onSelectMapArea(areaId)" in workspace
@@ -4,6 +4,7 @@ import importlib.util
from pathlib import Path from pathlib import Path
import pytest import pytest
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -118,9 +119,7 @@ def test_readiness_compiles_false_negative_review_validator() -> None:
def test_map_explains_strict_and_diagnostic_detection_matching() -> None: def test_map_explains_strict_and_diagnostic_detection_matching() -> None:
workspace = ( workspace = read_map_workspace()
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
).read_text(encoding="utf-8")
# Both matching methods must be named; the exact label may change. # Both matching methods must be named; the exact label may change.
assert "strikte" in workspace.casefold() assert "strikte" in workspace.casefold()
assert "Rechthoekcontrole" in workspace assert "Rechthoekcontrole" in workspace
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -10,7 +10,7 @@ def read(path: str) -> str:
def test_evolution_mode_falls_back_to_an_available_series() -> None: def test_evolution_mode_falls_back_to_an_available_series() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
assert "themeTemporalSeriesMap" in workspace assert "themeTemporalSeriesMap" in workspace
assert "availableEvolutionThemes[0]" in workspace assert "availableEvolutionThemes[0]" in workspace
@@ -20,7 +20,7 @@ def test_evolution_mode_falls_back_to_an_available_series() -> None:
def test_evolution_theme_catalog_distinguishes_history_from_current_only_data() -> None: def test_evolution_theme_catalog_distinguishes_history_from_current_only_data() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
assert "analysisMode === 'current'" in workspace assert "analysisMode === 'current'" in workspace
# Theme availability depends on a dataset or an on-demand product; # Theme availability depends on a dataset or an on-demand product;
@@ -13,6 +13,7 @@ from shapely.ops import transform as transform_geometry
from app.models import Dataset from app.models import Dataset
from app.schemas.operations import VectorSelectionSummary from app.schemas.operations import VectorSelectionSummary
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -260,7 +261,7 @@ def test_operator_is_packaged_readiness_checked_and_wired_to_map() -> None:
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8") service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8")
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") map_workspace = read_map_workspace()
source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8") source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8")
assert "COPY scripts/provision_mol_bwk_natura2000.py" in dockerfile assert "COPY scripts/provision_mol_bwk_natura2000.py" in dockerfile
@@ -14,6 +14,7 @@ from shapely.ops import transform as transform_geometry
from app.models import Dataset from app.models import Dataset
from app.schemas.operations import VectorSelectionSummary from app.schemas.operations import VectorSelectionSummary
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -261,7 +262,7 @@ def test_operator_uses_canonical_upload_and_is_packaged_for_runtime() -> None:
service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8") service = (ROOT / "backend" / "app" / "services" / "vector_feature_service.py").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") map_workspace = read_map_workspace()
source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8") source_catalog = (ROOT / "frontend" / "src" / "components" / "datasets" / "SourceCatalogPanel.tsx").read_text(encoding="utf-8")
dataset_display = (ROOT / "frontend" / "src" / "lib" / "datasetDisplay.ts").read_text(encoding="utf-8") dataset_display = (ROOT / "frontend" / "src" / "lib" / "datasetDisplay.ts").read_text(encoding="utf-8")
+2 -1
View File
@@ -21,6 +21,7 @@ from app.models import Area, Dataset, DatasetVersion, Job, Project, SourceRegist
from app.schemas.dhmv import DhmvAcquireRequest, TerrainPartitionSelectionRequest, TerrainSelectionRequest from app.schemas.dhmv import DhmvAcquireRequest, TerrainPartitionSelectionRequest, TerrainSelectionRequest
from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.dhmv_acquisition_service import DhmvAcquisitionService
from app.services.terrain_analysis_service import TerrainAnalysisService from app.services.terrain_analysis_service import TerrainAnalysisService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -616,7 +617,7 @@ def test_frontend_and_runtime_expose_dhmv_workflow() -> None:
capabilities_source = ( capabilities_source = (
ROOT / "frontend" / "src" / "lib" / "datasetCapabilities.ts" ROOT / "frontend" / "src" / "lib" / "datasetCapabilities.ts"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
map_source = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") map_source = read_map_workspace()
hook_source = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8") hook_source = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8")
service_source = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8") service_source = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8")
@@ -12,6 +12,7 @@ from shapely.ops import transform as transform_geometry
from app.models import Dataset from app.models import Dataset
from app.schemas.operations import VectorSelectionSummary from app.schemas.operations import VectorSelectionSummary
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -352,7 +353,7 @@ def test_operator_is_canonical_packaged_and_mol_scoped_in_explorer() -> None:
service = (ROOT / "backend/app/services/vector_feature_service.py").read_text(encoding="utf-8") service = (ROOT / "backend/app/services/vector_feature_service.py").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
catalog = (ROOT / "frontend/src/components/datasets/SourceCatalogPanel.tsx").read_text(encoding="utf-8") catalog = (ROOT / "frontend/src/components/datasets/SourceCatalogPanel.tsx").read_text(encoding="utf-8")
display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8") display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8")
@@ -25,6 +25,7 @@ from app.schemas.assistant import AssistantQueryRequest
from app.services.geo_assistant_service import GeoAssistantService from app.services.geo_assistant_service import GeoAssistantService
from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -549,7 +550,7 @@ def test_flood_hazard_runtime_contract_is_packaged() -> None:
operator = (ROOT / "scripts" / "provision_mol_flood_hazards.py").read_text(encoding="utf-8") operator = (ROOT / "scripts" / "provision_mol_flood_hazards.py").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
frontend = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") frontend = read_map_workspace()
assert "/datasets/flood-hazard/acquire" in operator assert "/datasets/flood-hazard/acquire" in operator
assert "/raster/flood-hazard/select" in operator assert "/raster/flood-hazard/select" in operator
assert "concurrent_flood_volume_m3" in operator assert "concurrent_flood_volume_m3" in operator
@@ -11,6 +11,7 @@ from shapely.geometry import box, mapping, shape
from app.models import Dataset from app.models import Dataset
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -235,7 +236,7 @@ def test_regional_operator_is_packaged_release_checked_and_exact_area_is_preferr
dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy/unraid/Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
service = (ROOT / "backend/app/services/vector_feature_service.py").read_text(encoding="utf-8") service = (ROOT / "backend/app/services/vector_feature_service.py").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
catalog = (ROOT / "frontend/src/components/datasets/SourceCatalogPanel.tsx").read_text(encoding="utf-8") catalog = (ROOT / "frontend/src/components/datasets/SourceCatalogPanel.tsx").read_text(encoding="utf-8")
model = (ROOT / "backend/app/models/entities.py").read_text(encoding="utf-8") model = (ROOT / "backend/app/models/entities.py").read_text(encoding="utf-8")
migration = ( migration = (
+2 -1
View File
@@ -10,6 +10,7 @@ from shapely.ops import transform as transform_geometry
from app.models import Dataset from app.models import Dataset
from app.services.vector_feature_service import VectorFeatureService from app.services.vector_feature_service import VectorFeatureService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -154,7 +155,7 @@ def test_soil_operator_contract_has_no_direct_persistence_and_is_packaged() -> N
operator = (ROOT / "scripts" / "provision_mol_soil_map.py").read_text(encoding="utf-8") operator = (ROOT / "scripts" / "provision_mol_soil_map.py").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") map_workspace = read_map_workspace()
assert "/datasets/upload" in operator assert "/datasets/upload" in operator
assert "vector_features" in operator assert "vector_features" in operator
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -21,7 +21,7 @@ def test_partitioned_raster_routes_are_canonical_and_documented() -> None:
def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None: def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None:
app = (ROOT / "frontend/src/App.tsx").read_text(encoding="utf-8") app = (ROOT / "frontend/src/App.tsx").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8") hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
api = (ROOT / "frontend/src/services/api/datasets.ts").read_text(encoding="utf-8") api = (ROOT / "frontend/src/services/api/datasets.ts").read_text(encoding="utf-8")
@@ -13,6 +13,7 @@ import pytest
from app.core.errors import AppError from app.core.errors import AppError
from app.models import Dataset from app.models import Dataset
from app.services.grb_refresh_plan_service import GrbRefreshPlanService from app.services.grb_refresh_plan_service import GrbRefreshPlanService
from tests.frontend_contract import read_map_workspace
NOW = datetime(2026, 7, 16, 16, 0, tzinfo=timezone.utc) NOW = datetime(2026, 7, 16, 16, 0, tzinfo=timezone.utc)
@@ -252,7 +253,7 @@ def test_refresh_api_and_frontend_remain_explicit_only() -> None:
def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> None: def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> None:
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
observed_sort = workspace.index("const observedAtDifference") observed_sort = workspace.index("const observedAtDifference")
feature_tiebreaker = workspace.index("right.feature_count", observed_sort) feature_tiebreaker = workspace.index("right.feature_count", observed_sort)
assert observed_sort < feature_tiebreaker assert observed_sort < feature_tiebreaker
@@ -262,7 +263,7 @@ def test_map_theme_ranking_prefers_newer_observation_over_feature_count() -> Non
def test_map_workspace_restores_theme_from_selected_dataset() -> None: def test_map_workspace_restores_theme_from_selected_dataset() -> None:
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
assert "function themeIdForDataset(" in workspace assert "function themeIdForDataset(" in workspace
assert "useState<DataThemeId>(() =>" in workspace assert "useState<DataThemeId>(() =>" in workspace
assert "return themeIdForDataset(selectedDataset) ?? 'buildings'" in workspace assert "return themeIdForDataset(selectedDataset) ?? 'buildings'" in workspace
@@ -20,7 +20,7 @@ from app.services.storage_service import StorageService
from app.services.source_registry_service import SourceRegistryService from app.services.source_registry_service import SourceRegistryService
from app.services.temporal_analysis_service import TemporalAnalysisService from app.services.temporal_analysis_service import TemporalAnalysisService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
class FakeSession: class FakeSession:
@@ -364,7 +364,7 @@ def test_map_result_export_endpoint_uses_canonical_envelope(monkeypatch) -> None
def test_frontend_persists_map_result_before_opening_downloads() -> None: def test_frontend_persists_map_result_before_opening_downloads() -> None:
root = Path(__file__).resolve().parents[2] root = Path(__file__).resolve().parents[2]
workspace = (root / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
hook = (root / "frontend/src/hooks/useExportWorkflow.ts").read_text(encoding="utf-8") hook = (root / "frontend/src/hooks/useExportWorkflow.ts").read_text(encoding="utf-8")
api = (root / "frontend/src/services/api/exports.ts").read_text(encoding="utf-8") api = (root / "frontend/src/services/api/exports.ts").read_text(encoding="utf-8")
@@ -19,6 +19,7 @@ from app.models import Area, Dataset, Job, Project
from app.schemas.bathymetry import BathymetryProfileAcquireRequest from app.schemas.bathymetry import BathymetryProfileAcquireRequest
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
from app.services.dataset_service import DatasetService from app.services.dataset_service import DatasetService
from tests.frontend_contract import read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -360,7 +361,7 @@ def test_bathymetry_contract_and_expansion_roadmap_are_documented() -> None:
assert "py_compile scripts/provision_mol_bathymetry_profiles.py" in readiness assert "py_compile scripts/provision_mol_bathymetry_profiles.py" in readiness
assert "COPY scripts/provision_mol_bathymetry_profiles.py" in dockerfile assert "COPY scripts/provision_mol_bathymetry_profiles.py" in dockerfile
operator = (ROOT / "scripts" / "provision_mol_bathymetry_profiles.py").read_text(encoding="utf-8") operator = (ROOT / "scripts" / "provision_mol_bathymetry_profiles.py").read_text(encoding="utf-8")
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") map_workspace = read_map_workspace()
assert 'DEFAULT_PROJECT_NAME = "Kempen Regional Workbench"' in operator assert 'DEFAULT_PROJECT_NAME = "Kempen Regional Workbench"' in operator
assert "regional_partitions_complete" in map_workspace assert "regional_partitions_complete" in map_workspace
assert "historische profielen" in map_workspace assert "historische profielen" in map_workspace
@@ -31,6 +31,7 @@ if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS))
import provision_flanders_geographic_scope as flanders_scope # noqa: E402 import provision_flanders_geographic_scope as flanders_scope # noqa: E402
from tests.frontend_contract import read_map_workspace
class BinaryResponse: class BinaryResponse:
@@ -463,9 +464,7 @@ def test_frontend_uses_partitioned_bathymetry_selection_for_regional_scope() ->
focus = ( focus = (
ROOT / "frontend" / "src" / "config" / "primaryFocus.ts" ROOT / "frontend" / "src" / "config" / "primaryFocus.ts"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
map_workspace = ( map_workspace = read_map_workspace()
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
).read_text(encoding="utf-8")
theme_hook = ( theme_hook = (
ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts" ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
@@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -10,7 +10,7 @@ def read(path: str) -> str:
def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> None: def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
product_hook = read("frontend/src/hooks/useOfficialMapProducts.ts") product_hook = read("frontend/src/hooks/useOfficialMapProducts.ts")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts") selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
api = read("frontend/src/services/api/datasets.ts") api = read("frontend/src/services/api/datasets.ts")
@@ -30,7 +30,7 @@ def test_flanders_workspace_exposes_governed_thematic_products_on_demand() -> No
def test_selection_reads_and_bounded_acquires_all_relevant_themes() -> None: def test_selection_reads_and_bounded_acquires_all_relevant_themes() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
app = read("frontend/src/App.tsx") app = read("frontend/src/App.tsx")
selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts") selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.ts")
@@ -51,7 +51,7 @@ def test_selection_reads_and_bounded_acquires_all_relevant_themes() -> None:
def test_regional_on_demand_sources_require_a_bounded_drawn_selection() -> None: def test_regional_on_demand_sources_require_a_bounded_drawn_selection() -> None:
workspace = read("frontend/src/components/map/MapWorkspace.tsx") workspace = read_map_workspace()
assert "regionalOnDemandThemeActive" in workspace assert "regionalOnDemandThemeActive" in workspace
# Both regional source kinds need a bounded selection before acquiring. # Both regional source kinds need a bounded selection before acquiring.
@@ -19,7 +19,7 @@ from app.models import Area, Dataset, Job, Project
from app.schemas.grb import GrbAcquireRequest from app.schemas.grb import GrbAcquireRequest
from app.services.dataset_service import DatasetService from app.services.dataset_service import DatasetService
from app.services.grb_acquisition_service import GrbAcquisitionService from app.services.grb_acquisition_service import GrbAcquisitionService
from tests.frontend_contract import assert_calls, assert_mentions, assert_wired from tests.frontend_contract import assert_calls, assert_mentions, assert_wired, read_map_workspace
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -383,7 +383,7 @@ def test_system_capabilities_reports_bounded_grb_integration() -> None:
def test_grb_frontend_and_contracts_use_only_the_governed_backend_path() -> None: def test_grb_frontend_and_contracts_use_only_the_governed_backend_path() -> None:
selection_hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8") selection_hook = (ROOT / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
catalog_hook = (ROOT / "frontend/src/hooks/useOfficialMapProducts.ts").read_text(encoding="utf-8") catalog_hook = (ROOT / "frontend/src/hooks/useOfficialMapProducts.ts").read_text(encoding="utf-8")
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") workspace = read_map_workspace()
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8") contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
assert "datasetsApi.acquireGrb" in selection_hook assert "datasetsApi.acquireGrb" in selection_hook
+27 -592
View File
@@ -25,7 +25,6 @@ import {
bboxesEqual, bboxesEqual,
copyText, copyText,
datasetIntersectsSelection, datasetIntersectsSelection,
deduplicateTemporalSnapshots,
downloadJsonFile, downloadJsonFile,
formatArea, formatArea,
formatBboxLabel, formatBboxLabel,
@@ -35,14 +34,12 @@ import {
getFeatureCollectionBBox, getFeatureCollectionBBox,
getFeatureGeometrySummary, getFeatureGeometrySummary,
isMunicipalityAreaName, isMunicipalityAreaName,
isSelectionBoundedDataset,
normalizeBboxFromCorners, normalizeBboxFromCorners,
operationalScopeProjectLabel, operationalScopeProjectLabel,
parseBboxInput, parseBboxInput,
persistedDatasetSupportsSelection, persistedDatasetSupportsSelection,
productCoversZones, productCoversZones,
readablePropertyName, readablePropertyName,
resultCountLabel,
resultMetricLabel, resultMetricLabel,
safeFileStem, safeFileStem,
selectedAreaCoverageZones, selectedAreaCoverageZones,
@@ -53,596 +50,34 @@ import {
selectionFeatureLimit, selectionFeatureLimit,
selectionMetricLabel, selectionMetricLabel,
splitSelectionBbox, splitSelectionBbox,
temporalDatasetAreaMatch,
} from './mapWorkspaceUtils' } from './mapWorkspaceUtils'
import {
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' COVERAGE_THEME_BY_MAP_THEME,
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' DATA_THEMES,
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = [] DATA_THEME_MAP_STYLES,
DEFAULT_AREA_SELECTION_FILENAME,
type DataThemeId = DEFAULT_SELECTED_FEATURE_FILENAME,
| 'administrative' EMPTY_TEMPORAL_SERIES,
| 'buildings' coverageStatusLabel,
| 'land_cover' coverageZoneLabel,
| 'space_occupation' datasetCoversSelectedArea,
| 'open_space' datasetProductKey,
| 'population' floodScenarioLabel,
| 'forest' formatDatasetObservation,
| 'nature_value' formatObservationDate,
| 'agriculture' isPartitionedBathymetry,
| 'soil' isPartitionedRaster,
| 'water' listThemeTemporalSeries,
| 'bathymetry' pickThemeDataset,
| 'flood_hazard' productSupportsSelection,
| 'elevation' rasterPartitionsForDataset,
| 'accessibility' themeIdForDataset,
| 'services' type DataTheme,
| 'roads' type DataThemeId,
| 'parcels' type OnDemandMapProduct,
| 'maritime_planning' type PlannedOnDemandMapProduct,
| 'marine_environment' type TemporalSeriesGroup,
} from './mapWorkspaceThemes'
interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
interface TemporalSeriesGroup {
key: string
label: string
items: DatasetCreateResponse[]
}
interface OnDemandMapProduct extends MapThemeAcquisition {
theme: DataThemeId
availabilityLabel: string
attribution: string
limitationMessage: string
coverageZones: string[]
}
interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
acquisitionBboxes: VectorSelectionBBox[]
}
function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return true
const dimensions = selectionDimensions(bbox)
if (product.kind === 'dhmv' || product.kind === 'spw_terrain' || product.kind === 'flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (product.kind === 'thematic_raster' || product.kind === 'walous') {
return dimensions.widthMetres <= 50_000
&& dimensions.heightMetres <= 50_000
&& dimensions.areaSquareMetres <= 2_800_000_000
}
return true
}
const DATA_THEMES: DataTheme[] = [
{
id: 'administrative',
label: 'Bestuurlijke indeling',
shortLabel: 'Bestuursgebieden',
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
},
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'land_cover',
label: 'Landbedekking',
shortLabel: 'Landbedekking',
description: 'Fysieke en biologische bodembedekking uit een officieel regionaal classificatieraster.',
tokens: ['land_cover', 'land_cover_use', 'landbedekking', 'walous', 'occupation du sol'],
},
{
id: 'space_occupation',
label: 'Ruimtebeslag',
shortLabel: 'Ruimtebeslag',
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
},
{
id: 'open_space',
label: 'Open ruimte',
shortLabel: 'Open ruimte',
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
tokens: ['open_space', 'open ruimte', 'openruimte'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'nature_value',
label: 'Natuurwaarde',
shortLabel: 'BWK-oppervlakte',
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
},
{
id: 'agriculture',
label: 'Landbouw',
shortLabel: 'Landbouwgebruik',
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
},
{
id: 'soil',
label: 'Bodem',
shortLabel: 'Bodemkaart',
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'bathymetry',
label: 'Waterbodem',
shortLabel: 'Dwarsprofielen',
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
},
{
id: 'flood_hazard',
label: 'Overstroming',
shortLabel: 'Overstroomd oppervlak',
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
},
{
id: 'elevation',
label: 'Hoogte & reliëf',
shortLabel: 'Hoogte',
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
},
{
id: 'accessibility',
label: 'Bereikbaarheid',
shortLabel: 'Knooppuntwaarde',
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
},
{
id: 'services',
label: 'Voorzieningen',
shortLabel: 'Voorzieningenniveau',
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
{
id: 'maritime_planning',
label: 'Maritieme planning',
shortLabel: 'Plan- en gebruikszones',
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
},
{
id: 'marine_environment',
label: 'Mariene rapportagezones',
shortLabel: 'Zeegebieden',
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
},
]
const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
administrative: 'admin',
buildings: 'buildings',
land_cover: 'land_cover_use',
space_occupation: 'land_cover_use',
open_space: 'land_cover_use',
population: 'population',
forest: 'land_cover_use',
nature_value: 'nature',
agriculture: 'land_cover_use',
soil: 'soil',
water: 'surface_water',
bathymetry: 'bathymetry',
flood_hazard: 'flood_climate',
elevation: 'elevation',
accessibility: 'roads',
services: 'population',
roads: 'roads',
parcels: 'parcels',
maritime_planning: 'maritime_planning',
marine_environment: 'marine_environment',
}
function coverageStatusLabel(status: CoverageStatus): string {
const labels: Record<CoverageStatus, string> = {
operational: 'Beschikbaar',
partial: 'Gedeeltelijk',
not_configured: 'Niet gekoppeld',
unsupported: 'Niet ondersteund',
}
return labels[status]
}
function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
belgium: 'Belgie',
flanders: 'Vlaanderen',
wallonia: 'Wallonie',
brussels: 'Brussel',
belgian_north_sea: 'Belgische Noordzee',
territorial_sea: 'Territoriale zee',
exclusive_economic_zone: 'EEZ',
continental_shelf: 'Continentaal plat',
}
return labels[zone] ?? zone
}
const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
administrative: { fill: '#5f6f7f', line: '#344554' },
buildings: { fill: '#d45f3d', line: '#9f3e24' },
land_cover: { fill: '#4f7b4f', line: '#315c39' },
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
open_space: { fill: '#267a46', line: '#175c32' },
population: { fill: '#7559a6', line: '#5b3f88' },
forest: { fill: '#347950', line: '#225f3b' },
nature_value: { fill: '#9a4f64', line: '#74364a' },
agriculture: { fill: '#7b8f32', line: '#53671d' },
soil: { fill: '#9a7040', line: '#6f4c27' },
water: { fill: '#2676a8', line: '#155b85' },
bathymetry: { fill: '#0e7490', line: '#164e63' },
flood_hazard: { fill: '#1597c2', line: '#075985' },
elevation: { fill: '#a57a4b', line: '#315f59' },
accessibility: { fill: '#0f766e', line: '#115e59' },
services: { fill: '#b66d16', line: '#854d0e' },
roads: { fill: '#6b7280', line: '#4b5563' },
parcels: { fill: '#a7792f', line: '#7d571f' },
maritime_planning: { fill: '#2f7f8f', line: '#145d6a' },
marine_environment: { fill: '#3475a3', line: '#1c557d' },
}
function datasetSearchText(dataset: DatasetCreateResponse): string {
return [
dataset.name,
dataset.original_filename,
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
dataset.source_metadata?.['product_display_name'],
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
// Governed raster products have one unambiguous semantic theme. Matching
// them by generic substrings (for example "water" in "waterdiepte") would
// make a flood scenario replace the permanent surface-water layer.
if (dataset.source_name === 'vmm_flood_hazard') {
return theme.id === 'flood_hazard'
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
return theme.id === 'bathymetry'
}
if (dataset.source_name === 'spw_bathymetry') {
return theme.id === 'bathymetry'
}
if (['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')) {
return theme.id === 'elevation'
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dataset.source_metadata?.['theme'] === theme.id
}
if (dataset.source_name === 'spw_walous_land_cover') {
return theme.id === 'land_cover'
}
if (dataset.source_name === 'dov_soil_map') {
return theme.id === 'soil'
}
if (dataset.source_name === 'ngi_adminvector') {
return theme.id === 'administrative'
}
if (dataset.source_name === 'rbins_msp_2026') {
return theme.id === 'maritime_planning'
}
if (dataset.source_name === 'rbins_marine_reporting_units') {
return theme.id === 'marine_environment'
}
const searchText = datasetSearchText(dataset)
return theme.tokens.some((token) => searchText.includes(token))
}
function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.dataset_type === 'raster'
&& ['digitaal_vlaanderen_dhmv', 'spw_terrain', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
)
}
function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
)
}
function datasetProductKey(dataset: DatasetCreateResponse): string {
return String(dataset.source_metadata?.['product_key'] ?? '')
}
function datasetCoversSelectedArea(
dataset: DatasetCreateResponse,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): boolean {
if (isSelectionBoundedDataset(dataset.source_metadata)) {
return false
}
const selectedZones = selectedAreaCoverageZones(selectedAreaName)
const configuredZones = dataset.source_metadata?.['coverage_zones']
if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) {
const datasetZones = configuredZones.map((zone) => String(zone))
if (!selectedZones.some((zone) => datasetZones.includes(zone))) {
return false
}
}
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
if (isPartitionedBathymetry(dataset)) {
return regionalScope
? true
: Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
if (coverageScope !== 'municipality' || !dataset.area_id) {
return true
}
if (regionalScope) {
return true
}
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
function rasterPartitionsForDataset(
datasets: DatasetCreateResponse[],
representative: DatasetCreateResponse | null,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope: boolean,
): DatasetCreateResponse[] {
if (!representative) {
return []
}
if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) {
return [representative]
}
const productKey = datasetProductKey(representative)
const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '')
return datasets
.filter(
(dataset) =>
dataset.source_name === representative.source_name
&& (
isPartitionedBathymetry(representative)
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
: datasetProductKey(dataset) === productKey
)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
)
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
}
function pickThemeDataset(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): DatasetCreateResponse | null {
const candidates = datasets.filter(
(dataset) =>
datasetMatchesTheme(dataset, theme)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
)
candidates.sort((left, right) => {
const priorityScore = (dataset: DatasetCreateResponse) =>
(dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) +
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
(dataset.source_name === 'grb' ? 100_000 : 0) +
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_walous_land_cover' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_terrain' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0)
const priorityDifference = priorityScore(right) - priorityScore(left)
if (priorityDifference !== 0) return priorityDifference
const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime()
if (observedAtDifference !== 0) return observedAtDifference
const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime()
if (importedAtDifference !== 0) return importedAtDifference
return (right.feature_count ?? right.vector_summary?.feature_count ?? 0)
- (left.feature_count ?? left.vector_summary?.feature_count ?? 0)
})
return candidates[0] ?? null
}
function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
return dataset
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
: null
}
function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
?.source_metadata?.['temporal_series_label']
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
return configuredLabel
}
const first = items[0]
const source = first ? getDatasetDisplayName(first) : 'Tijdreeks'
const range = temporalRangeLabel(items)
return range ? `${source} (${range})` : source
}
function listThemeTemporalSeries(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
): TemporalSeriesGroup[] {
const groups = new Map<string, DatasetCreateResponse[]>()
for (const dataset of datasets) {
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
continue
}
if (temporalDatasetAreaMatch(dataset, selectedAreaId) === 'other') {
continue
}
const items = groups.get(dataset.temporal_series_key) ?? []
items.push(dataset)
groups.set(dataset.temporal_series_key, items)
}
const series = Array.from(groups.entries())
.map(([key, items]) => {
const ordered = deduplicateTemporalSnapshots(items)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.filter((group) => group.items.length >= 2)
const sourcesWithExactAreaSeries = new Set(
series
.filter((group) => group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact'))
.map((group) => group.items[0]?.source_name)
.filter(Boolean),
)
return series
.filter((group) => {
const sourceName = group.items[0]?.source_name
if (!sourceName || !sourcesWithExactAreaSeries.has(sourceName)) return true
return group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact')
})
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
}
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
return latest(right) - latest(left)
})
}
function formatObservationDate(value: string | null | undefined): string {
if (!value) {
return 'Geen peildatum'
}
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
const firstValue = items[0]?.observed_at
const lastValue = items[items.length - 1]?.observed_at
if (!firstValue || !lastValue) {
return null
}
const first = new Date(firstValue)
const last = new Date(lastValue)
if (first.getTime() === last.getTime()) {
return formatObservationDate(firstValue)
}
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
}
if (first.getUTCMonth() === last.getUTCMonth()) {
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
}
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
}
function formatDatasetObservation(dataset: DatasetCreateResponse): string {
if (dataset.source_name === 'vmm_flood_hazard') {
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
return firstMeasurement && lastMeasurement
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
: 'historische profielmetingen'
}
if (dataset.source_name === 'spw_bathymetry') {
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
if (Number.isFinite(observationYear)) {
return `referentiejaar ${observationYear}`
}
}
if (dataset.source_name === 'spw_walous_land_cover') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
return Number.isFinite(observationYear) ? `WALOUS referentiejaar ${observationYear}` : 'WALOUS landbedekking'
}
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
}
return formatObservationDate(dataset.observed_at)
}
function floodScenarioLabel(dataset: DatasetCreateResponse): string {
const configured = dataset.source_metadata?.['product_display_name']
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
}
interface MapWorkspaceProps { interface MapWorkspaceProps {
readOnly?: boolean readOnly?: boolean
@@ -0,0 +1,612 @@
/**
* The map workspace's domain layer: which themes exist, which persisted dataset
* answers a theme, and how a dataset describes itself to an operator.
*
* This was ~590 lines at the top of MapWorkspace.tsx, above a 3.200-line
* component. None of it is React, all of it is testable on its own, and both
* render paths in that component read from it.
*/
import type {
CoverageStatus,
DatasetCreateResponse,
VectorSelectionBBox,
} from '../../types'
import type { MapThemeAcquisition } from '../../hooks/useMapThemeSelectionInsights'
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
import {
deduplicateTemporalSnapshots,
isSelectionBoundedDataset,
selectedAreaCoverageZones,
selectionAnalysisScale,
selectionDimensions,
temporalDatasetAreaMatch,
} from './mapWorkspaceUtils'
export const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
export const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
export const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
export type DataThemeId =
| 'administrative'
| 'buildings'
| 'land_cover'
| 'space_occupation'
| 'open_space'
| 'population'
| 'forest'
| 'nature_value'
| 'agriculture'
| 'soil'
| 'water'
| 'bathymetry'
| 'flood_hazard'
| 'elevation'
| 'accessibility'
| 'services'
| 'roads'
| 'parcels'
| 'maritime_planning'
| 'marine_environment'
export interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
export interface TemporalSeriesGroup {
key: string
label: string
items: DatasetCreateResponse[]
}
export interface OnDemandMapProduct extends MapThemeAcquisition {
theme: DataThemeId
availabilityLabel: string
attribution: string
limitationMessage: string
coverageZones: string[]
}
export interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
acquisitionBboxes: VectorSelectionBBox[]
}
export function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return true
const dimensions = selectionDimensions(bbox)
if (product.kind === 'dhmv' || product.kind === 'spw_terrain' || product.kind === 'flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (product.kind === 'thematic_raster' || product.kind === 'walous') {
return dimensions.widthMetres <= 50_000
&& dimensions.heightMetres <= 50_000
&& dimensions.areaSquareMetres <= 2_800_000_000
}
return true
}
export const DATA_THEMES: DataTheme[] = [
{
id: 'administrative',
label: 'Bestuurlijke indeling',
shortLabel: 'Bestuursgebieden',
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
},
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'land_cover',
label: 'Landbedekking',
shortLabel: 'Landbedekking',
description: 'Fysieke en biologische bodembedekking uit een officieel regionaal classificatieraster.',
tokens: ['land_cover', 'land_cover_use', 'landbedekking', 'walous', 'occupation du sol'],
},
{
id: 'space_occupation',
label: 'Ruimtebeslag',
shortLabel: 'Ruimtebeslag',
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
},
{
id: 'open_space',
label: 'Open ruimte',
shortLabel: 'Open ruimte',
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
tokens: ['open_space', 'open ruimte', 'openruimte'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'nature_value',
label: 'Natuurwaarde',
shortLabel: 'BWK-oppervlakte',
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
},
{
id: 'agriculture',
label: 'Landbouw',
shortLabel: 'Landbouwgebruik',
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
},
{
id: 'soil',
label: 'Bodem',
shortLabel: 'Bodemkaart',
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'bathymetry',
label: 'Waterbodem',
shortLabel: 'Dwarsprofielen',
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
},
{
id: 'flood_hazard',
label: 'Overstroming',
shortLabel: 'Overstroomd oppervlak',
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
},
{
id: 'elevation',
label: 'Hoogte & reliëf',
shortLabel: 'Hoogte',
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
},
{
id: 'accessibility',
label: 'Bereikbaarheid',
shortLabel: 'Knooppuntwaarde',
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
},
{
id: 'services',
label: 'Voorzieningen',
shortLabel: 'Voorzieningenniveau',
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
{
id: 'maritime_planning',
label: 'Maritieme planning',
shortLabel: 'Plan- en gebruikszones',
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
},
{
id: 'marine_environment',
label: 'Mariene rapportagezones',
shortLabel: 'Zeegebieden',
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
},
]
export const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
administrative: 'admin',
buildings: 'buildings',
land_cover: 'land_cover_use',
space_occupation: 'land_cover_use',
open_space: 'land_cover_use',
population: 'population',
forest: 'land_cover_use',
nature_value: 'nature',
agriculture: 'land_cover_use',
soil: 'soil',
water: 'surface_water',
bathymetry: 'bathymetry',
flood_hazard: 'flood_climate',
elevation: 'elevation',
accessibility: 'roads',
services: 'population',
roads: 'roads',
parcels: 'parcels',
maritime_planning: 'maritime_planning',
marine_environment: 'marine_environment',
}
export function coverageStatusLabel(status: CoverageStatus): string {
const labels: Record<CoverageStatus, string> = {
operational: 'Beschikbaar',
partial: 'Gedeeltelijk',
not_configured: 'Niet gekoppeld',
unsupported: 'Niet ondersteund',
}
return labels[status]
}
export function coverageZoneLabel(zone: string): string {
const labels: Record<string, string> = {
belgium: 'Belgie',
flanders: 'Vlaanderen',
wallonia: 'Wallonie',
brussels: 'Brussel',
belgian_north_sea: 'Belgische Noordzee',
territorial_sea: 'Territoriale zee',
exclusive_economic_zone: 'EEZ',
continental_shelf: 'Continentaal plat',
}
return labels[zone] ?? zone
}
export const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
administrative: { fill: '#5f6f7f', line: '#344554' },
buildings: { fill: '#d45f3d', line: '#9f3e24' },
land_cover: { fill: '#4f7b4f', line: '#315c39' },
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
open_space: { fill: '#267a46', line: '#175c32' },
population: { fill: '#7559a6', line: '#5b3f88' },
forest: { fill: '#347950', line: '#225f3b' },
nature_value: { fill: '#9a4f64', line: '#74364a' },
agriculture: { fill: '#7b8f32', line: '#53671d' },
soil: { fill: '#9a7040', line: '#6f4c27' },
water: { fill: '#2676a8', line: '#155b85' },
bathymetry: { fill: '#0e7490', line: '#164e63' },
flood_hazard: { fill: '#1597c2', line: '#075985' },
elevation: { fill: '#a57a4b', line: '#315f59' },
accessibility: { fill: '#0f766e', line: '#115e59' },
services: { fill: '#b66d16', line: '#854d0e' },
roads: { fill: '#6b7280', line: '#4b5563' },
parcels: { fill: '#a7792f', line: '#7d571f' },
maritime_planning: { fill: '#2f7f8f', line: '#145d6a' },
marine_environment: { fill: '#3475a3', line: '#1c557d' },
}
export function datasetSearchText(dataset: DatasetCreateResponse): string {
return [
dataset.name,
dataset.original_filename,
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
dataset.source_metadata?.['product_display_name'],
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
// Governed raster products have one unambiguous semantic theme. Matching
// them by generic substrings (for example "water" in "waterdiepte") would
// make a flood scenario replace the permanent surface-water layer.
if (dataset.source_name === 'vmm_flood_hazard') {
return theme.id === 'flood_hazard'
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
return theme.id === 'bathymetry'
}
if (dataset.source_name === 'spw_bathymetry') {
return theme.id === 'bathymetry'
}
if (['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')) {
return theme.id === 'elevation'
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dataset.source_metadata?.['theme'] === theme.id
}
if (dataset.source_name === 'spw_walous_land_cover') {
return theme.id === 'land_cover'
}
if (dataset.source_name === 'dov_soil_map') {
return theme.id === 'soil'
}
if (dataset.source_name === 'ngi_adminvector') {
return theme.id === 'administrative'
}
if (dataset.source_name === 'rbins_msp_2026') {
return theme.id === 'maritime_planning'
}
if (dataset.source_name === 'rbins_marine_reporting_units') {
return theme.id === 'marine_environment'
}
const searchText = datasetSearchText(dataset)
return theme.tokens.some((token) => searchText.includes(token))
}
export function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.dataset_type === 'raster'
&& ['digitaal_vlaanderen_dhmv', 'spw_terrain', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
)
}
export function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
return Boolean(
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
)
}
export function datasetProductKey(dataset: DatasetCreateResponse): string {
return String(dataset.source_metadata?.['product_key'] ?? '')
}
export function datasetCoversSelectedArea(
dataset: DatasetCreateResponse,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): boolean {
if (isSelectionBoundedDataset(dataset.source_metadata)) {
return false
}
const selectedZones = selectedAreaCoverageZones(selectedAreaName)
const configuredZones = dataset.source_metadata?.['coverage_zones']
if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) {
const datasetZones = configuredZones.map((zone) => String(zone))
if (!selectedZones.some((zone) => datasetZones.includes(zone))) {
return false
}
}
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
if (isPartitionedBathymetry(dataset)) {
return regionalScope
? true
: Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
if (coverageScope !== 'municipality' || !dataset.area_id) {
return true
}
if (regionalScope) {
return true
}
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
}
export function rasterPartitionsForDataset(
datasets: DatasetCreateResponse[],
representative: DatasetCreateResponse | null,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope: boolean,
): DatasetCreateResponse[] {
if (!representative) {
return []
}
if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) {
return [representative]
}
const productKey = datasetProductKey(representative)
const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '')
return datasets
.filter(
(dataset) =>
dataset.source_name === representative.source_name
&& (
isPartitionedBathymetry(representative)
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
: datasetProductKey(dataset) === productKey
)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
)
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
}
export function pickThemeDataset(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
selectedAreaName: string | null | undefined,
regionalScope = false,
): DatasetCreateResponse | null {
const candidates = datasets.filter(
(dataset) =>
datasetMatchesTheme(dataset, theme)
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
)
candidates.sort((left, right) => {
const priorityScore = (dataset: DatasetCreateResponse) =>
(dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) +
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
(dataset.source_name === 'grb' ? 100_000 : 0) +
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_walous_land_cover' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_terrain' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
(dataset.source_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) +
(dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) +
(dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0)
const priorityDifference = priorityScore(right) - priorityScore(left)
if (priorityDifference !== 0) return priorityDifference
const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime()
if (observedAtDifference !== 0) return observedAtDifference
const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime()
if (importedAtDifference !== 0) return importedAtDifference
return (right.feature_count ?? right.vector_summary?.feature_count ?? 0)
- (left.feature_count ?? left.vector_summary?.feature_count ?? 0)
})
return candidates[0] ?? null
}
export function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
return dataset
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
: null
}
export function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
?.source_metadata?.['temporal_series_label']
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
return configuredLabel
}
const first = items[0]
const source = first ? getDatasetDisplayName(first) : 'Tijdreeks'
const range = temporalRangeLabel(items)
return range ? `${source} (${range})` : source
}
export function listThemeTemporalSeries(
datasets: DatasetCreateResponse[],
theme: DataTheme,
selectedAreaId: string | null,
): TemporalSeriesGroup[] {
const groups = new Map<string, DatasetCreateResponse[]>()
for (const dataset of datasets) {
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
continue
}
if (temporalDatasetAreaMatch(dataset, selectedAreaId) === 'other') {
continue
}
const items = groups.get(dataset.temporal_series_key) ?? []
items.push(dataset)
groups.set(dataset.temporal_series_key, items)
}
const series = Array.from(groups.entries())
.map(([key, items]) => {
const ordered = deduplicateTemporalSnapshots(items)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.filter((group) => group.items.length >= 2)
const sourcesWithExactAreaSeries = new Set(
series
.filter((group) => group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact'))
.map((group) => group.items[0]?.source_name)
.filter(Boolean),
)
return series
.filter((group) => {
const sourceName = group.items[0]?.source_name
if (!sourceName || !sourcesWithExactAreaSeries.has(sourceName)) return true
return group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact')
})
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
}
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
return latest(right) - latest(left)
})
}
export function formatObservationDate(value: string | null | undefined): string {
if (!value) {
return 'Geen peildatum'
}
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
export function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
const firstValue = items[0]?.observed_at
const lastValue = items[items.length - 1]?.observed_at
if (!firstValue || !lastValue) {
return null
}
const first = new Date(firstValue)
const last = new Date(lastValue)
if (first.getTime() === last.getTime()) {
return formatObservationDate(firstValue)
}
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
}
if (first.getUTCMonth() === last.getUTCMonth()) {
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
}
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
}
export function formatDatasetObservation(dataset: DatasetCreateResponse): string {
if (dataset.source_name === 'vmm_flood_hazard') {
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
}
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
return firstMeasurement && lastMeasurement
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
: 'historische profielmetingen'
}
if (dataset.source_name === 'spw_bathymetry') {
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
if (Number.isFinite(observationYear)) {
return `referentiejaar ${observationYear}`
}
}
if (dataset.source_name === 'spw_walous_land_cover') {
const observationYear = Number(dataset.source_metadata?.['observation_year'])
return Number.isFinite(observationYear) ? `WALOUS referentiejaar ${observationYear}` : 'WALOUS landbedekking'
}
const period = dataset.source_metadata?.['acquisition_period']
if (typeof period === 'string' && period.trim()) {
return `opnameperiode ${period}`
}
return formatObservationDate(dataset.observed_at)
}
export function floodScenarioLabel(dataset: DatasetCreateResponse): string {
const configured = dataset.source_metadata?.['product_display_name']
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
}