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) +}