From 39c12822bca102a30f1a8eed480ed082fe161119 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 22 Aug 2026 18:49:06 +0200 Subject: [PATCH] extract the map workspace's domain layer out of the component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/frontend_contract.py | 34 + backend/tests/test_rc4_national_coverage.py | 7 +- backend/tests/test_rc9_ux_release_contract.py | 9 +- .../test_sprint105_map_feature_extract.py | 5 +- .../tests/test_sprint106_map_bbox_extract.py | 4 +- ...sprint186_map_first_geographic_explorer.py | 6 +- .../test_sprint187_temporal_map_foundation.py | 3 +- ...t_sprint188_official_landuse_timeseries.py | 3 +- .../test_sprint192_regional_map_state.py | 4 +- ...t_sprint198_detection_review_completion.py | 5 +- ...est_sprint200_temporal_explorer_handoff.py | 6 +- .../tests/test_sprint204_bwk_natura2000.py | 3 +- ...t_sprint205_agricultural_parcel_history.py | 3 +- backend/tests/test_sprint205_dhmv_terrain.py | 3 +- ..._sprint206_buildings_addresses_register.py | 3 +- .../tests/test_sprint208_vmm_flood_hazard.py | 3 +- .../test_sprint210_regional_bwk_natura2000.py | 3 +- backend/tests/test_sprint214_dov_soil_map.py | 3 +- ...test_sprint219_regional_raster_explorer.py | 4 +- .../test_sprint223_governed_grb_refresh.py | 5 +- .../test_sprint233_operational_completion.py | 4 +- .../test_sprint235_bathymetry_profiles.py | 3 +- .../test_sprint236_bathymetry_expansion.py | 5 +- ...t_sprint237_flanders_thematic_on_demand.py | 8 +- .../test_sprint239_bounded_grb_acquisition.py | 4 +- frontend/src/components/map/MapWorkspace.tsx | 619 +----------------- .../src/components/map/mapWorkspaceThemes.ts | 612 +++++++++++++++++ 27 files changed, 728 insertions(+), 643 deletions(-) create mode 100644 frontend/src/components/map/mapWorkspaceThemes.ts diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index bfb28880..565bf360 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -22,12 +22,46 @@ ROOT = Path(__file__).resolve().parents[2] 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: """Read one frontend source file relative to ``frontend/src``.""" 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: """Every identifier must appear in ``source``. diff --git a/backend/tests/test_rc4_national_coverage.py b/backend/tests/test_rc4_national_coverage.py index faeb733d..6ed903eb 100644 --- a/backend/tests/test_rc4_national_coverage.py +++ b/backend/tests/test_rc4_national_coverage.py @@ -13,6 +13,7 @@ from app.models import Area, Dataset, Project from app.schemas.coverage import CoverageBBox from app.services.coverage_registry_service import CoverageRegistryService, THEMES, ZONES from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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: root = Path(__file__).resolve().parents[2] 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( - encoding="utf-8" - ) + map_workspace = read_map_workspace() assert '"belgium_municipalities": "administrative"' 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") 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") - 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 "nationalProject" in workspace_hook diff --git a/backend/tests/test_rc9_ux_release_contract.py b/backend/tests/test_rc9_ux_release_contract.py index 169cbbe8..f269adf7 100644 --- a/backend/tests/test_rc9_ux_release_contract.py +++ b/backend/tests/test_rc9_ux_release_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations import json from pathlib import Path +from tests.frontend_contract import read_map_workspace 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: app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") - map_workspace = ( - ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx" - ).read_text(encoding="utf-8") + map_workspace = read_map_workspace() geo_map = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text( encoding="utf-8" ) @@ -42,9 +41,7 @@ def test_rc9_performance_budgets_are_documented_and_visible() -> None: ROOT / "frontend" / "src" / "lib" / "performanceBudget.ts" ).read_text(encoding="utf-8") docs = (ROOT / "docs" / "UX_PERFORMANCE_BUDGETS.md").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 "COVERAGE_RESPONSE_BUDGET_MS = 4_000" in budget assert "MAP_ANALYSIS_BUDGET_MS = 15_000" in budget diff --git a/backend/tests/test_sprint105_map_feature_extract.py b/backend/tests/test_sprint105_map_feature_extract.py index 8bd0e230..c24fc2e8 100644 --- a/backend/tests/test_sprint105_map_feature_extract.py +++ b/backend/tests/test_sprint105_map_feature_extract.py @@ -1,15 +1,14 @@ from __future__ import annotations from pathlib import Path +from tests.frontend_contract import read_map_workspace ROOT = Path(__file__).resolve().parents[2] def test_map_workspace_exposes_feature_extract_actions() -> None: - map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text( - encoding="utf-8" - ) + map_workspace = read_map_workspace() assert "Selectie en extractie" in map_workspace assert "downloadSelectedMapFeature" in map_workspace diff --git a/backend/tests/test_sprint106_map_bbox_extract.py b/backend/tests/test_sprint106_map_bbox_extract.py index f9367c9b..b748c5aa 100644 --- a/backend/tests/test_sprint106_map_bbox_extract.py +++ b/backend/tests/test_sprint106_map_bbox_extract.py @@ -11,7 +11,7 @@ from shapely.geometry import Polygon, box from app.core.errors import AppError from app.models import Dataset, VectorFeature 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] @@ -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: 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") app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") extract_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint186_map_first_geographic_explorer.py b/backend/tests/test_sprint186_map_first_geographic_explorer.py index 26bd0e54..2a7bee86 100644 --- a/backend/tests/test_sprint186_map_first_geographic_explorer.py +++ b/backend/tests/test_sprint186_map_first_geographic_explorer.py @@ -3,7 +3,7 @@ from __future__ import annotations import re 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] @@ -15,7 +15,7 @@ def read(path: str) -> str: def test_map_first_explorer_is_the_default_product_flow() -> None: app = read("frontend/src/App.tsx") - workspace = read("frontend/src/components/map/MapWorkspace.tsx") + workspace = read_map_workspace() assert "useState('map')" in app 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: - workspace = read("frontend/src/components/map/MapWorkspace.tsx") + workspace = read_map_workspace() geomap = read("frontend/src/components/GeoMap.tsx") styles = read("frontend/src/styles/app.css") diff --git a/backend/tests/test_sprint187_temporal_map_foundation.py b/backend/tests/test_sprint187_temporal_map_foundation.py index 21daaa41..02ec70d8 100644 --- a/backend/tests/test_sprint187_temporal_map_foundation.py +++ b/backend/tests/test_sprint187_temporal_map_foundation.py @@ -16,6 +16,7 @@ from app.schemas.temporal import TemporalComparisonRequest, TemporalObjectChange from app.services.dataset_service import DatasetService from app.services.temporal_analysis_service import TemporalAnalysisService from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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: - 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") 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") diff --git a/backend/tests/test_sprint188_official_landuse_timeseries.py b/backend/tests/test_sprint188_official_landuse_timeseries.py index 4c3fcd48..7aed29a3 100644 --- a/backend/tests/test_sprint188_official_landuse_timeseries.py +++ b/backend/tests/test_sprint188_official_landuse_timeseries.py @@ -11,6 +11,7 @@ import rasterio from rasterio.transform import from_origin from shapely.geometry import Polygon, shape from shapely.ops import transform +from tests.frontend_contract import read_map_workspace 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: 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") - 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") premium_css = (ROOT / "frontend/src/styles/premium.css").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint192_regional_map_state.py b/backend/tests/test_sprint192_regional_map_state.py index 7bd95974..ca6b3ca9 100644 --- a/backend/tests/test_sprint192_regional_map_state.py +++ b/backend/tests/test_sprint192_regional_map_state.py @@ -1,5 +1,5 @@ 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] @@ -10,7 +10,7 @@ def read(path: str) -> str: 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 "clearAreaSelection()\n onSelectMapArea(areaId)" in workspace diff --git a/backend/tests/test_sprint198_detection_review_completion.py b/backend/tests/test_sprint198_detection_review_completion.py index bd7b5d66..60ff5de9 100644 --- a/backend/tests/test_sprint198_detection_review_completion.py +++ b/backend/tests/test_sprint198_detection_review_completion.py @@ -4,6 +4,7 @@ import importlib.util from pathlib import Path import pytest +from tests.frontend_contract import read_map_workspace 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: - workspace = ( - ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx" - ).read_text(encoding="utf-8") + workspace = read_map_workspace() # Both matching methods must be named; the exact label may change. assert "strikte" in workspace.casefold() assert "Rechthoekcontrole" in workspace diff --git a/backend/tests/test_sprint200_temporal_explorer_handoff.py b/backend/tests/test_sprint200_temporal_explorer_handoff.py index afa4f96b..ae7b368c 100644 --- a/backend/tests/test_sprint200_temporal_explorer_handoff.py +++ b/backend/tests/test_sprint200_temporal_explorer_handoff.py @@ -1,5 +1,5 @@ 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] @@ -10,7 +10,7 @@ def read(path: str) -> str: 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 "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: - workspace = read("frontend/src/components/map/MapWorkspace.tsx") + workspace = read_map_workspace() assert "analysisMode === 'current'" in workspace # Theme availability depends on a dataset or an on-demand product; diff --git a/backend/tests/test_sprint204_bwk_natura2000.py b/backend/tests/test_sprint204_bwk_natura2000.py index 9e57606d..71277bc8 100644 --- a/backend/tests/test_sprint204_bwk_natura2000.py +++ b/backend/tests/test_sprint204_bwk_natura2000.py @@ -13,6 +13,7 @@ from shapely.ops import transform as transform_geometry from app.models import Dataset from app.schemas.operations import VectorSelectionSummary from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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") 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") - 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") assert "COPY scripts/provision_mol_bwk_natura2000.py" in dockerfile diff --git a/backend/tests/test_sprint205_agricultural_parcel_history.py b/backend/tests/test_sprint205_agricultural_parcel_history.py index 3c2343ea..c7c73d5a 100644 --- a/backend/tests/test_sprint205_agricultural_parcel_history.py +++ b/backend/tests/test_sprint205_agricultural_parcel_history.py @@ -14,6 +14,7 @@ from shapely.ops import transform as transform_geometry from app.models import Dataset from app.schemas.operations import VectorSelectionSummary from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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") 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") - 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") dataset_display = (ROOT / "frontend" / "src" / "lib" / "datasetDisplay.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint205_dhmv_terrain.py b/backend/tests/test_sprint205_dhmv_terrain.py index 4b5de6e3..3c714b79 100644 --- a/backend/tests/test_sprint205_dhmv_terrain.py +++ b/backend/tests/test_sprint205_dhmv_terrain.py @@ -21,6 +21,7 @@ from app.models import Area, Dataset, DatasetVersion, Job, Project, SourceRegist from app.schemas.dhmv import DhmvAcquireRequest, TerrainPartitionSelectionRequest, TerrainSelectionRequest from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.terrain_analysis_service import TerrainAnalysisService +from tests.frontend_contract import read_map_workspace ROOT = Path(__file__).resolve().parents[2] @@ -616,7 +617,7 @@ def test_frontend_and_runtime_expose_dhmv_workflow() -> None: capabilities_source = ( ROOT / "frontend" / "src" / "lib" / "datasetCapabilities.ts" ).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") service_source = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint206_buildings_addresses_register.py b/backend/tests/test_sprint206_buildings_addresses_register.py index c288494f..905036a2 100644 --- a/backend/tests/test_sprint206_buildings_addresses_register.py +++ b/backend/tests/test_sprint206_buildings_addresses_register.py @@ -12,6 +12,7 @@ from shapely.ops import transform as transform_geometry from app.models import Dataset from app.schemas.operations import VectorSelectionSummary from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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") 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") - 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") display = (ROOT / "frontend/src/lib/datasetDisplay.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint208_vmm_flood_hazard.py b/backend/tests/test_sprint208_vmm_flood_hazard.py index ede20cf1..bbe077ba 100644 --- a/backend/tests/test_sprint208_vmm_flood_hazard.py +++ b/backend/tests/test_sprint208_vmm_flood_hazard.py @@ -25,6 +25,7 @@ from app.schemas.assistant import AssistantQueryRequest from app.services.geo_assistant_service import GeoAssistantService from app.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService +from tests.frontend_contract import read_map_workspace 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") 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") - 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 "/raster/flood-hazard/select" in operator assert "concurrent_flood_volume_m3" in operator diff --git a/backend/tests/test_sprint210_regional_bwk_natura2000.py b/backend/tests/test_sprint210_regional_bwk_natura2000.py index 8fc50e5c..d4a13838 100644 --- a/backend/tests/test_sprint210_regional_bwk_natura2000.py +++ b/backend/tests/test_sprint210_regional_bwk_natura2000.py @@ -11,6 +11,7 @@ from shapely.geometry import box, mapping, shape from app.models import Dataset from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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") 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") - 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") model = (ROOT / "backend/app/models/entities.py").read_text(encoding="utf-8") migration = ( diff --git a/backend/tests/test_sprint214_dov_soil_map.py b/backend/tests/test_sprint214_dov_soil_map.py index ca52ebc3..907bec54 100644 --- a/backend/tests/test_sprint214_dov_soil_map.py +++ b/backend/tests/test_sprint214_dov_soil_map.py @@ -10,6 +10,7 @@ from shapely.ops import transform as transform_geometry from app.models import Dataset from app.services.vector_feature_service import VectorFeatureService +from tests.frontend_contract import read_map_workspace 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") 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") - 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 "vector_features" in operator diff --git a/backend/tests/test_sprint219_regional_raster_explorer.py b/backend/tests/test_sprint219_regional_raster_explorer.py index 704ffd9f..cfabf6fe 100644 --- a/backend/tests/test_sprint219_regional_raster_explorer.py +++ b/backend/tests/test_sprint219_regional_raster_explorer.py @@ -1,5 +1,5 @@ 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] @@ -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: 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") api = (ROOT / "frontend/src/services/api/datasets.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint223_governed_grb_refresh.py b/backend/tests/test_sprint223_governed_grb_refresh.py index 1dc8134c..1232767a 100644 --- a/backend/tests/test_sprint223_governed_grb_refresh.py +++ b/backend/tests/test_sprint223_governed_grb_refresh.py @@ -13,6 +13,7 @@ import pytest from app.core.errors import AppError from app.models import Dataset 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) @@ -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: 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") feature_tiebreaker = workspace.index("right.feature_count", observed_sort) 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: 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 "useState(() =>" in workspace assert "return themeIdForDataset(selectedDataset) ?? 'buildings'" in workspace diff --git a/backend/tests/test_sprint233_operational_completion.py b/backend/tests/test_sprint233_operational_completion.py index a9c8cfd3..f8f2d11f 100644 --- a/backend/tests/test_sprint233_operational_completion.py +++ b/backend/tests/test_sprint233_operational_completion.py @@ -20,7 +20,7 @@ from app.services.storage_service import StorageService from app.services.source_registry_service import SourceRegistryService from app.services.temporal_analysis_service import TemporalAnalysisService 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: @@ -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: 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") api = (root / "frontend/src/services/api/exports.ts").read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint235_bathymetry_profiles.py b/backend/tests/test_sprint235_bathymetry_profiles.py index 1455a959..703d3f4b 100644 --- a/backend/tests/test_sprint235_bathymetry_profiles.py +++ b/backend/tests/test_sprint235_bathymetry_profiles.py @@ -19,6 +19,7 @@ from app.models import Area, Dataset, Job, Project from app.schemas.bathymetry import BathymetryProfileAcquireRequest from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService from app.services.dataset_service import DatasetService +from tests.frontend_contract import read_map_workspace 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 "COPY scripts/provision_mol_bathymetry_profiles.py" in dockerfile 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 "regional_partitions_complete" in map_workspace assert "historische profielen" in map_workspace diff --git a/backend/tests/test_sprint236_bathymetry_expansion.py b/backend/tests/test_sprint236_bathymetry_expansion.py index c35269d1..1967abd1 100644 --- a/backend/tests/test_sprint236_bathymetry_expansion.py +++ b/backend/tests/test_sprint236_bathymetry_expansion.py @@ -31,6 +31,7 @@ if str(SCRIPTS) not in sys.path: sys.path.insert(0, str(SCRIPTS)) import provision_flanders_geographic_scope as flanders_scope # noqa: E402 +from tests.frontend_contract import read_map_workspace class BinaryResponse: @@ -463,9 +464,7 @@ def test_frontend_uses_partitioned_bathymetry_selection_for_regional_scope() -> focus = ( ROOT / "frontend" / "src" / "config" / "primaryFocus.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() theme_hook = ( ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts" ).read_text(encoding="utf-8") diff --git a/backend/tests/test_sprint237_flanders_thematic_on_demand.py b/backend/tests/test_sprint237_flanders_thematic_on_demand.py index 9803e100..f9afc37f 100644 --- a/backend/tests/test_sprint237_flanders_thematic_on_demand.py +++ b/backend/tests/test_sprint237_flanders_thematic_on_demand.py @@ -1,5 +1,5 @@ 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] @@ -10,7 +10,7 @@ def read(path: str) -> str: 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") selection_hook = read("frontend/src/hooks/useMapThemeSelectionInsights.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: - workspace = read("frontend/src/components/map/MapWorkspace.tsx") + workspace = read_map_workspace() app = read("frontend/src/App.tsx") 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: - workspace = read("frontend/src/components/map/MapWorkspace.tsx") + workspace = read_map_workspace() assert "regionalOnDemandThemeActive" in workspace # Both regional source kinds need a bounded selection before acquiring. diff --git a/backend/tests/test_sprint239_bounded_grb_acquisition.py b/backend/tests/test_sprint239_bounded_grb_acquisition.py index e9144fc1..76a27b86 100644 --- a/backend/tests/test_sprint239_bounded_grb_acquisition.py +++ b/backend/tests/test_sprint239_bounded_grb_acquisition.py @@ -19,7 +19,7 @@ from app.models import Area, Dataset, Job, Project from app.schemas.grb import GrbAcquireRequest from app.services.dataset_service import DatasetService 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] @@ -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: 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") - 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") assert "datasetsApi.acquireGrb" in selection_hook diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 11e709b0..904cf098 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -25,7 +25,6 @@ import { bboxesEqual, copyText, datasetIntersectsSelection, - deduplicateTemporalSnapshots, downloadJsonFile, formatArea, formatBboxLabel, @@ -35,14 +34,12 @@ import { getFeatureCollectionBBox, getFeatureGeometrySummary, isMunicipalityAreaName, - isSelectionBoundedDataset, normalizeBboxFromCorners, operationalScopeProjectLabel, parseBboxInput, persistedDatasetSupportsSelection, productCoversZones, readablePropertyName, - resultCountLabel, resultMetricLabel, safeFileStem, selectedAreaCoverageZones, @@ -53,596 +50,34 @@ import { selectionFeatureLimit, selectionMetricLabel, splitSelectionBbox, - temporalDatasetAreaMatch, } from './mapWorkspaceUtils' - -const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson' -const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson' -const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = [] - -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' - -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 = { - 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 = { - operational: 'Beschikbaar', - partial: 'Gedeeltelijk', - not_configured: 'Niet gekoppeld', - unsupported: 'Niet ondersteund', - } - return labels[status] -} - -function coverageZoneLabel(zone: string): string { - const labels: Record = { - 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 = { - 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() - 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) -} +import { + COVERAGE_THEME_BY_MAP_THEME, + DATA_THEMES, + DATA_THEME_MAP_STYLES, + DEFAULT_AREA_SELECTION_FILENAME, + DEFAULT_SELECTED_FEATURE_FILENAME, + EMPTY_TEMPORAL_SERIES, + coverageStatusLabel, + coverageZoneLabel, + datasetCoversSelectedArea, + datasetProductKey, + floodScenarioLabel, + formatDatasetObservation, + formatObservationDate, + isPartitionedBathymetry, + isPartitionedRaster, + listThemeTemporalSeries, + pickThemeDataset, + productSupportsSelection, + rasterPartitionsForDataset, + themeIdForDataset, + type DataTheme, + type DataThemeId, + type OnDemandMapProduct, + type PlannedOnDemandMapProduct, + type TemporalSeriesGroup, +} from './mapWorkspaceThemes' interface MapWorkspaceProps { readOnly?: boolean diff --git a/frontend/src/components/map/mapWorkspaceThemes.ts b/frontend/src/components/map/mapWorkspaceThemes.ts new file mode 100644 index 00000000..0d816a49 --- /dev/null +++ b/frontend/src/components/map/mapWorkspaceThemes.ts @@ -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 = { + 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 = { + operational: 'Beschikbaar', + partial: 'Gedeeltelijk', + not_configured: 'Niet gekoppeld', + unsupported: 'Niet ondersteund', + } + return labels[status] +} + +export function coverageZoneLabel(zone: string): string { + const labels: Record = { + 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 = { + 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() + 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) +}