From c6837ec1b22953e395e2188592b8445146bee184 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 22 Aug 2026 22:38:25 +0200 Subject: [PATCH] split the map workspace into a view model and two views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MapWorkspace.tsx was 3.157 lines: a props interface, 1.200 lines of derived state and handlers, and two complete render paths — the map-first explorer and the advanced workbench behind it. It is now five modules, and the container is nineteen lines that choose between the two. The obstacle was the props signature. The explorer reads 97 derived values and the workbench 40, so passing them individually would have produced a 97-field interface — worse than the file it replaced. Extracting the derived state into a hook that returns one object solves it: MapWorkspaceViewModel is ReturnType, so the shape is derived from what the hook actually produces and cannot drift from it. Each view then names two typed objects, and the JSX moved unchanged. The contract tests found the one place where widening a negative assertion is wrong. "The map workspace performs no transport" was true of the old file and false of the whole feature, because the hooks call the API by design. It is now scoped to the presentational modules, which is what it always meant. Co-Authored-By: Claude Opus 5 --- backend/tests/frontend_contract.py | 10 + ...est_sprint110_map_qa_evidence_drilldown.py | 3 +- backend/tests/test_sprint189_kempen_scope.py | 2 +- .../test_sprint193_end_user_workbench.py | 2 +- .../test_sprint197_accuracy_review_loop.py | 2 +- .../test_sprint240_official_flemish_themes.py | 2 +- .../test_sprint30_workbench_components.py | 18 +- .../components/map/MapAdvancedWorkbench.tsx | 1138 ++++++ .../src/components/map/MapExplorerView.tsx | 1136 ++++++ frontend/src/components/map/MapWorkspace.tsx | 3265 +---------------- .../src/components/map/mapWorkspaceProps.ts | 104 + .../map/useMapWorkspaceViewModel.ts | 1472 ++++++++ 12 files changed, 3891 insertions(+), 3263 deletions(-) create mode 100644 frontend/src/components/map/MapAdvancedWorkbench.tsx create mode 100644 frontend/src/components/map/MapExplorerView.tsx create mode 100644 frontend/src/components/map/mapWorkspaceProps.ts create mode 100644 frontend/src/components/map/useMapWorkspaceViewModel.ts diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index a13d893b..6b94cd30 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -34,8 +34,10 @@ FRONTEND_SRC = ROOT / "frontend" / "src" FEATURE_SOURCES: dict[str, tuple[str, ...]] = { "map_workspace": ( "components/map/MapWorkspace.tsx", + "components/map/mapWorkspaceProps.ts", "components/map/mapWorkspaceThemes.ts", "components/map/mapWorkspaceUtils.ts", + "components/map/useMapWorkspaceViewModel.ts", "components/map/MapExplorerView.tsx", "components/map/MapAdvancedWorkbench.tsx", "hooks/useMapImageOverlays.ts", @@ -51,6 +53,14 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = { "hooks/useCoverageResolver.ts", "hooks/useOfficialMapProducts.ts", ), + # The presentational half of the map workspace. Transport belongs to the + # hooks, so "this performs no transport" is a contract about these modules + # and would fail — correctly — against the whole feature. + "map_workspace_presentation": ( + "components/map/MapWorkspace.tsx", + "components/map/MapExplorerView.tsx", + "components/map/MapAdvancedWorkbench.tsx", + ), "detection": ( "components/detection/DetectionLab.tsx", "components/detection/DetectionModelManagement.tsx", diff --git a/backend/tests/test_sprint110_map_qa_evidence_drilldown.py b/backend/tests/test_sprint110_map_qa_evidence_drilldown.py index 8c4fa4d4..afc28819 100644 --- a/backend/tests/test_sprint110_map_qa_evidence_drilldown.py +++ b/backend/tests/test_sprint110_map_qa_evidence_drilldown.py @@ -1,4 +1,5 @@ from pathlib import Path +from tests.frontend_contract import read_feature ROOT = Path(__file__).resolve().parents[2] @@ -11,7 +12,7 @@ def read_text(relative_path: str) -> str: def test_map_qa_result_exposes_quality_check_evidence_contract(): types = read_text("frontend/src/types.ts") hook = read_text("frontend/src/hooks/useMapSelectionQa.ts") - workspace = read_text("frontend/src/components/map/MapWorkspace.tsx") + workspace = read_feature("map_workspace") app = read_text("frontend/src/App.tsx") assert "quality_check_id?: string" in types diff --git a/backend/tests/test_sprint189_kempen_scope.py b/backend/tests/test_sprint189_kempen_scope.py index 116af173..d8548108 100644 --- a/backend/tests/test_sprint189_kempen_scope.py +++ b/backend/tests/test_sprint189_kempen_scope.py @@ -124,7 +124,7 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None: readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8") workspace = "\n".join( ( - (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8"), + read_feature("map_workspace"), (ROOT / "frontend/src/components/map/mapWorkspaceUtils.ts").read_text(encoding="utf-8"), ) ) diff --git a/backend/tests/test_sprint193_end_user_workbench.py b/backend/tests/test_sprint193_end_user_workbench.py index 40b208c6..af9824f3 100644 --- a/backend/tests/test_sprint193_end_user_workbench.py +++ b/backend/tests/test_sprint193_end_user_workbench.py @@ -11,7 +11,7 @@ def read(path: str) -> str: def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> None: project_hook = read("frontend/src/hooks/useProjectWorkspace.ts") - map_workspace = read("frontend/src/components/map/MapWorkspace.tsx") + map_workspace = read_feature("map_workspace") national_check = project_hook.index("const nationalProject") regional_check = project_hook.index("const regionalProject") diff --git a/backend/tests/test_sprint197_accuracy_review_loop.py b/backend/tests/test_sprint197_accuracy_review_loop.py index b85a4a8d..40f572f9 100644 --- a/backend/tests/test_sprint197_accuracy_review_loop.py +++ b/backend/tests/test_sprint197_accuracy_review_loop.py @@ -249,7 +249,7 @@ def test_map_detection_qa_uses_documented_footprint_threshold_and_honest_labels( assert "kandidaten" in hook assert "precision" in hook.lower() assert "false, iouThreshold" in app_source - assert "AI-kandidaten" in (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8") + assert "AI-kandidaten" in read_feature("map_workspace") assert "VectorFeature.id.in_(uuid_identifiers)" in evidence_service assert "VectorFeature.source_feature_id.in_(identifiers)" in evidence_service assert "for row in db.query(VectorFeature).filter(VectorFeature.dataset_id" not in evidence_service diff --git a/backend/tests/test_sprint240_official_flemish_themes.py b/backend/tests/test_sprint240_official_flemish_themes.py index d195acdb..6f36f8b8 100644 --- a/backend/tests/test_sprint240_official_flemish_themes.py +++ b/backend/tests/test_sprint240_official_flemish_themes.py @@ -415,7 +415,7 @@ def test_official_vector_routes_and_frontend_use_canonical_backend_path(monkeypa selection_hook = read_feature("map_workspace") catalog_hook = read_feature("map_workspace") - workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8") + workspace = read_feature("map_workspace") assert "datasetsApi.acquireOfficialVector" in selection_hook assert "datasetsApi.listOfficialVectorProducts" in catalog_hook assert "officialMapProducts.officialVector" in workspace diff --git a/backend/tests/test_sprint30_workbench_components.py b/backend/tests/test_sprint30_workbench_components.py index c35e1561..35e5edef 100644 --- a/backend/tests/test_sprint30_workbench_components.py +++ b/backend/tests/test_sprint30_workbench_components.py @@ -2,6 +2,7 @@ from __future__ import annotations import re from pathlib import Path +from tests.frontend_contract import read_feature ROOT = Path(__file__).resolve().parents[2] @@ -47,9 +48,7 @@ def test_quality_results_panel_owns_persisted_quality_check_markup() -> None: def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None: - map_workspace = ( - ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx" - ).read_text(encoding="utf-8") + map_workspace = read_feature("map_workspace") assert "GeoMap" in map_workspace assert "map-toolbar" in map_workspace @@ -58,8 +57,11 @@ def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None: assert "Actieve kaartlaag" in map_workspace assert "Objectinspectie" in map_workspace assert "onFeatureSelect={onSelectMapFeature}" in map_workspace - assert "fetch(" not in map_workspace - # The component owns markup and interaction, never transport. A bare "api" - # substring also matches useMapImageOverlays, so name what is forbidden. - assert "services/api" not in map_workspace - assert not re.search(r"\w*[Aa]pi\.(get|post|put|delete)\(", map_workspace) + # The markup layer owns interaction, never transport. Scoped to the + # presentational modules because the workspace's hooks do perform + # transport, by design. A bare "api" substring also matches + # useMapImageOverlays, so name what is actually forbidden. + presentation = read_feature("map_workspace_presentation") + assert "fetch(" not in presentation + assert "services/api" not in presentation + assert not re.search(r"\w*[Aa]pi\.(get|post|put|delete)\(", presentation) diff --git a/frontend/src/components/map/MapAdvancedWorkbench.tsx b/frontend/src/components/map/MapAdvancedWorkbench.tsx new file mode 100644 index 00000000..c4255031 --- /dev/null +++ b/frontend/src/components/map/MapAdvancedWorkbench.tsx @@ -0,0 +1,1138 @@ +/** + * The advanced workbench: layer controls, provenance and the full GIS workflow. + */ + +import GeoMap from '../GeoMap' +import { formatPerformanceDuration } from '../../lib/performanceBudget' +import { formatBboxLabel } from './mapWorkspaceUtils' +import { coverageStatusLabel, coverageZoneLabel } from './mapWorkspaceThemes' +import type { MapWorkspaceProps } from './mapWorkspaceProps' + +import type { MapWorkspaceViewModel } from './useMapWorkspaceViewModel' + +interface MapAdvancedWorkbenchProps { + props: MapWorkspaceProps + view: MapWorkspaceViewModel +} + +export function MapAdvancedWorkbench({ props, view }: MapAdvancedWorkbenchProps): JSX.Element { + const { + readOnly = false, + secondaryResultsContainer = null, + selectedProjectId, + projects, + areas, + selectedMapAreaId, + areaFeatureCollection, + mapFeatureCollection, + qualityEvidenceGeoJson = null, + qualityEvidenceFeatureCount = 0, + qualityEvidenceLoading = false, + qualityEvidenceError = null, + qualityEvidenceWarnings = [], + mapLayerLabel, + mapLayerSourceLabel, + mapLayerProvenance, + mapLayerVisible, + mapLayerOpacity, + areaLayerVisible, + areaLayerOpacity, + mapFeatureCount, + areaFeatureCount, + viewportVectorEnabled, + viewportVectorStatus, + viewportVectorTone, + fitMapDataOnChange, + mapContentMode, + analysisLayerAvailable, + selectedMapFeature, + selectedFeature = selectedMapFeature, + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, + coverage, + coverageLoading, + coverageError, + coverageDurationMs, + coverageBudgetExceeded, + workspaceLoading, + workspaceError, + selectionExporting, + selectionExportError, + latestSelectionExportPath, + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDataset, + latestSelectionDatasetName, + mapQaReferenceDatasets, + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, + latestMapSelectionQualityCheckId, + orthophotoAnalysisStage, + orthophotoAnalysisStatus, + orthophotoAnalysisError, + orthophotoAnalysisRunning, + orthophotoAnalysisQuality, + orthophotoAnalysisDetectionCount, + orthophotoProducts, + selectedOrthophotoProductKey, + orthophotoResult, + orthophotoImageUrl, + availableMapDatasets, + selectedMapDatasetId, + onSelectMapArea, + onActivateMunicipality, + onSetContextSourceLabel, + onSetContextLayerLabel, + onOpenDatasetInMap, + onSetAreaLayerVisible, + onSetAreaLayerOpacity, + onSetMapLayerVisible, + onSetMapLayerOpacity, + onSetMapContentMode, + onSelectMapFeature, + onMapViewportChange, + onSetMapSelectionBbox, + onRunMapSelectionExtract, + onClearMapSelectionExtract, + onExportMapSelection, + onPersistMapResult, + onDeriveMapSelectionDataset, + onSelectMapQaReferenceDataset, + onRunMapSelectionQa, + onOpenMapSelectionQualityEvidence, + onRunOrthophotoAnalysis, + onSelectOrthophotoProduct, + onClearQualityEvidence, + onRefreshProjectData, + onOpenAssistant, + onOpenExports, + } = props + const { + activeCoverageItems, + activeImageOverlays, + activeLayerBbox, + activeMetricLabel, + activeOnDemandMapProduct, + activeResultDataset, + activeScopeLabel, + activeSecondaryLabel, + activeSecondaryMetric, + activeSelectionResult, + activeSeriesIsDailyGrb, + activeSupportingMetrics, + activeTemporalSeries, + activeTemporalSeriesGroup, + activeTemporalSeriesGroups, + activeTheme, + activeThemeDataset, + activeThemeMapStyle, + activeThemePartitions, + advancedMode, + analysisMode, + analysisOverlayActive, + analyzeSelection, + areaIdForSelection, + areaSelectionPreviewFeatures, + bathymetryDocumentUrl, + bboxInput, + bboxSelectionMode, + clearAreaSelection, + clearTemporalComparison, + clearThemeInsights, + copyActiveThemeResult, + copyAreaSelection, + copySelectedMapFeatureProperties, + copyTemporalComparison, + coverageCounts, + currentSelectionBbox, + downloadActiveThemeResult, + downloadAreaSelection, + downloadSelectedMapFeature, + downloadTemporalComparison, + earlierDatasetId, + earlierTemporalOptions, + explorerMapFeatureCollection, + explorerSelectionFeatureCollection, + featureExtractionEntries, + featureGeometrySummary, + featureProperties, + featureSummaryEntries, + flandersScopeSelected, + floodHazardDatasets, + fullWorkflowError, + fullWorkflowMode, + fullWorkflowRunning, + fullWorkflowStatus, + handleAnalysisModeKeyDown, + handleMapBboxPreview, + handleMapBboxSelect, + handleMapCoordinateSelect, + handleSelectMapArea, + isBathymetryProfile, + laterDatasetId, + laterTemporalOptions, + liveJourneyError, + liveJourneyHasResult, + liveJourneyProcessing, + liveJourneyResultLabel, + liveJourneyStatus, + liveJourneyValidating, + liveJourneyVerified, + mapAnalysisDurationMs, + municipalityAreaCount, + officialMapProducts, + officialMapProductsError, + officialMapProductsLoading, + onDemandProductMap, + openSelectedDatabaseLayer, + persistActiveResultAndOpenDownloads, + rectangle, + regionalBathymetryThemeActive, + regionalScopeSelected, + resultsPanelOpen, + runAreaExtract, + runFullGisWorkflow, + runQuickAoiExtract, + runTemporalComparison, + saveAreaSelectionDataset, + saveAreaSelectionExport, + selectDataTheme, + selectedAreaBbox, + selectedAreaSquareMetres, + selectedDensity, + selectedDhmvProductKey, + selectedFeatureBbox, + selectedFloodHazardProductKey, + selectedLaterSnapshot, + selectedMapArea, + selectedMapDataset, + selectedOrthophotoProduct, + selectedResultProperties, + selectedThemeIds, + selectedThemes, + selectionRelevantThemes, + selectionScaleNotice, + setAdvancedMode, + setBboxInput, + setEarlierDatasetId, + setExplorerMode, + setFullWorkflowMode, + setLaterDatasetId, + setResultsPanelOpen, + setSelectedDhmvProductKey, + setSelectedFloodHazardDatasetId, + setSelectedFloodHazardProductKey, + setSelectedTemporalSeriesKey, + setSelectionBbox, + setThemeFilter, + startBboxSelection, + temporalComparison, + temporalComparisonError, + temporalComparisonLoading, + temporalSelectionValid, + thematicLegendMax, + thematicLegendMin, + themeDatasetMap, + themeFilter, + themeInsights, + themeResults, + themeResultsError, + themeResultsLoading, + themeTemporalSeriesMap, + usesDefaultOsmBasemap, + visibleThemes, + walloniaScopeSelected, + } = view + + return ( +
+ +
+
+

Ruimtelijke controle

+

Kaartwerkruimte

+
+ + {mapFeatureCollection ? `${mapFeatureCount} objecten` : viewportVectorEnabled ? 'zoom in om te laden' : 'geen laag'} + +
+ +
+ {usesDefaultOsmBasemap ? ( +
+ Publieke kaartondergrond + De publieke OpenStreetMap-ondergrond is actief. Configureer voor intensief gebruik een eigen kaartstijl. +
+ ) : null} +
+
+ Kaartinhoud +
+ + +
+
+ + +
+ + onSetAreaLayerOpacity(Number(event.target.value))} + data-testid="map-area-opacity" + /> +
+
+ + onSetMapLayerOpacity(Number(event.target.value))} + data-testid="map-layer-opacity" + /> +
+
+ {mapLayerLabel} + {selectedMapDataset ? `Databaselaag: ${selectedMapDataset.name}` : 'Geen databaselaag gekozen'} + {areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Geen werkgebied geladen'} + + {mapFeatureCollection + ? `${mapFeatureCount} objecten geladen` + : viewportVectorEnabled + ? 'Databaselaag gekozen; zichtbare objecten laden volgens de kaartuitsnede' + : 'Geen vector- of resultaatlaag geladen'} + + {viewportVectorEnabled && viewportVectorStatus ? ( + + {viewportVectorStatus} + + ) : null} +
+
+
+ +
+ +
+ +
+ + Details van de kaartlagen + + {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Kaartuitsnedelaag gekozen' : 'Geen actieve laag'} + + +
+
+ Werkgebied + {selectedMapArea?.name ?? 'Geen gebied geselecteerd'} + {areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Werkgebiedlaag uitgeschakeld'} +
+
+ Actieve kaartlaag + {mapLayerLabel} + {mapLayerSourceLabel} +
+
+ Status kaartobjecten + + {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Wachten op detail van de kaartuitsnede' : 'Geen laag getekend'} + + {mapLayerProvenance} +
+
+ Kaartbewijs kwaliteitscontrole + {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewijsobjecten` : 'Geen bewijslaag'} + {qualityEvidenceLoading ? 'Bewaard bewijs laden' : 'Overeenkomsten, onterecht gevonden en gemiste objecten'} +
+
+
+
+ Bron van de kaartlaag + {mapLayerSourceLabel} +
+
+ Herkomst + {mapLayerProvenance} +
+
+ Weergavestatus + + {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Laden volgens kaartuitsnede actief' : 'Geen actieve vector- of resultaatlaag'} + +
+
+ Kaartbewijs + {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} getekend` : 'uitgeschakeld'} +
+
+
+ + {qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? ( +
+
+ Kaartbewijs kwaliteitscontrole + {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewaarde objecten` : 'Niet geladen'} + {qualityEvidenceError ?

{qualityEvidenceError}

: null} + {qualityEvidenceWarnings.length > 0 ? ( +

+ {qualityEvidenceWarnings.length} {qualityEvidenceWarnings.length === 1 ? 'bewijsverwijzing kon' : 'bewijsverwijzingen konden'} niet worden teruggevonden. +

+ ) : null} +
+
+ Overeenkomst resultaat + Overeenkomst referentie + Onterecht gevonden + Gemist +
+ {onClearQualityEvidence ? ( + + ) : null} +
+ ) : null} + + {!mapFeatureCollection && !viewportVectorEnabled ? ( +
+ Geen actieve vector- of resultaatlaag +

Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen.

+ {availableMapDatasets.length > 0 ? ( + <> +

Open een beschikbare vectorlaag

+
+ {availableMapDatasets.map((dataset) => ( + + ))} +
+ + ) : ( +

Nog geen gebruiksklare vectorlagen beschikbaar. Voeg eerst een vectorbestand toe.

+ )} +
+ ) : null} + + {mapSelectionBbox ? ( +
+
+
+

Dekking van deze selectie

+

{activeTheme.label}

+
+ {coverageLoading ? controleren : null} +
+ {coverageError ?

{coverageError}

: null} + {coverageDurationMs !== null ? ( +

+ Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}. + {coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''} +

+ ) : null} + {coverage ? ( + <> +
+ {coverage.intersected_zones.map((zone) => ( + {coverageZoneLabel(zone)} + ))} + {coverage.outside_supported_scope ? deels buiten scope : null} +
+
+ {activeCoverageItems.map((item) => ( +
+ {coverageZoneLabel(item.zone)} + {coverageStatusLabel(item.status)} + {item.source_names.join(', ') || 'Geen broncontract'} +
+ ))} + {activeCoverageItems.length === 0 ? ( +

Deze selectie raakt geen bewaarde Belgische land- of zeezone.

+ ) : null} +
+
+ {coverageCounts.operational} beschikbaar + {coverageCounts.partial} gedeeltelijk + {coverageCounts.not_configured} niet gekoppeld + {coverageCounts.unsupported} niet ondersteund +
+ {coverage.warnings.map((warning) =>

{warning}

)} + + ) : !coverageLoading && !coverageError ? ( +

De dekkingsmatrix wordt bepaald zodra de selectie volledig is.

+ ) : null} +
+ ) : null} + +
+
+
+
+

Operationele GIS-controle

+

Databaselaag doorzoeken

+
+ {mapSelectionResult ? `${mapSelectionResult.feature_count} resultaten` : 'gereed'} +
+

+ Kies een bewaarde vectorlaag en doorzoek daarna de objecten in PostGIS binnen het werkgebied of de volledige laag. +

+
+
+ Databaselaag + {selectedMapDataset?.name ?? 'Kies een laag'} +
+
+ Begrenzing werkgebied + {selectedAreaBbox ? 'beschikbaar' : 'ontbreekt'} +
+
+ Begrenzing kaartlaag + {activeLayerBbox ? 'beschikbaar' : 'ontbreekt'} +
+
+ Resultaat + {mapSelectionResult ? `${mapSelectionResult.feature_count} objecten` : 'nog niet uitgevoerd'} +
+
+
+ + + +
+ {mapSelectionError ?

{mapSelectionError}

: null} +
+
+
+ 1 + Kaartlaag + {selectedMapDataset ? selectedMapDataset.name : 'Kies een databaselaag'} +
+
+ 2 + Begrenzing + {currentSelectionBbox ? 'Gebiedsbegrenzing gereed' : 'Gebruik het werkgebied of de laagbegrenzing'} +
+
+ 3 + Selectie + {mapSelectionResult ? `${mapSelectionResult.feature_count} bewaarde objecten` : 'Voer de ruimtelijke selectie uit'} +
+
+ 4 + Resultaatlaag + {latestSelectionDatasetName ?? 'Bewaar het selectieresultaat'} +
+
+ 5 + Kwaliteitscontrole + {mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n.v.t.'}` : 'Vergelijk met een referentielaag'} +
+
+ 6 + Download + {latestSelectionExportPath ? 'GeoJSON-bestand gereed' : 'Bewaar een downloadbestand'} +
+
+
+ + +
+ Selecteren, bewaren, controleren en downloaden + {fullWorkflowStatus} +
+ + + + + +
+ {selectionDatasetError ?

{selectionDatasetError}

: null} + {selectionExportError ?

{selectionExportError}

: null} + {mapSelectionQaError ?

{mapSelectionQaError}

: null} + {fullWorkflowError ?

{fullWorkflowError}

: null} +
+
+
+ + Geavanceerde selectie en inspectie + Coördinaten, objectextractie en ruwe eigenschappen + +
+
+
+
+

Bewaarde vectorobjecten

+

Gebiedsselectie

+
+ + {mapSelectionResult ? `${mapSelectionResult.feature_count} geselecteerd` : bboxSelectionMode ? 'selecteren' : 'gereed'} + +
+
+ {bboxSelectionMode ? (rectangle.firstCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'} + {formatBboxLabel(currentSelectionBbox)} +
+
+ + + + +
+
+ + + + + + +
+ {mapSelectionError ?

{mapSelectionError}

: null} + {mapSelectionResult ? ( +
+
+
+ Objecten + {mapSelectionResult.feature_count} +
+
+ Limiet + {mapSelectionResult.limit} +
+
+ Afgekapt + {mapSelectionResult.truncated ? 'ja' : 'nee'} +
+
+ Bron + Bewaarde databankobjecten +
+
+
+ + + + +
+ {selectionExportError ?

{selectionExportError}

: null} + {latestSelectionExportPath ? ( +

De geselecteerde download is bewaard.

+ ) : null} + {selectionDatasetError ?

{selectionDatasetError}

: null} + {latestSelectionDatasetName ? ( +

Bewaarde afgeleide laag: {latestSelectionDatasetName}

+ ) : null} + {latestSelectionDatasetName ? ( +
+ + + {mapSelectionQaError ?

{mapSelectionQaError}

: null} + {mapSelectionQaResult ? ( +
+
+
+

Kaartbewijs

+

Vergelijking van de bewaarde selectie

+
+ +
+
+
+ Precisie + {mapSelectionQaResult.precision ?? 'n.v.t.'} +
+
+ Herkenningsgraad + {mapSelectionQaResult.recall ?? 'n.v.t.'} +
+
+ F1 + {mapSelectionQaResult.f1_score ?? 'n.v.t.'} +
+
+ Gemiddelde overlap + {mapSelectionQaResult.mean_iou ?? 'n.v.t.'} +
+
+ Overeenkomsten + {mapSelectionQaResult.matches} +
+
+ Onterecht gevonden + {mapSelectionQaResult.false_positives} +
+
+ Gemist + {mapSelectionQaResult.false_negatives} +
+
+ Status bewijs + {latestMapSelectionQualityCheckId ? 'bewaard' : 'niet bewaard'} +
+
+ {mapSelectionQaResult.warnings.length > 0 ? ( +
+ Aandachtspunten +
    + {mapSelectionQaResult.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+ ) : null} + {areaSelectionPreviewFeatures.length > 0 ? ( +
+ + + + + + + + + + {areaSelectionPreviewFeatures.map((feature, index) => ( + + + + + + ))} + +
ObjectKlasseBronreferentie
{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}{String(feature.properties?.['feature_class'] ?? 'n.v.t.')}{String(feature.properties?.['source_feature_id'] ?? 'n.v.t.')}
+
+ ) : ( +

Geen bewaarde vectorobjecten kruisen deze selectie.

+ )} +
+ ) : null} +
+
+
+
+

Geselecteerd object

+

Selectie en extractie

+
+ {selectedMapFeature ? 'gereed' : 'wachten'} +
+ {selectedMapFeature ? ( + <> + {isBathymetryProfile ? ( +
+
+ Waterloop + {String(featureProperties?.['watercourse_name'] ?? 'Onbekende waterloop')} +
+
+ Profiel + {String(featureProperties?.['profile_number'] ?? 'n.v.t.')} +
+
+ Meetdatum + {String(featureProperties?.['measurement_date'] ?? 'Niet geregistreerd')} +
+
+ Geregistreerde diepte + + {typeof featureProperties?.['recorded_depth_m'] === 'number' + ? `${featureProperties['recorded_depth_m'].toLocaleString('nl-BE')} m` + : 'Niet als veld beschikbaar'} + +
+ {bathymetryDocumentUrl ? ( + + Officieel profielblad openen + + ) : ( + Voor dit meetpunt is geen digitaal profielblad gekoppeld. + )} +

+ Historisch dwarsprofiel. Dit punt is geen continue actuele bodemkaart en levert zonder + gelijktijdig waterpeil geen actueel watervolume. +

+
+ ) : null} +
+
+ Geometrie + {featureGeometrySummary.geometryType} +
+
+ Coördinaten + {featureGeometrySummary.coordinateCount} +
+
+ Eigenschappen + {featureExtractionEntries.length} +
+
+ BBox EPSG:4326 + {featureGeometrySummary.bboxLabel} +
+
+
+ + + +
+ {featureExtractionEntries.length > 0 ? ( +
+ + + + + + + + + {featureExtractionEntries.map(([key, value]) => ( + + + + + ))} + +
EigenschapWaarde
{key}{typeof value === 'object' ? JSON.stringify(value) : String(value)}
+
+ ) : ( +

Het geselecteerde object heeft geometrie maar geen bewaarde eigenschappen.

+ )} + + ) : ( +
+ Geen object geselecteerd +

Klik op een zichtbaar kaartobject om de eigenschappen en GeoJSON te bekijken.

+
+ )} +
+
+
+

Objectinspectie

+ {selectedMapFeature?.geometry?.type ?? 'geen'} +
+ {selectedMapFeature ? ( + <> + {featureSummaryEntries.length > 0 ? ( +
+ {featureSummaryEntries.map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+ ) : null} +
{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}
+ + ) : ( +

Klik op een zichtbaar kaartobject om de eigenschappen te bekijken.

+ )} +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/map/MapExplorerView.tsx b/frontend/src/components/map/MapExplorerView.tsx new file mode 100644 index 00000000..980bcc46 --- /dev/null +++ b/frontend/src/components/map/MapExplorerView.tsx @@ -0,0 +1,1136 @@ +/** + * The map-first explorer: one map, the themes beside it, results underneath. + */ + +import { BoxSelect, ChevronLeft, ChevronRight, MapPinned, Play, Search, SlidersHorizontal, Trash2 } from 'lucide-react' +import GeoMap from '../GeoMap' +import { isValueRampRasterSource } from '../../hooks/useMapImageOverlays' +import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' +import { TemporalTrendChart } from './TemporalTrendChart' +import { MunicipalitySearch } from './MunicipalitySearch' +import { LiveAnalysisJourney } from './LiveAnalysisJourney' +import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay' +import { MAP_ANALYSIS_BUDGET_MS, exceedsPerformanceBudget, formatPerformanceDuration } from '../../lib/performanceBudget' +import { formatArea, formatPercentage, formatTemporalMetric, readablePropertyName, resultMetricLabel, selectionMetricLabel } from './mapWorkspaceUtils' +import { datasetCoversSelectedArea, datasetProductKey, floodScenarioLabel, formatDatasetObservation, formatObservationDate } from './mapWorkspaceThemes' +import type { MapWorkspaceProps } from './mapWorkspaceProps' + +import type { MapWorkspaceViewModel } from './useMapWorkspaceViewModel' + +interface MapExplorerViewProps { + props: MapWorkspaceProps + view: MapWorkspaceViewModel +} + +export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Element { + const { + readOnly = false, + secondaryResultsContainer = null, + selectedProjectId, + projects, + areas, + selectedMapAreaId, + areaFeatureCollection, + mapFeatureCollection, + qualityEvidenceGeoJson = null, + qualityEvidenceFeatureCount = 0, + qualityEvidenceLoading = false, + qualityEvidenceError = null, + qualityEvidenceWarnings = [], + mapLayerLabel, + mapLayerSourceLabel, + mapLayerProvenance, + mapLayerVisible, + mapLayerOpacity, + areaLayerVisible, + areaLayerOpacity, + mapFeatureCount, + areaFeatureCount, + viewportVectorEnabled, + viewportVectorStatus, + viewportVectorTone, + fitMapDataOnChange, + mapContentMode, + analysisLayerAvailable, + selectedMapFeature, + selectedFeature = selectedMapFeature, + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, + coverage, + coverageLoading, + coverageError, + coverageDurationMs, + coverageBudgetExceeded, + workspaceLoading, + workspaceError, + selectionExporting, + selectionExportError, + latestSelectionExportPath, + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDataset, + latestSelectionDatasetName, + mapQaReferenceDatasets, + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, + latestMapSelectionQualityCheckId, + orthophotoAnalysisStage, + orthophotoAnalysisStatus, + orthophotoAnalysisError, + orthophotoAnalysisRunning, + orthophotoAnalysisQuality, + orthophotoAnalysisDetectionCount, + orthophotoProducts, + selectedOrthophotoProductKey, + orthophotoResult, + orthophotoImageUrl, + availableMapDatasets, + selectedMapDatasetId, + onSelectMapArea, + onActivateMunicipality, + onSetContextSourceLabel, + onSetContextLayerLabel, + onOpenDatasetInMap, + onSetAreaLayerVisible, + onSetAreaLayerOpacity, + onSetMapLayerVisible, + onSetMapLayerOpacity, + onSetMapContentMode, + onSelectMapFeature, + onMapViewportChange, + onSetMapSelectionBbox, + onRunMapSelectionExtract, + onClearMapSelectionExtract, + onExportMapSelection, + onPersistMapResult, + onDeriveMapSelectionDataset, + onSelectMapQaReferenceDataset, + onRunMapSelectionQa, + onOpenMapSelectionQualityEvidence, + onRunOrthophotoAnalysis, + onSelectOrthophotoProduct, + onClearQualityEvidence, + onRefreshProjectData, + onOpenAssistant, + onOpenExports, + } = props + const { + activeCoverageItems, + activeImageOverlays, + activeLayerBbox, + activeMetricLabel, + activeOnDemandMapProduct, + activeResultDataset, + activeScopeLabel, + activeSecondaryLabel, + activeSecondaryMetric, + activeSelectionResult, + activeSeriesIsDailyGrb, + activeSupportingMetrics, + activeTemporalSeries, + activeTemporalSeriesGroup, + activeTemporalSeriesGroups, + activeTheme, + activeThemeDataset, + activeThemeMapStyle, + activeThemePartitions, + advancedMode, + analysisMode, + analysisOverlayActive, + analyzeSelection, + areaIdForSelection, + areaSelectionPreviewFeatures, + bathymetryDocumentUrl, + bboxInput, + bboxSelectionMode, + clearAreaSelection, + clearTemporalComparison, + clearThemeInsights, + copyActiveThemeResult, + copyAreaSelection, + copySelectedMapFeatureProperties, + copyTemporalComparison, + coverageCounts, + currentSelectionBbox, + downloadActiveThemeResult, + downloadAreaSelection, + downloadSelectedMapFeature, + downloadTemporalComparison, + earlierDatasetId, + earlierTemporalOptions, + explorerMapFeatureCollection, + explorerSelectionFeatureCollection, + featureExtractionEntries, + featureGeometrySummary, + featureProperties, + featureSummaryEntries, + flandersScopeSelected, + floodHazardDatasets, + fullWorkflowError, + fullWorkflowMode, + fullWorkflowRunning, + fullWorkflowStatus, + handleAnalysisModeKeyDown, + handleMapBboxPreview, + handleMapBboxSelect, + handleMapCoordinateSelect, + handleSelectMapArea, + isBathymetryProfile, + laterDatasetId, + laterTemporalOptions, + liveJourneyError, + liveJourneyHasResult, + liveJourneyProcessing, + liveJourneyResultLabel, + liveJourneyStatus, + liveJourneyValidating, + liveJourneyVerified, + mapAnalysisDurationMs, + municipalityAreaCount, + officialMapProducts, + officialMapProductsError, + officialMapProductsLoading, + onDemandProductMap, + openSelectedDatabaseLayer, + persistActiveResultAndOpenDownloads, + rectangle, + regionalBathymetryThemeActive, + regionalScopeSelected, + resultsPanelOpen, + runAreaExtract, + runFullGisWorkflow, + runQuickAoiExtract, + runTemporalComparison, + saveAreaSelectionDataset, + saveAreaSelectionExport, + selectDataTheme, + selectedAreaBbox, + selectedAreaSquareMetres, + selectedDensity, + selectedDhmvProductKey, + selectedFeatureBbox, + selectedFloodHazardProductKey, + selectedLaterSnapshot, + selectedMapArea, + selectedMapDataset, + selectedOrthophotoProduct, + selectedResultProperties, + selectedThemeIds, + selectedThemes, + selectionRelevantThemes, + selectionScaleNotice, + setAdvancedMode, + setBboxInput, + setEarlierDatasetId, + setExplorerMode, + setFullWorkflowMode, + setLaterDatasetId, + setResultsPanelOpen, + setSelectedDhmvProductKey, + setSelectedFloodHazardDatasetId, + setSelectedFloodHazardProductKey, + setSelectedTemporalSeriesKey, + setSelectionBbox, + setThemeFilter, + startBboxSelection, + temporalComparison, + temporalComparisonError, + temporalComparisonLoading, + temporalSelectionValid, + thematicLegendMax, + thematicLegendMin, + themeDatasetMap, + themeFilter, + themeInsights, + themeResults, + themeResultsError, + themeResultsLoading, + themeTemporalSeriesMap, + usesDefaultOsmBasemap, + visibleThemes, + walloniaScopeSelected, + } = view + + return ( +
+
+
+

{activeScopeLabel} · geografische verkenner

+

Gebied analyseren

+

Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.

+
+
+
+ + +
+ {!readOnly ? ( + + ) : null} +
+
+ + {readOnly ? ( +
+
+ ) : ( + + )} + + {workspaceLoading ? ( +
+
+ ) : workspaceError ? ( +
+
+ De werkruimte kon niet volledig worden geladen + {workspaceError} +
+
+ ) : null} + +
+ + +
+
+
+
+

Baken uw onderzoeksvraag af

+

+ {bboxSelectionMode + ? 'Sleep nu een rechthoek op de kaart.' + : mapSelectionBbox + ? 'Gebied gekozen. Selecteer links één of meer thema’s en start de analyse.' + : 'Teken een rechthoek of gebruik het volledige werkgebied. Er wordt nog niets automatisch geladen.'} +

+
+
+
+ + + +
+
+ +
+ + 0 && !workspaceLoading} + processing={liveJourneyProcessing} + validating={liveJourneyValidating} + hasResult={liveJourneyHasResult} + verified={liveJourneyVerified} + error={liveJourneyError} + selectionLabel={mapSelectionBbox ? 'Begrensde kaartselectie' : selectedMapArea?.name ?? 'Nog geen gebied geselecteerd'} + sourceLabel={selectedThemes.length > 0 ? `${selectedThemes.length} gekozen` : 'Kies thema’s'} + statusMessage={liveJourneyStatus} + resultLabel={liveJourneyResultLabel} + /> +
+ Werkgebied + {isValueRampRasterSource(activeThemeDataset) && activeImageOverlays.length > 0 ? ( + + + {thematicLegendMin} → {thematicLegendMax} + + ) : activeImageOverlays.length > 0 ? ( + + {activeImageOverlays[0].label} + {activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''} + + ) : null} + {analysisOverlayActive ? ( + <> + AI-kandidaten + Selectie + + ) : analysisMode === 'evolution' && temporalComparison?.object_changes.available ? ( + <> + Nieuw + Verdwenen + Gewijzigd + + ) : ( + <> + {activeTheme.shortLabel} + Selectie + + )} +
+ {bboxSelectionMode ? ( +
+ Rechthoek tekenen + Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los. +
+ ) : null} + {viewportVectorEnabled && viewportVectorStatus ? ( +
+ {viewportVectorStatus} +
+ ) : null} +
+
+ + {secondaryResultsContainer ? null : ( + + )} + + + + +
+ +
+ Werkgebied: {selectedMapArea?.name ?? 'Geen werkgebied geselecteerd'} + + Bron:{' '} + {analysisOverlayActive + ? `${mapLayerLabel} · ${mapLayerSourceLabel}` + : analysisMode === 'evolution' + ? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks' + : regionalBathymetryThemeActive + ? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities` + : activeThemeDataset + ? getDatasetDisplayName(activeThemeDataset) + : activeOnDemandMapProduct?.displayName + ?? 'niet beschikbaar'} + + {usesDefaultOsmBasemap ? Ondergrond: OpenStreetMap : null} +
+
+ ) +} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 9fd7f920..ee71462d 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -1,3252 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import { BoxSelect, ChevronLeft, ChevronRight, MapPinned, Play, Search, SlidersHorizontal, Trash2 } from 'lucide-react' -import GeoMap from '../GeoMap' -import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' -import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' -import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts' -import { useTemporalComparison } from '../../hooks/useTemporalComparison' -import { isValueRampRasterSource, useMapImageOverlays } from '../../hooks/useMapImageOverlays' -import { useMapRectangleSelection } from '../../hooks/useMapRectangleSelection' -import { useFullGisWorkflow } from '../../hooks/useFullGisWorkflow' -import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' -import { TemporalTrendChart } from './TemporalTrendChart' -import { MunicipalitySearch } from './MunicipalitySearch' -import { LiveAnalysisJourney } from './LiveAnalysisJourney' -import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay' -import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus' -import { - MAP_ANALYSIS_BUDGET_MS, - exceedsPerformanceBudget, - formatPerformanceDuration, -} from '../../lib/performanceBudget' -import { - bboxesEqual, - copyText, - datasetIntersectsSelection, - downloadJsonFile, - formatArea, - formatBboxLabel, - formatPercentage, - formatTemporalMetric, - getFeatureBBox, - getFeatureCollectionBBox, - getFeatureGeometrySummary, - isMunicipalityAreaName, - operationalScopeProjectLabel, - parseBboxInput, - persistedDatasetSupportsSelection, - productCoversZones, - readablePropertyName, - resultMetricLabel, - safeFileStem, - selectedAreaCoverageZones, - selectedFeatureCollection, - selectionAnalysisScale, - selectionAreaSquareMetres, - selectionDimensions, - selectionFeatureLimit, - selectionMetricLabel, - splitSelectionBbox, -} from './mapWorkspaceUtils' -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' +import { MapAdvancedWorkbench } from './MapAdvancedWorkbench' +import { MapExplorerView } from './MapExplorerView' +import type { MapWorkspaceProps } from './mapWorkspaceProps' +import { useMapWorkspaceViewModel } from './useMapWorkspaceViewModel' -interface MapWorkspaceProps { - readOnly?: boolean - secondaryResultsContainer?: HTMLElement | null - selectedProjectId: string | null - projects: ProjectRead[] - areas: AreaRead[] - selectedMapAreaId: string - areaFeatureCollection: GeoJSON.FeatureCollection | null - mapFeatureCollection: GeoJSON.FeatureCollection | null - qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null - qualityEvidenceFeatureCount?: number - qualityEvidenceLoading?: boolean - qualityEvidenceError?: string | null - qualityEvidenceWarnings?: string[] - mapLayerLabel: string - mapLayerSourceLabel: string - mapLayerProvenance: string - mapLayerVisible: boolean - mapLayerOpacity: number - areaLayerVisible: boolean - areaLayerOpacity: number - mapFeatureCount: number - areaFeatureCount: number - viewportVectorEnabled: boolean - viewportVectorStatus: string | null - viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error' - fitMapDataOnChange: boolean - mapContentMode: 'dataset' | 'analysis' - analysisLayerAvailable: boolean - selectedMapFeature: GeoJSON.Feature | null - selectedFeature?: GeoJSON.Feature | null - mapSelectionBbox: VectorSelectionBBox | null - mapSelectionResult: VectorSelectionResponse | null - mapSelectionLoading: boolean - mapSelectionError: string | null - coverage: CoverageResolveResponse | null - coverageLoading: boolean - coverageError: string | null - coverageDurationMs: number | null - coverageBudgetExceeded: boolean - workspaceLoading: boolean - workspaceError: string | null - selectionExporting: boolean - selectionExportError: string | null - latestSelectionExportPath: string | null - selectionDatasetSaving: boolean - selectionDatasetError: string | null - latestSelectionDataset: DatasetCreateResponse | null - latestSelectionDatasetName: string | null - mapQaReferenceDatasets: DatasetCreateResponse[] - selectedMapQaReferenceDatasetId: string - mapSelectionQaRunning: boolean - mapSelectionQaError: string | null - mapSelectionQaResult: QaComparisonResult | null - latestMapSelectionQualityCheckId: string | null - orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed' - orthophotoAnalysisStatus: string - orthophotoAnalysisError: string | null - orthophotoAnalysisRunning: boolean - orthophotoAnalysisQuality: DetectionQaResult | null - orthophotoAnalysisDetectionCount: number | null - orthophotoProducts: OrthophotoProductRead[] - selectedOrthophotoProductKey: string - orthophotoResult: OrthophotoAcquisitionResult | null - orthophotoImageUrl: string | null - availableMapDatasets: DatasetCreateResponse[] - selectedMapDatasetId: string - onSelectMapArea: (areaId: string) => void - onActivateMunicipality: (niscode: string) => Promise - onSetContextSourceLabel: (label: string | null) => void - onSetContextLayerLabel: (label: string | null) => void - onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void - onSetAreaLayerVisible: (visible: boolean) => void - onSetAreaLayerOpacity: (opacity: number) => void - onSetMapLayerVisible: (visible: boolean) => void - onSetMapLayerOpacity: (opacity: number) => void - onSetMapContentMode: (mode: 'dataset' | 'analysis') => void - onSelectMapFeature: (feature: GeoJSON.Feature | null) => void - onMapViewportChange: (viewport: MapViewportState) => void - onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void - onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise - onClearMapSelectionExtract: () => void - onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise - onPersistMapResult: (payload: MapResultExportRequest) => Promise - onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise - onSelectMapQaReferenceDataset: (datasetId: string) => void - onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise - onOpenMapSelectionQualityEvidence: () => void - onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise - onSelectOrthophotoProduct: (productKey: string) => void - onClearQualityEvidence?: () => void - onRefreshProjectData: () => Promise - onOpenAssistant: () => void - onOpenExports: () => void -} - -export function MapWorkspace({ - readOnly = false, - secondaryResultsContainer = null, - selectedProjectId, - projects, - areas, - selectedMapAreaId, - areaFeatureCollection, - mapFeatureCollection, - qualityEvidenceGeoJson = null, - qualityEvidenceFeatureCount = 0, - qualityEvidenceLoading = false, - qualityEvidenceError = null, - qualityEvidenceWarnings = [], - mapLayerLabel, - mapLayerSourceLabel, - mapLayerProvenance, - mapLayerVisible, - mapLayerOpacity, - areaLayerVisible, - areaLayerOpacity, - mapFeatureCount, - areaFeatureCount, - viewportVectorEnabled, - viewportVectorStatus, - viewportVectorTone, - fitMapDataOnChange, - mapContentMode, - analysisLayerAvailable, - selectedMapFeature, - selectedFeature = selectedMapFeature, - mapSelectionBbox, - mapSelectionResult, - mapSelectionLoading, - mapSelectionError, - coverage, - coverageLoading, - coverageError, - coverageDurationMs, - coverageBudgetExceeded, - workspaceLoading, - workspaceError, - selectionExporting, - selectionExportError, - latestSelectionExportPath, - selectionDatasetSaving, - selectionDatasetError, - latestSelectionDataset, - latestSelectionDatasetName, - mapQaReferenceDatasets, - selectedMapQaReferenceDatasetId, - mapSelectionQaRunning, - mapSelectionQaError, - mapSelectionQaResult, - latestMapSelectionQualityCheckId, - orthophotoAnalysisStage, - orthophotoAnalysisStatus, - orthophotoAnalysisError, - orthophotoAnalysisRunning, - orthophotoAnalysisQuality, - orthophotoAnalysisDetectionCount, - orthophotoProducts, - selectedOrthophotoProductKey, - orthophotoResult, - orthophotoImageUrl, - availableMapDatasets, - selectedMapDatasetId, - onSelectMapArea, - onActivateMunicipality, - onSetContextSourceLabel, - onSetContextLayerLabel, - onOpenDatasetInMap, - onSetAreaLayerVisible, - onSetAreaLayerOpacity, - onSetMapLayerVisible, - onSetMapLayerOpacity, - onSetMapContentMode, - onSelectMapFeature, - onMapViewportChange, - onSetMapSelectionBbox, - onRunMapSelectionExtract, - onClearMapSelectionExtract, - onExportMapSelection, - onPersistMapResult, - onDeriveMapSelectionDataset, - onSelectMapQaReferenceDataset, - onRunMapSelectionQa, - onOpenMapSelectionQualityEvidence, - onRunOrthophotoAnalysis, - onSelectOrthophotoProduct, - onClearQualityEvidence, - onRefreshProjectData, - onOpenAssistant, - onOpenExports, -}: MapWorkspaceProps): JSX.Element { - const [advancedMode, setAdvancedMode] = useState(false) - useEffect(() => { - if (readOnly && advancedMode) setAdvancedMode(false) - }, [advancedMode, readOnly]) - const [activeThemeId, setActiveThemeId] = useState(() => { - const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null - return themeIdForDataset(selectedDataset) ?? 'buildings' - }) - const [selectedThemeIds, setSelectedThemeIds] = useState([]) - const [themeFilter, setThemeFilter] = useState('') - const [resultsPanelOpen, setResultsPanelOpen] = useState(false) - const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null - const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' - const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) - const selectedCoverageZones = useMemo( - () => selectedAreaCoverageZones(selectedMapArea?.name), - [selectedMapArea?.name], - ) - const flandersScopeSelected = Boolean( - selectedCoverageZones?.includes('flanders') - || (!selectedCoverageZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME), - ) - const walloniaScopeSelected = Boolean(selectedCoverageZones?.includes('wallonia')) - const { - themeInsights, - themeInsightsLoading: themeResultsLoading, - themeInsightsError: themeResultsError, - loadThemeInsights, - clearThemeInsights, - } = useMapThemeSelectionInsights(selectedProjectId, onRefreshProjectData) - const { - products: officialMapProducts, - loading: officialMapProductsLoading, - error: officialMapProductsError, - resolveCoverage, - resolveCoveragePartitions, - } = useOfficialMapProducts(selectedProjectId) - const { - temporalComparison, - temporalComparisonLoading, - temporalComparisonError, - compareTemporalSnapshots, - clearTemporalComparison, - } = useTemporalComparison(selectedProjectId) - const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current') - const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('') - const [selectedDhmvProductKey, setSelectedDhmvProductKey] = useState<'dtm_1m' | 'dsm_1m'>('dtm_1m') - const [selectedFloodHazardProductKey, setSelectedFloodHazardProductKey] = useState('pluviaal_current_t100') - const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('') - const [earlierDatasetId, setEarlierDatasetId] = useState('') - const [laterDatasetId, setLaterDatasetId] = useState('') - const rectangle = useMapRectangleSelection(mapSelectionBbox) - const { - drawing: bboxSelectionMode, - setDrawing: setBboxSelectionMode, - bboxInput, - setBboxInput, - } = rectangle - const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState(null) - const mapAnalysisRequestSequence = useRef(0) - const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) - const featureProperties = selectedMapFeature?.properties ?? null - const isBathymetryProfile = featureProperties?.['provider'] === 'vmm_vha_bathymetry_profiles' - || featureProperties?.['measurement_semantics'] === 'historical_cross_section_profile_point' - const bathymetryDocumentUrl = typeof featureProperties?.['source_document_url'] === 'string' - && featureProperties['source_document_url'].startsWith('https://vha.waterinfo.be/') - ? featureProperties['source_document_url'] - : null - const featureSummaryEntries = featureProperties - ? Object.entries(featureProperties) - .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object') - .slice(0, 6) - : [] - const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : [] - const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature) - const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null - const selectedFeatureBbox = useMemo(() => getFeatureBBox(selectedMapFeature), [selectedMapFeature]) - const activeLayerBbox = useMemo(() => getFeatureCollectionBBox(mapFeatureCollection), [mapFeatureCollection]) - const selectedAreaBbox = useMemo(() => getFeatureCollectionBBox(areaFeatureCollection), [areaFeatureCollection]) - const currentSelectionBbox = parseBboxInput(bboxInput) - const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? [] - const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12) - const selectedFeatureStem = safeFileStem( - featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature', - ) - const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson` - const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null - const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL - const floodHazardDatasets = useMemo( - () => { - const scoped = availableMapDatasets - .filter( - (dataset) => - dataset.source_name === 'vmm_flood_hazard' - && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), - ) - .sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')) - if (!regionalScopeSelected) { - return scoped - } - const products = new Map() - for (const dataset of scoped) { - const key = datasetProductKey(dataset) - if (key && !products.has(key)) { - products.set(key, dataset) - } - } - return Array.from(products.values()) - }, - [availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId], - ) - const themeDatasetMap = useMemo(() => { - const result = Object.fromEntries( - DATA_THEMES.map((theme) => [ - theme.id, - pickThemeDataset( - availableMapDatasets, - theme, - selectedMapAreaId, - selectedMapArea?.name, - regionalScopeSelected, - ), - ]), - ) as Record - const selectedFloodHazard = floodHazardDatasets.find( - (dataset) => - dataset.id === selectedFloodHazardDatasetId - || (flandersScopeSelected && datasetProductKey(dataset) === selectedFloodHazardProductKey), - ) - if (selectedFloodHazard) { - result.flood_hazard = selectedFloodHazard - } else if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) { - result.flood_hazard = null - } - if (flandersScopeSelected && officialMapProducts.dhmv.length > 0) { - result.elevation = availableMapDatasets.find( - (dataset) => - dataset.source_name === 'digitaal_vlaanderen_dhmv' - && datasetProductKey(dataset) === selectedDhmvProductKey - && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), - ) ?? null - } - if (walloniaScopeSelected && officialMapProducts.spwTerrain.some((product) => product.configured)) { - result.elevation = availableMapDatasets.find( - (dataset) => - dataset.source_name === 'spw_terrain' - && datasetProductKey(dataset) === 'spw_mnt_1m_2021_2022' - && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), - ) ?? null - } - if (flandersScopeSelected && officialMapProducts.thematic.length > 0) { - for (const product of officialMapProducts.thematic) { - if (!result[product.theme]) { - result[product.theme] = null - } - } - } - if (flandersScopeSelected && officialMapProducts.grb.length > 0) { - for (const product of officialMapProducts.grb) { - if (!result[product.key]) { - result[product.key] = null - } - } - } - if (officialMapProducts.officialVector.length > 0) { - for (const product of officialMapProducts.officialVector.filter((item) => - productCoversZones(item.coverage_zones, selectedCoverageZones), - )) { - if (!result[product.theme]) { - result[product.theme] = null - } - } - } - return result - }, [ - availableMapDatasets, - flandersScopeSelected, - floodHazardDatasets, - officialMapProducts.dhmv.length, - officialMapProducts.spwTerrain, - officialMapProducts.floodHazard.length, - officialMapProducts.grb, - officialMapProducts.officialVector, - officialMapProducts.thematic, - regionalScopeSelected, - selectedDhmvProductKey, - selectedFloodHazardDatasetId, - selectedFloodHazardProductKey, - selectedMapArea?.name, - selectedMapAreaId, - selectedCoverageZones, - walloniaScopeSelected, - ]) - const themePartitionMap = useMemo( - () => - Object.fromEntries( - DATA_THEMES.map((theme) => [ - theme.id, - rasterPartitionsForDataset( - availableMapDatasets, - themeDatasetMap[theme.id], - selectedMapAreaId, - selectedMapArea?.name, - regionalScopeSelected, - ), - ]), - ) as Record, - [availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId, themeDatasetMap], - ) - const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] - const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id] - const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? [] - const coverageCounts = useMemo( - () => coverage?.items.reduce>( - (counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }), - { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, - ) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, - [coverage], - ) - const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => { - const result: OnDemandMapProduct[] = [] - const effectiveZones = zones ?? selectedCoverageZones - const includesFlanders = Boolean( - effectiveZones?.includes('flanders') - || (!effectiveZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME), - ) - const includesWallonia = Boolean(effectiveZones?.includes('wallonia')) - if (includesFlanders) { - for (const product of officialMapProducts.thematic) { - result.push({ - kind: 'thematic_raster', - productKey: product.key, - displayName: product.display_name, - theme: product.theme, - availabilityLabel: `${product.native_resolution_m} m · ${product.observation_year} · automatisch bij selectie`, - attribution: product.attribution, - limitationMessage: product.limitation_message, - coverageZones: ['flanders'], - }) - } - for (const product of officialMapProducts.grb) { - result.push({ - kind: 'grb', - productKey: product.key, - displayName: product.display_name, - theme: product.key, - availabilityLabel: 'officiële vectorbron · automatisch bij selectie', - attribution: product.attribution, - limitationMessage: product.limitation_message, - coverageZones: ['flanders'], - }) - } - for (const source of officialMapProducts.bathymetry.filter( - (item) => - item.key === 'vha_inland_profiles' - && item.acquisition_supported - && item.configured, - )) { - result.push({ - kind: 'bathymetry_profiles', - productKey: source.key, - displayName: source.display_name, - theme: 'bathymetry', - availabilityLabel: 'historische profielpunten · automatisch bij selectie', - attribution: source.attribution, - limitationMessage: source.limitation_message, - coverageZones: ['flanders'], - }) - } - } - const latestWalous = officialMapProducts.walous - .filter((product) => product.configured && productCoversZones(product.coverage_zones, effectiveZones)) - .sort((left, right) => right.observation_year - left.observation_year)[0] - if (latestWalous) { - result.push({ - kind: 'walous', - productKey: latestWalous.key, - historyProductKeys: officialMapProducts.walous - .filter( - (product) => - product.configured - && product.key !== latestWalous.key - && productCoversZones(product.coverage_zones, effectiveZones), - ) - .map((product) => product.key), - displayName: latestWalous.display_name, - theme: 'land_cover', - availabilityLabel: `${latestWalous.analysis_resolution_m ?? latestWalous.native_resolution_m} m analyse · ${latestWalous.observation_year} · automatisch bij selectie`, - attribution: latestWalous.attribution, - limitationMessage: latestWalous.limitation_message, - coverageZones: latestWalous.coverage_zones, - }) - } - for (const product of officialMapProducts.officialVector.filter((item) => - productCoversZones(item.coverage_zones, effectiveZones), - )) { - result.push({ - kind: 'official_vector', - productKey: product.key, - displayName: product.display_name, - theme: product.theme, - availabilityLabel: `${product.observation_label} · officiële vectorbron · automatisch bij selectie`, - attribution: product.attribution, - limitationMessage: product.limitation_message, - coverageZones: product.coverage_zones, - }) - } - const dhmvProduct = includesFlanders - ? officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey) - : null - if (dhmvProduct) { - result.push({ - kind: 'dhmv', - productKey: dhmvProduct.key, - displayName: dhmvProduct.display_name, - theme: 'elevation', - availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · automatisch bij selectie`, - attribution: dhmvProduct.attribution, - limitationMessage: dhmvProduct.limitation_message, - coverageZones: ['flanders'], - }) - } - const spwTerrainProduct = includesWallonia - ? officialMapProducts.spwTerrain.find((product) => product.configured) - : null - if (spwTerrainProduct) { - result.push({ - kind: 'spw_terrain', - productKey: spwTerrainProduct.key, - displayName: spwTerrainProduct.display_name, - theme: 'elevation', - availabilityLabel: `${spwTerrainProduct.analysis_resolution_m} m analyse · ${spwTerrainProduct.acquisition_period} · automatisch bij selectie`, - attribution: spwTerrainProduct.attribution, - limitationMessage: spwTerrainProduct.limitation_message, - coverageZones: spwTerrainProduct.coverage_zones, - }) - } - const floodProduct = includesFlanders - ? officialMapProducts.floodHazard.find( - (product) => product.key === selectedFloodHazardProductKey, - ) - : null - if (floodProduct) { - result.push({ - kind: 'flood_hazard', - productKey: floodProduct.key, - displayName: floodProduct.display_name, - theme: 'flood_hazard', - availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · automatisch bij selectie`, - attribution: floodProduct.attribution, - limitationMessage: floodProduct.limitation_message, - coverageZones: ['flanders'], - }) - } - return result - }, [ - activeScopeProject?.name, - officialMapProducts, - selectedCoverageZones, - selectedDhmvProductKey, - selectedFloodHazardProductKey, - ]) - const onDemandProductMap = useMemo(() => { - const result = new Map() - const productsByTheme = new Map() - for (const product of onDemandProductsForZones(selectedCoverageZones)) { - productsByTheme.set(product.theme, [...(productsByTheme.get(product.theme) ?? []), product]) - } - for (const [theme, products] of productsByTheme) { - if (products.length === 1) { - const product = products[0] - const scopeZones = product.coverageZones.filter((zone) => selectedCoverageZones?.includes(zone) ?? true) - result.set(theme, selectedCoverageZones && selectedCoverageZones.length > 1 - ? { - ...product, - availabilityLabel: `${product.availabilityLabel} · alleen ${scopeZones.map(coverageZoneLabel).join(', ')}`, - } - : product) - continue - } - const coverageZones = Array.from(new Set( - products.flatMap((product) => product.coverageZones) - .filter((zone) => selectedCoverageZones?.includes(zone) ?? true), - )) - result.set(theme, { - ...products[0], - displayName: 'Officiële bron per regio', - availabilityLabel: `${coverageZones.map(coverageZoneLabel).join(', ')} · bron wordt na selectie bepaald`, - attribution: 'Officiële Belgische en gewestelijke databronnen', - limitationMessage: 'GeoIntel bepaalt na de getekende selectie welke regionale bron van toepassing is en voegt alleen semantisch gelijkwaardige resultaten samen.', - coverageZones, - }) - } - return result - }, [onDemandProductsForZones, selectedCoverageZones]) - const mapSelectionScale = mapSelectionBbox ? selectionAnalysisScale(mapSelectionBbox) : null - const selectionRelevantThemes = useMemo(() => { - if (!mapSelectionBbox || !coverage) { - return DATA_THEMES - } - const boundedThemes = new Set( - (mapSelectionBbox - ? onDemandProductsForZones(coverage.intersected_zones).filter( - (product) => productSupportsSelection(product, mapSelectionBbox), - ) - : []) - .map((product) => product.theme), - ) - return DATA_THEMES.filter((theme) => { - if (boundedThemes.has(theme.id)) { - return true - } - const dataset = themeDatasetMap[theme.id] - if (!dataset || !persistedDatasetSupportsSelection(dataset, mapSelectionBbox)) { - return false - } - const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id] - return coverage.items.some( - (item) => item.theme === coverageTheme && item.status === 'operational', - ) - }) - }, [coverage, mapSelectionBbox, mapSelectionScale, onDemandProductsForZones, themeDatasetMap]) - const activeThemeSupportsCurrentSelection = selectionRelevantThemes.some((theme) => theme.id === activeTheme.id) - const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id] - ? null - : onDemandProductMap.get(activeTheme.id) ?? null - const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id] - const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection) - const liveJourneyError = mapSelectionError - ?? themeResultsError - ?? temporalComparisonError - ?? orthophotoAnalysisError - ?? coverageError - ?? workspaceError - const liveJourneyHasResult = Boolean( - mapSelectionResult - || themeInsights.length > 0 - || temporalComparison - || orthophotoResult - || analysisOverlayActive, - ) - const liveJourneyVerified = Boolean(mapSelectionQaResult || orthophotoAnalysisQuality) - const liveJourneyProcessing = Boolean( - mapSelectionLoading - || themeResultsLoading - || temporalComparisonLoading - || orthophotoAnalysisRunning, - ) - const liveJourneyValidating = Boolean( - mapSelectionQaRunning || orthophotoAnalysisStage === 'validating', - ) - const liveJourneyStatus = orthophotoAnalysisStatus - || (temporalComparisonLoading ? 'Officiële meetmomenten vergelijken…' : '') - || (themeResultsLoading ? 'Begrensde bronnen verwerken…' : '') - || (mapSelectionLoading ? 'Objecten binnen de selectie ophalen…' : '') - || (liveJourneyHasResult ? 'Resultaat op de kaart beschikbaar' : 'Klaar om de selectie te verwerken') - const liveJourneyResultLabel = liveJourneyVerified - ? 'Kwaliteitsbewijs beschikbaar' - : latestMapSelectionQualityCheckId - ? 'Bewaard kwaliteitsbewijs beschikbaar' - : 'Controleerbaar resultaat' - const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null - const orthophotoImageOverlay = useMemo( - () => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4 - ? { - url: orthophotoImageUrl, - bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number], - label: orthophotoResult.display_name, - opacity: 0.9, - } - : null, - [orthophotoImageUrl, orthophotoResult], - ) - const activeThemeDataset = themeDatasetMap[activeTheme.id] - const activeThemePartitions = themePartitionMap[activeTheme.id] - const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset) - const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset) - const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive - const onDemandThemeActive = analysisMode === 'current' && Boolean(activeOnDemandMapProduct) - const regionalOnDemandThemeActive = regionalScopeSelected && onDemandThemeActive - const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThemeActive - - const visibleThemes = useMemo(() => { - const query = themeFilter.trim().toLocaleLowerCase('nl-BE') - if (!query) return DATA_THEMES - return DATA_THEMES.filter((theme) => theme.label.toLocaleLowerCase('nl-BE').includes(query)) - }, [themeFilter]) - const selectedThemes = useMemo( - () => DATA_THEMES.filter((theme) => selectedThemeIds.includes(theme.id)), - [selectedThemeIds], - ) - - useEffect(() => { - if ( - analysisMode !== 'current' - || activeThemeAvailable - || ( - selectedProjectId - && officialMapProductsLoading - && onDemandProductMap.size === 0 - && !officialMapProductsError - ) - ) { - return - } - const fallbackTheme = DATA_THEMES.find((theme) => - flandersScopeSelected - && theme.id === 'space_occupation' - && Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)), - ) ?? DATA_THEMES.find((theme) => - Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)), - ) - if (!fallbackTheme) { - return - } - setActiveThemeId(fallbackTheme.id) - const fallbackDataset = themeDatasetMap[fallbackTheme.id] - if (fallbackDataset) { - onOpenDatasetInMap(fallbackDataset) - } - }, [ - activeThemeAvailable, - analysisMode, - flandersScopeSelected, - officialMapProductsError, - officialMapProductsLoading, - onDemandProductMap, - onOpenDatasetInMap, - selectedProjectId, - themeDatasetMap, - ]) - - const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde') - const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde') - const activeImageOverlays = useMapImageOverlays({ - selectedProjectId, - activeThemeId: activeTheme.id, - activeThemeDataset, - activeThemePartitions, - orthophotoImageOverlay, - floodScenarioLabel, - }) - const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length - const themeTemporalSeriesMap = useMemo( - () => - Object.fromEntries( - DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme, selectedMapAreaId)]), - ) as Record, - [availableMapDatasets, selectedMapAreaId], - ) - const activeTemporalSeriesGroups = themeTemporalSeriesMap[activeTheme.id] - const availableEvolutionThemes = DATA_THEMES.filter((theme) => - themeTemporalSeriesMap[theme.id].some((group) => group.items.length >= 2), - ) - const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey) - ?? activeTemporalSeriesGroups[0] - const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES - const earlierTemporalOptions = activeTemporalSeries.slice(0, -1) - const selectedEarlierSnapshot = activeTemporalSeries.find((dataset) => dataset.id === earlierDatasetId) - const selectedLaterSnapshot = activeTemporalSeries.find((dataset) => dataset.id === laterDatasetId) - const selectedEarlierTime = new Date(selectedEarlierSnapshot?.observed_at ?? 0).getTime() - const laterTemporalOptions = activeTemporalSeries.filter( - (dataset) => new Date(dataset.observed_at ?? 0).getTime() > selectedEarlierTime, - ) - const temporalSelectionValid = Boolean( - selectedEarlierSnapshot - && selectedLaterSnapshot - && selectedEarlierSnapshot.id !== selectedLaterSnapshot.id - && selectedEarlierTime < new Date(selectedLaterSnapshot.observed_at ?? 0).getTime(), - ) - const activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2 - && activeTemporalSeries.every((dataset) => dataset.source_name === 'grb') - && new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime() - - new Date(activeTemporalSeries[0].observed_at ?? 0).getTime() <= 7 * 24 * 60 * 60 * 1000 - - useEffect(() => { - const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null - : regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen' - : activeOnDemandMapProduct?.displayName ?? null - onSetContextSourceLabel(contextSourceLabel) - return () => onSetContextSourceLabel(null) - }, [ - activeTemporalSeriesGroup?.label, - activeOnDemandMapProduct?.displayName, - analysisMode, - onSetContextSourceLabel, - regionalBathymetryThemeActive, - ]) - - const themeResults = useMemo( - () => - themeInsights.flatMap((insight) => { - const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId) - return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : [] - }), - [themeInsights], - ) - const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId) - const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset - const activeSelectionResult = activeThemeInsight?.result - ?? (!regionalPartitionedThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) - useEffect(() => { - const contextLayerLabel = analysisMode === 'current' && activeOnDemandMapProduct - ? activeSelectionResult - ? `${( - activeSelectionResult.total_feature_count - ?? activeSelectionResult.feature_count - ).toLocaleString('nl-BE')} objecten gemeten` - : 'Automatisch bij selectie' - : null - onSetContextLayerLabel(contextLayerLabel) - return () => onSetContextLayerLabel(null) - }, [ - activeOnDemandMapProduct, - activeSelectionResult, - analysisMode, - onSetContextLayerLabel, - ]) - const explorerMapFeatureCollection = regionalBathymetryThemeActive - ? null - : analysisMode === 'evolution' && temporalComparison?.geojson.features.length - ? temporalComparison.geojson - : onDemandThemeActive - ? null - : mapFeatureCollection - const explorerSelectionFeatureCollection = analysisMode === 'current' - ? activeSelectionResult?.geojson ?? null - : null - const selectedAreaSquareMetres = useMemo( - () => - bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2 - ? selectedMapArea.area_m2 - : selectionAreaSquareMetres(mapSelectionBbox), - [mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2], - ) - const selectionScaleNotice = useMemo(() => { - if (!mapSelectionBbox || !mapSelectionScale) return null - const dimensions = selectionDimensions(mapSelectionBbox) - const widthKm = dimensions.widthMetres / 1000 - const heightKm = dimensions.heightMetres / 1000 - if (mapSelectionScale === 'regional') { - const partitionCount = splitSelectionBbox(mapSelectionBbox).length - return `Regionale selectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. De server verwerkt dit thema hervatbaar over ${partitionCount} of meer bronafhankelijke partities en presenteert één gezamenlijke status.` - } - if (mapSelectionScale === 'overview') { - return `Overzichtsselectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. De server kiest automatisch bronlimieten, checkpoints en partities; resolutie en beschikbare dekking blijven die van de officiële bron.` - } - return null - }, [mapSelectionBbox, mapSelectionScale]) - const selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0 - const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0 - ? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000) - : null - const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal - const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten' - const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel - const activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter( - (metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key, - ) - const terrainReliefMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'relief_m') - const populationDensityMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'population_density_mean_per_ha') - const scoreMedianMetric = activeSupportingMetrics.find((metric) => metric.metric_key.endsWith('_median')) - const activeSecondaryMetric = activeMetricUnit === 'm TAW' || activeMetricUnit === 'm DNG' - ? terrainReliefMetric ? `${terrainReliefMetric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits: 2 })} m reliëf` : null - : activeMetricUnit === 'inwoners' - ? populationDensityMetric ? selectionMetricLabel(populationDensityMetric) : null - : activeMetricUnit.startsWith('score') - ? scoreMedianMetric ? selectionMetricLabel(scoreMedianMetric) : null - : selectedAreaSquareMetres && selectedAreaSquareMetres > 0 - ? activeMetricUnit === 'ha' - ? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking` - : `${(activeMetricValue / (selectedAreaSquareMetres / 1_000_000)).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${activeMetricUnit} / km2` - : null - const activeSecondaryLabel = activeMetricUnit === 'ha' - ? 'Aandeel selectie' - : activeMetricUnit === 'm TAW' || activeMetricUnit === 'm DNG' - ? 'Reliëf' - : activeMetricUnit === 'inwoners' - ? 'Gemiddelde dichtheid' - : activeMetricUnit.startsWith('score') - ? 'Mediaan' - : 'Dichtheid' - const selectedResultProperties = useMemo(() => { - const keys = new Map>() - for (const feature of activeSelectionResult?.geojson.features ?? []) { - for (const [key, value] of Object.entries(feature.properties ?? {})) { - if (value === null || value === undefined || typeof value === 'object' || key.endsWith('_id')) { - continue - } - const values = keys.get(key) ?? new Set() - if (values.size < 4) { - values.add(String(value)) - } - keys.set(key, values) - } - } - return Array.from(keys.entries()) - .filter(([, values]) => values.size > 0) - .slice(0, 8) - .map(([key, values]) => ({ key, values: Array.from(values) })) - }, [activeSelectionResult]) - - useEffect(() => { - rectangle.showBbox((mapSelectionBbox)) - }, [mapSelectionBbox]) - - useEffect(() => { - setSelectedTemporalSeriesKey((current) => - activeTemporalSeriesGroups.some((group) => group.key === current) - ? current - : activeTemporalSeriesGroups[0]?.key ?? '', - ) - }, [activeTemporalSeriesGroups]) - - useEffect(() => { - const selected = floodHazardDatasets.find( - (dataset) => datasetProductKey(dataset) === selectedFloodHazardProductKey, - ) - if (selected) { - if (selected.id !== selectedFloodHazardDatasetId) { - setSelectedFloodHazardDatasetId(selected.id) - } - return - } - if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) { - setSelectedFloodHazardDatasetId('') - return - } - const preferred = floodHazardDatasets.find( - (dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100', - ) ?? floodHazardDatasets[0] - setSelectedFloodHazardDatasetId(preferred?.id ?? '') - if (preferred && datasetProductKey(preferred)) { - setSelectedFloodHazardProductKey(datasetProductKey(preferred)) - } - }, [ - flandersScopeSelected, - floodHazardDatasets, - officialMapProducts.floodHazard.length, - selectedFloodHazardDatasetId, - selectedFloodHazardProductKey, - ]) - - useEffect(() => { - const first = activeTemporalSeries[0] - const last = activeTemporalSeries[activeTemporalSeries.length - 1] - setEarlierDatasetId(first?.id ?? '') - setLaterDatasetId(last?.id ?? '') - clearTemporalComparison() - }, [activeTemporalSeries]) - - useEffect(() => { - if (advancedMode || !activeThemeDataset || selectedMapDataset?.id === activeThemeDataset.id) { - return - } - onOpenDatasetInMap(activeThemeDataset) - }, [activeTheme, activeThemeDataset, advancedMode, onOpenDatasetInMap, selectedMapDataset]) - - useEffect(() => { - if (!selectedMapDataset) { - return - } - const selectedProductKey = datasetProductKey(selectedMapDataset) - if ( - selectedMapDataset.source_name === 'digitaal_vlaanderen_dhmv' - && (selectedProductKey === 'dtm_1m' || selectedProductKey === 'dsm_1m') - ) { - setSelectedDhmvProductKey(selectedProductKey) - } - if (selectedMapDataset.source_name === 'vmm_flood_hazard' && selectedProductKey) { - setSelectedFloodHazardProductKey(selectedProductKey) - setSelectedFloodHazardDatasetId(selectedMapDataset.id) - } - const matchingThemeId = themeIdForDataset(selectedMapDataset) - if (matchingThemeId) { - setActiveThemeId(matchingThemeId) - } - }, [selectedMapDataset]) - - const downloadSelectedMapFeature = () => { - if (!selectedFeatureGeoJson) { - return - } - downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson, 'application/geo+json') - } - - const copySelectedMapFeatureProperties = () => { - copyText(JSON.stringify(featureProperties ?? {}, null, 2)) - } - - const setSelectionBbox = (bbox: VectorSelectionBBox | null) => { - onSetMapSelectionBbox(bbox) - rectangle.showBbox((bbox)) - } - - const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => ( - bbox && selectedMapArea ? selectedMapArea.id : undefined - ) - - const startBboxSelection = () => { - rectangle.reset() - clearThemeInsights() - clearTemporalComparison() - setResultsPanelOpen(false) - setBboxSelectionMode(true) - } - - const handleMapCoordinateSelect = (coordinate: [number, number]) => { - // The first click only places a corner; there is no rectangle to act on yet. - const bbox = rectangle.placeCorner(coordinate) - if (!bbox) return - setSelectionBbox(bbox) - clearThemeInsights() - clearTemporalComparison() - setResultsPanelOpen(false) - } - - const runAreaExtract = () => { - const bbox = parseBboxInput(bboxInput) - if (!bbox) { - return - } - void analyzeSelection(bbox, areaIdForSelection(bbox)) - } - - const clearAreaSelection = () => { - mapAnalysisRequestSequence.current += 1 - setMapAnalysisDurationMs(null) - rectangle.reset() - rectangle.showBbox((null)) - setResultsPanelOpen(false) - clearThemeInsights() - clearTemporalComparison() - onClearMapSelectionExtract() - } - - const handleSelectMapArea = (areaId: string) => { - clearAreaSelection() - onSelectMapArea(areaId) - } - - const downloadAreaSelection = () => { - if (!mapSelectionResult) { - return - } - downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson, 'application/geo+json') - } - - const copyAreaSelection = () => { - copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) - } - - const downloadActiveThemeResult = () => { - if (!activeSelectionResult) { - return - } - if (activeResultDataset?.dataset_type === 'raster') { - downloadJsonFile(`${activeTheme.id}-analysis.json`, { - project_id: selectedProjectId, - area_id: areaIdForSelection(mapSelectionBbox) ?? null, - area_name: selectedMapArea?.name ?? null, - theme: activeTheme, - dataset_id: activeResultDataset.id, - dataset_name: activeResultDataset.name, - source_name: activeResultDataset.source_name, - result: activeSelectionResult, - }) - return - } - downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson, 'application/geo+json') - } - - const copyActiveThemeResult = () => { - copyText(JSON.stringify(activeSelectionResult ?? {}, null, 2)) - } - - const downloadTemporalComparison = () => { - if (!temporalComparison) { - return - } - downloadJsonFile(`${activeTheme.id}-evolution-${temporalComparison.earlier.observed_at.slice(0, 10)}-${temporalComparison.later.observed_at.slice(0, 10)}.json`, temporalComparison) - } - - const copyTemporalComparison = () => { - copyText(JSON.stringify(temporalComparison ?? {}, null, 2)) - } - - const persistActiveResultAndOpenDownloads = async () => { - if (!selectedProjectId || !mapSelectionBbox) { - onOpenExports() - return - } - const areaId = areaIdForSelection(mapSelectionBbox) - const payload: MapResultExportRequest | null = analysisMode === 'evolution' - ? temporalComparison && earlierDatasetId && laterDatasetId - ? { - project_id: selectedProjectId, - mode: 'evolution', - bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, - earlier_dataset_id: earlierDatasetId, - later_dataset_id: laterDatasetId, - area_id: areaId, - theme_id: activeTheme.id, - name: `${activeTheme.id}-evolution`, - } - : null - : activeSelectionResult && activeResultDataset - ? { - project_id: selectedProjectId, - mode: 'current', - bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, - dataset_id: activeResultDataset.id, - area_id: areaId, - partitioned: regionalPartitionedThemeActive, - product_key: regionalRasterThemeActive - ? String(activeResultDataset.source_metadata?.['product_key'] ?? '') || undefined - : undefined, - partition_scope_key: regionalBathymetryThemeActive - ? String(activeResultDataset.source_metadata?.['partition_scope_key'] ?? 'flanders') - : undefined, - theme_id: activeTheme.id, - name: `${activeTheme.id}-analysis`, - } - : null - if (!payload) { - onOpenExports() - return - } - const persisted = await onPersistMapResult(payload) - if (persisted) { - onOpenExports() - } - } - - const saveAreaSelectionExport = () => { - const bbox = parseBboxInput(bboxInput) - if (!bbox) { - return - } - onExportMapSelection(bbox, areaIdForSelection(bbox)) - } - - const saveAreaSelectionDataset = () => { - const bbox = parseBboxInput(bboxInput) - if (!bbox) { - return - } - onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox)) - } - - const openSelectedDatabaseLayer = (datasetId: string) => { - const dataset = availableMapDatasets.find((item) => item.id === datasetId) - if (dataset) { - onOpenDatasetInMap(dataset) - } - } - - const selectDataTheme = (theme: DataTheme) => { - const temporalGroup = themeTemporalSeriesMap[theme.id][0] - const dataset = analysisMode === 'evolution' - ? temporalGroup?.items[temporalGroup.items.length - 1] ?? null - : themeDatasetMap[theme.id] - const onDemandProduct = analysisMode === 'current' - ? onDemandProductMap.get(theme.id) - : null - if (!dataset && !onDemandProduct) { - return - } - setSelectedThemeIds((current) => ( - current.includes(theme.id) - ? current.filter((themeId) => themeId !== theme.id) - : [...current, theme.id] - )) - setActiveThemeId(theme.id) - clearThemeInsights() - clearTemporalComparison() - if (dataset) { - onOpenDatasetInMap(dataset) - } - } - - const setExplorerMode = (mode: 'current' | 'evolution') => { - setAnalysisMode(mode) - setSelectedThemeIds([]) - setResultsPanelOpen(false) - clearThemeInsights() - clearTemporalComparison() - if (mode !== 'evolution' || activeTemporalSeriesGroups.length > 0) { - return - } - const fallbackTheme = availableEvolutionThemes[0] - const fallbackGroup = fallbackTheme ? themeTemporalSeriesMap[fallbackTheme.id][0] : null - const fallbackDataset = fallbackGroup?.items[fallbackGroup.items.length - 1] - if (fallbackTheme && fallbackDataset) { - setActiveThemeId(fallbackTheme.id) - onOpenDatasetInMap(fallbackDataset) - } - } - - const handleAnalysisModeKeyDown = ( - event: KeyboardEvent, - mode: 'current' | 'evolution', - ) => { - if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return - event.preventDefault() - const nextMode = event.key === 'ArrowLeft' || event.key === 'Home' - ? 'current' - : event.key === 'ArrowRight' || event.key === 'End' - ? 'evolution' - : mode - setExplorerMode(nextMode) - window.requestAnimationFrame(() => { - document.getElementById(`geo-analysis-tab-${nextMode}`)?.focus() - }) - } - - const loadSelectedThemeResult = async (bbox: VectorSelectionBBox, areaId?: string) => { - let resolvedZones = selectedCoverageZones - const scale = selectionAnalysisScale(bbox) - if (analysisMode === 'current' && selectedProjectId) { - const resolvedCoverage = await resolveCoverage({ - minx: bbox.min_x, - miny: bbox.min_y, - maxx: bbox.max_x, - maxy: bbox.max_y, - }) - if (!resolvedCoverage) { - clearThemeInsights() - return - } - resolvedZones = resolvedCoverage.intersected_zones - } - let resolvedProducts: PlannedOnDemandMapProduct[] = [] - if (analysisMode === 'current') { - const zoneProducts = resolvedZones - ? onDemandProductsForZones(resolvedZones) - : [] - if (scale === 'detail' || !selectedProjectId || scale === 'overview') { - resolvedProducts = zoneProducts - .filter((product) => productSupportsSelection(product, bbox)) - .map((product) => ({ - ...product, - acquisitionBboxes: [bbox], - })) - } else { - const detailTiles = splitSelectionBbox(bbox) - const tileCoverage = await resolveCoveragePartitions(detailTiles) - if (!tileCoverage) { - clearThemeInsights() - return - } - const grouped = new Map() - for (const item of tileCoverage) { - for (const product of onDemandProductsForZones(item.coverage.intersected_zones)) { - if (product.kind === 'thematic_raster' || !productSupportsSelection(product, bbox)) continue - const key = `${product.kind}:${product.productKey}` - const existing = grouped.get(key) - if (existing) { - existing.acquisitionBboxes.push(item.bbox) - } else { - grouped.set(key, { ...product, acquisitionBboxes: [item.bbox] }) - } - } - } - for (const product of zoneProducts.filter( - (candidate) => candidate.kind === 'thematic_raster' && productSupportsSelection(candidate, bbox), - )) { - grouped.set(`${product.kind}:${product.productKey}`, { - ...product, - acquisitionBboxes: [bbox], - }) - } - resolvedProducts = [...grouped.values()] - } - } - const availableThemes: Array> = [] - const resultFeatureLimit = selectionFeatureLimit(bbox) - for (const theme of selectedThemes) { - const dataset = themeDatasetMap[theme.id] - const partitioned = Boolean( - dataset - && regionalScopeSelected - && (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)), - ) - const coveringPartitions = partitioned - ? themePartitionMap[theme.id].filter((partition) => datasetIntersectsSelection(partition, bbox)) - : [] - const persistedCoverageAvailable = Boolean( - dataset - && persistedDatasetSupportsSelection(dataset, bbox) - && (!partitioned || coveringPartitions.length > 0), - ) - if (dataset && persistedCoverageAvailable) { - availableThemes.push({ - themeId: theme.id, - dataset, - datasetIds: coveringPartitions.map((partition) => partition.id), - featureLimit: resultFeatureLimit, - partitioned, - }) - continue - } - const onDemandProducts = resolvedProducts.filter((product) => product.theme === theme.id) - if (onDemandProducts.length > 0) { - for (const onDemandProduct of onDemandProducts) { - availableThemes.push({ - themeId: theme.id, - acquisition: { - kind: onDemandProduct.kind, - productKey: onDemandProduct.productKey, - displayName: onDemandProduct.displayName, - historyProductKeys: onDemandProduct.historyProductKeys, - coverageZone: onDemandProduct.coverageZones.find((zone) => resolvedZones?.includes(zone)), - serverOrchestrated: scale !== 'detail', - }, - acquisitionBboxes: onDemandProduct.acquisitionBboxes, - featureLimit: resultFeatureLimit, - }) - } - continue - } - } - await loadThemeInsights(bbox, availableThemes, areaId) - } - - const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { - if (analysisMode === 'current' && selectedThemes.length === 0) { - return - } - setResultsPanelOpen(true) - const requestId = mapAnalysisRequestSequence.current + 1 - mapAnalysisRequestSequence.current = requestId - const startedAt = Date.now() - setMapAnalysisDurationMs(null) - setSelectionBbox(bbox) - const tasks: Array> = analysisMode === 'current' - ? [loadSelectedThemeResult(bbox, areaId)] - : [] - const activeDatasetSupportsSelection = !activeThemeDataset - || persistedDatasetSupportsSelection(activeThemeDataset, bbox) - if ( - analysisMode === 'current' - && advancedMode - && activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive - && activeDatasetSupportsSelection - ) { - tasks.push(onRunMapSelectionExtract(bbox, areaId)) - } - if (analysisMode === 'evolution' && temporalSelectionValid) { - tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId)) - } - try { - await Promise.all(tasks) - } finally { - if (mapAnalysisRequestSequence.current === requestId) { - setMapAnalysisDurationMs(Date.now() - startedAt) - } - } - } - - const runTemporalComparison = () => { - if (!mapSelectionBbox || !temporalSelectionValid) { - return - } - void compareTemporalSnapshots( - earlierDatasetId, - laterDatasetId, - mapSelectionBbox, - areaIdForSelection(mapSelectionBbox), - ) - } - - const handleMapBboxPreview = (bbox: VectorSelectionBBox) => { - setSelectionBbox(bbox) - } - - const handleMapBboxSelect = (bbox: VectorSelectionBBox) => { - rectangle.reset() - clearThemeInsights() - clearTemporalComparison() - setResultsPanelOpen(false) - setSelectionBbox(bbox) - } - - const runQuickAoiExtract = () => { - const bbox = selectedAreaBbox ?? activeLayerBbox - if (!bbox) { - return - } - setSelectionBbox(bbox) - void analyzeSelection(bbox, areaIdForSelection(bbox)) - } - - const fullWorkflow = useFullGisWorkflow({ - selectedDataset: selectedMapDataset, - latestSelectionDataset, - qaReferenceDatasetId: selectedMapQaReferenceDatasetId, - resolveBbox: () => currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox, - resolveAreaId: areaIdForSelection, - setSelectionBbox, - onRunSelectionExtract: onRunMapSelectionExtract, - onDeriveDataset: onDeriveMapSelectionDataset, - onExportSelection: onExportMapSelection, - onRunQa: onRunMapSelectionQa, - }) - const { - running: fullWorkflowRunning, - status: fullWorkflowStatus, - error: fullWorkflowError, - mode: fullWorkflowMode, - setMode: setFullWorkflowMode, - run: runFullGisWorkflow, - } = fullWorkflow - - if (!advancedMode) { - return ( -
-
-
-

{activeScopeLabel} · geografische verkenner

-

Gebied analyseren

-

Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.

-
-
-
- - -
- {!readOnly ? ( - - ) : null} -
-
- - {readOnly ? ( -
-
- ) : ( - - )} - - {workspaceLoading ? ( -
-
- ) : workspaceError ? ( -
-
- De werkruimte kon niet volledig worden geladen - {workspaceError} -
-
- ) : null} - -
- - -
-
-
-
-

Baken uw onderzoeksvraag af

-

- {bboxSelectionMode - ? 'Sleep nu een rechthoek op de kaart.' - : mapSelectionBbox - ? 'Gebied gekozen. Selecteer links één of meer thema’s en start de analyse.' - : 'Teken een rechthoek of gebruik het volledige werkgebied. Er wordt nog niets automatisch geladen.'} -

-
-
-
- - - -
-
- -
- - 0 && !workspaceLoading} - processing={liveJourneyProcessing} - validating={liveJourneyValidating} - hasResult={liveJourneyHasResult} - verified={liveJourneyVerified} - error={liveJourneyError} - selectionLabel={mapSelectionBbox ? 'Begrensde kaartselectie' : selectedMapArea?.name ?? 'Nog geen gebied geselecteerd'} - sourceLabel={selectedThemes.length > 0 ? `${selectedThemes.length} gekozen` : 'Kies thema’s'} - statusMessage={liveJourneyStatus} - resultLabel={liveJourneyResultLabel} - /> -
- Werkgebied - {isValueRampRasterSource(activeThemeDataset) && activeImageOverlays.length > 0 ? ( - - - {thematicLegendMin} → {thematicLegendMax} - - ) : activeImageOverlays.length > 0 ? ( - - {activeImageOverlays[0].label} - {activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''} - - ) : null} - {analysisOverlayActive ? ( - <> - AI-kandidaten - Selectie - - ) : analysisMode === 'evolution' && temporalComparison?.object_changes.available ? ( - <> - Nieuw - Verdwenen - Gewijzigd - - ) : ( - <> - {activeTheme.shortLabel} - Selectie - - )} -
- {bboxSelectionMode ? ( -
- Rechthoek tekenen - Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los. -
- ) : null} - {viewportVectorEnabled && viewportVectorStatus ? ( -
- {viewportVectorStatus} -
- ) : null} -
-
- - {secondaryResultsContainer ? null : ( - - )} - - - - -
- -
- Werkgebied: {selectedMapArea?.name ?? 'Geen werkgebied geselecteerd'} - - Bron:{' '} - {analysisOverlayActive - ? `${mapLayerLabel} · ${mapLayerSourceLabel}` - : analysisMode === 'evolution' - ? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks' - : regionalBathymetryThemeActive - ? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities` - : activeThemeDataset - ? getDatasetDisplayName(activeThemeDataset) - : activeOnDemandMapProduct?.displayName - ?? 'niet beschikbaar'} - - {usesDefaultOsmBasemap ? Ondergrond: OpenStreetMap : null} -
-
- ) - } - - return ( -
- -
-
-

Ruimtelijke controle

-

Kaartwerkruimte

-
- - {mapFeatureCollection ? `${mapFeatureCount} objecten` : viewportVectorEnabled ? 'zoom in om te laden' : 'geen laag'} - -
- -
- {usesDefaultOsmBasemap ? ( -
- Publieke kaartondergrond - De publieke OpenStreetMap-ondergrond is actief. Configureer voor intensief gebruik een eigen kaartstijl. -
- ) : null} -
-
- Kaartinhoud -
- - -
-
- - -
- - onSetAreaLayerOpacity(Number(event.target.value))} - data-testid="map-area-opacity" - /> -
-
- - onSetMapLayerOpacity(Number(event.target.value))} - data-testid="map-layer-opacity" - /> -
-
- {mapLayerLabel} - {selectedMapDataset ? `Databaselaag: ${selectedMapDataset.name}` : 'Geen databaselaag gekozen'} - {areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Geen werkgebied geladen'} - - {mapFeatureCollection - ? `${mapFeatureCount} objecten geladen` - : viewportVectorEnabled - ? 'Databaselaag gekozen; zichtbare objecten laden volgens de kaartuitsnede' - : 'Geen vector- of resultaatlaag geladen'} - - {viewportVectorEnabled && viewportVectorStatus ? ( - - {viewportVectorStatus} - - ) : null} -
-
-
- -
- -
- -
- - Details van de kaartlagen - - {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Kaartuitsnedelaag gekozen' : 'Geen actieve laag'} - - -
-
- Werkgebied - {selectedMapArea?.name ?? 'Geen gebied geselecteerd'} - {areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Werkgebiedlaag uitgeschakeld'} -
-
- Actieve kaartlaag - {mapLayerLabel} - {mapLayerSourceLabel} -
-
- Status kaartobjecten - - {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Wachten op detail van de kaartuitsnede' : 'Geen laag getekend'} - - {mapLayerProvenance} -
-
- Kaartbewijs kwaliteitscontrole - {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewijsobjecten` : 'Geen bewijslaag'} - {qualityEvidenceLoading ? 'Bewaard bewijs laden' : 'Overeenkomsten, onterecht gevonden en gemiste objecten'} -
-
-
-
- Bron van de kaartlaag - {mapLayerSourceLabel} -
-
- Herkomst - {mapLayerProvenance} -
-
- Weergavestatus - - {mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Laden volgens kaartuitsnede actief' : 'Geen actieve vector- of resultaatlaag'} - -
-
- Kaartbewijs - {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} getekend` : 'uitgeschakeld'} -
-
-
- - {qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? ( -
-
- Kaartbewijs kwaliteitscontrole - {qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewaarde objecten` : 'Niet geladen'} - {qualityEvidenceError ?

{qualityEvidenceError}

: null} - {qualityEvidenceWarnings.length > 0 ? ( -

- {qualityEvidenceWarnings.length} {qualityEvidenceWarnings.length === 1 ? 'bewijsverwijzing kon' : 'bewijsverwijzingen konden'} niet worden teruggevonden. -

- ) : null} -
-
- Overeenkomst resultaat - Overeenkomst referentie - Onterecht gevonden - Gemist -
- {onClearQualityEvidence ? ( - - ) : null} -
- ) : null} - - {!mapFeatureCollection && !viewportVectorEnabled ? ( -
- Geen actieve vector- of resultaatlaag -

Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen.

- {availableMapDatasets.length > 0 ? ( - <> -

Open een beschikbare vectorlaag

-
- {availableMapDatasets.map((dataset) => ( - - ))} -
- - ) : ( -

Nog geen gebruiksklare vectorlagen beschikbaar. Voeg eerst een vectorbestand toe.

- )} -
- ) : null} - - {mapSelectionBbox ? ( -
-
-
-

Dekking van deze selectie

-

{activeTheme.label}

-
- {coverageLoading ? controleren : null} -
- {coverageError ?

{coverageError}

: null} - {coverageDurationMs !== null ? ( -

- Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}. - {coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''} -

- ) : null} - {coverage ? ( - <> -
- {coverage.intersected_zones.map((zone) => ( - {coverageZoneLabel(zone)} - ))} - {coverage.outside_supported_scope ? deels buiten scope : null} -
-
- {activeCoverageItems.map((item) => ( -
- {coverageZoneLabel(item.zone)} - {coverageStatusLabel(item.status)} - {item.source_names.join(', ') || 'Geen broncontract'} -
- ))} - {activeCoverageItems.length === 0 ? ( -

Deze selectie raakt geen bewaarde Belgische land- of zeezone.

- ) : null} -
-
- {coverageCounts.operational} beschikbaar - {coverageCounts.partial} gedeeltelijk - {coverageCounts.not_configured} niet gekoppeld - {coverageCounts.unsupported} niet ondersteund -
- {coverage.warnings.map((warning) =>

{warning}

)} - - ) : !coverageLoading && !coverageError ? ( -

De dekkingsmatrix wordt bepaald zodra de selectie volledig is.

- ) : null} -
- ) : null} - -
-
-
-
-

Operationele GIS-controle

-

Databaselaag doorzoeken

-
- {mapSelectionResult ? `${mapSelectionResult.feature_count} resultaten` : 'gereed'} -
-

- Kies een bewaarde vectorlaag en doorzoek daarna de objecten in PostGIS binnen het werkgebied of de volledige laag. -

-
-
- Databaselaag - {selectedMapDataset?.name ?? 'Kies een laag'} -
-
- Begrenzing werkgebied - {selectedAreaBbox ? 'beschikbaar' : 'ontbreekt'} -
-
- Begrenzing kaartlaag - {activeLayerBbox ? 'beschikbaar' : 'ontbreekt'} -
-
- Resultaat - {mapSelectionResult ? `${mapSelectionResult.feature_count} objecten` : 'nog niet uitgevoerd'} -
-
-
- - - -
- {mapSelectionError ?

{mapSelectionError}

: null} -
-
-
- 1 - Kaartlaag - {selectedMapDataset ? selectedMapDataset.name : 'Kies een databaselaag'} -
-
- 2 - Begrenzing - {currentSelectionBbox ? 'Gebiedsbegrenzing gereed' : 'Gebruik het werkgebied of de laagbegrenzing'} -
-
- 3 - Selectie - {mapSelectionResult ? `${mapSelectionResult.feature_count} bewaarde objecten` : 'Voer de ruimtelijke selectie uit'} -
-
- 4 - Resultaatlaag - {latestSelectionDatasetName ?? 'Bewaar het selectieresultaat'} -
-
- 5 - Kwaliteitscontrole - {mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n.v.t.'}` : 'Vergelijk met een referentielaag'} -
-
- 6 - Download - {latestSelectionExportPath ? 'GeoJSON-bestand gereed' : 'Bewaar een downloadbestand'} -
-
-
- - -
- Selecteren, bewaren, controleren en downloaden - {fullWorkflowStatus} -
- - - - - -
- {selectionDatasetError ?

{selectionDatasetError}

: null} - {selectionExportError ?

{selectionExportError}

: null} - {mapSelectionQaError ?

{mapSelectionQaError}

: null} - {fullWorkflowError ?

{fullWorkflowError}

: null} -
-
-
- - Geavanceerde selectie en inspectie - Coördinaten, objectextractie en ruwe eigenschappen - -
-
-
-
-

Bewaarde vectorobjecten

-

Gebiedsselectie

-
- - {mapSelectionResult ? `${mapSelectionResult.feature_count} geselecteerd` : bboxSelectionMode ? 'selecteren' : 'gereed'} - -
-
- {bboxSelectionMode ? (rectangle.firstCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'} - {formatBboxLabel(currentSelectionBbox)} -
-
- - - - -
-
- - - - - - -
- {mapSelectionError ?

{mapSelectionError}

: null} - {mapSelectionResult ? ( -
-
-
- Objecten - {mapSelectionResult.feature_count} -
-
- Limiet - {mapSelectionResult.limit} -
-
- Afgekapt - {mapSelectionResult.truncated ? 'ja' : 'nee'} -
-
- Bron - Bewaarde databankobjecten -
-
-
- - - - -
- {selectionExportError ?

{selectionExportError}

: null} - {latestSelectionExportPath ? ( -

De geselecteerde download is bewaard.

- ) : null} - {selectionDatasetError ?

{selectionDatasetError}

: null} - {latestSelectionDatasetName ? ( -

Bewaarde afgeleide laag: {latestSelectionDatasetName}

- ) : null} - {latestSelectionDatasetName ? ( -
- - - {mapSelectionQaError ?

{mapSelectionQaError}

: null} - {mapSelectionQaResult ? ( -
-
-
-

Kaartbewijs

-

Vergelijking van de bewaarde selectie

-
- -
-
-
- Precisie - {mapSelectionQaResult.precision ?? 'n.v.t.'} -
-
- Herkenningsgraad - {mapSelectionQaResult.recall ?? 'n.v.t.'} -
-
- F1 - {mapSelectionQaResult.f1_score ?? 'n.v.t.'} -
-
- Gemiddelde overlap - {mapSelectionQaResult.mean_iou ?? 'n.v.t.'} -
-
- Overeenkomsten - {mapSelectionQaResult.matches} -
-
- Onterecht gevonden - {mapSelectionQaResult.false_positives} -
-
- Gemist - {mapSelectionQaResult.false_negatives} -
-
- Status bewijs - {latestMapSelectionQualityCheckId ? 'bewaard' : 'niet bewaard'} -
-
- {mapSelectionQaResult.warnings.length > 0 ? ( -
- Aandachtspunten -
    - {mapSelectionQaResult.warnings.map((warning) => ( -
  • {warning}
  • - ))} -
-
- ) : null} -
- ) : null} -
- ) : null} - {areaSelectionPreviewFeatures.length > 0 ? ( -
- - - - - - - - - - {areaSelectionPreviewFeatures.map((feature, index) => ( - - - - - - ))} - -
ObjectKlasseBronreferentie
{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}{String(feature.properties?.['feature_class'] ?? 'n.v.t.')}{String(feature.properties?.['source_feature_id'] ?? 'n.v.t.')}
-
- ) : ( -

Geen bewaarde vectorobjecten kruisen deze selectie.

- )} -
- ) : null} -
-
-
-
-

Geselecteerd object

-

Selectie en extractie

-
- {selectedMapFeature ? 'gereed' : 'wachten'} -
- {selectedMapFeature ? ( - <> - {isBathymetryProfile ? ( -
-
- Waterloop - {String(featureProperties?.['watercourse_name'] ?? 'Onbekende waterloop')} -
-
- Profiel - {String(featureProperties?.['profile_number'] ?? 'n.v.t.')} -
-
- Meetdatum - {String(featureProperties?.['measurement_date'] ?? 'Niet geregistreerd')} -
-
- Geregistreerde diepte - - {typeof featureProperties?.['recorded_depth_m'] === 'number' - ? `${featureProperties['recorded_depth_m'].toLocaleString('nl-BE')} m` - : 'Niet als veld beschikbaar'} - -
- {bathymetryDocumentUrl ? ( - - Officieel profielblad openen - - ) : ( - Voor dit meetpunt is geen digitaal profielblad gekoppeld. - )} -

- Historisch dwarsprofiel. Dit punt is geen continue actuele bodemkaart en levert zonder - gelijktijdig waterpeil geen actueel watervolume. -

-
- ) : null} -
-
- Geometrie - {featureGeometrySummary.geometryType} -
-
- Coördinaten - {featureGeometrySummary.coordinateCount} -
-
- Eigenschappen - {featureExtractionEntries.length} -
-
- BBox EPSG:4326 - {featureGeometrySummary.bboxLabel} -
-
-
- - - -
- {featureExtractionEntries.length > 0 ? ( -
- - - - - - - - - {featureExtractionEntries.map(([key, value]) => ( - - - - - ))} - -
EigenschapWaarde
{key}{typeof value === 'object' ? JSON.stringify(value) : String(value)}
-
- ) : ( -

Het geselecteerde object heeft geometrie maar geen bewaarde eigenschappen.

- )} - - ) : ( -
- Geen object geselecteerd -

Klik op een zichtbaar kaartobject om de eigenschappen en GeoJSON te bekijken.

-
- )} -
-
-
-

Objectinspectie

- {selectedMapFeature?.geometry?.type ?? 'geen'} -
- {selectedMapFeature ? ( - <> - {featureSummaryEntries.length > 0 ? ( -
- {featureSummaryEntries.map(([key, value]) => ( -
- {key} - {String(value)} -
- ))} -
- ) : null} -
{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}
- - ) : ( -

Klik op een zichtbaar kaartobject om de eigenschappen te bekijken.

- )} -
-
-
-
-
- ) +/** + * The map workspace has two render paths over one body of derived state: the + * map-first explorer, and the advanced workbench behind it. Both read the same + * view model, so the choice between them is all that is left here. + */ +export function MapWorkspace(props: MapWorkspaceProps): JSX.Element { + const view = useMapWorkspaceViewModel(props) + + return view.advancedMode + ? + : } diff --git a/frontend/src/components/map/mapWorkspaceProps.ts b/frontend/src/components/map/mapWorkspaceProps.ts new file mode 100644 index 00000000..1d74002a --- /dev/null +++ b/frontend/src/components/map/mapWorkspaceProps.ts @@ -0,0 +1,104 @@ +/** + * The map workspace's props. + * + * Its own module so the view-model hook and both render paths can name the + * same shape without importing the component that renders them. + */ + +import type { AreaRead, CoverageResolveResponse, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types' + +export interface MapWorkspaceProps { + readOnly?: boolean + secondaryResultsContainer?: HTMLElement | null + selectedProjectId: string | null + projects: ProjectRead[] + areas: AreaRead[] + selectedMapAreaId: string + areaFeatureCollection: GeoJSON.FeatureCollection | null + mapFeatureCollection: GeoJSON.FeatureCollection | null + qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null + qualityEvidenceFeatureCount?: number + qualityEvidenceLoading?: boolean + qualityEvidenceError?: string | null + qualityEvidenceWarnings?: string[] + mapLayerLabel: string + mapLayerSourceLabel: string + mapLayerProvenance: string + mapLayerVisible: boolean + mapLayerOpacity: number + areaLayerVisible: boolean + areaLayerOpacity: number + mapFeatureCount: number + areaFeatureCount: number + viewportVectorEnabled: boolean + viewportVectorStatus: string | null + viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error' + fitMapDataOnChange: boolean + mapContentMode: 'dataset' | 'analysis' + analysisLayerAvailable: boolean + selectedMapFeature: GeoJSON.Feature | null + selectedFeature?: GeoJSON.Feature | null + mapSelectionBbox: VectorSelectionBBox | null + mapSelectionResult: VectorSelectionResponse | null + mapSelectionLoading: boolean + mapSelectionError: string | null + coverage: CoverageResolveResponse | null + coverageLoading: boolean + coverageError: string | null + coverageDurationMs: number | null + coverageBudgetExceeded: boolean + workspaceLoading: boolean + workspaceError: string | null + selectionExporting: boolean + selectionExportError: string | null + latestSelectionExportPath: string | null + selectionDatasetSaving: boolean + selectionDatasetError: string | null + latestSelectionDataset: DatasetCreateResponse | null + latestSelectionDatasetName: string | null + mapQaReferenceDatasets: DatasetCreateResponse[] + selectedMapQaReferenceDatasetId: string + mapSelectionQaRunning: boolean + mapSelectionQaError: string | null + mapSelectionQaResult: QaComparisonResult | null + latestMapSelectionQualityCheckId: string | null + orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed' + orthophotoAnalysisStatus: string + orthophotoAnalysisError: string | null + orthophotoAnalysisRunning: boolean + orthophotoAnalysisQuality: DetectionQaResult | null + orthophotoAnalysisDetectionCount: number | null + orthophotoProducts: OrthophotoProductRead[] + selectedOrthophotoProductKey: string + orthophotoResult: OrthophotoAcquisitionResult | null + orthophotoImageUrl: string | null + availableMapDatasets: DatasetCreateResponse[] + selectedMapDatasetId: string + onSelectMapArea: (areaId: string) => void + onActivateMunicipality: (niscode: string) => Promise + onSetContextSourceLabel: (label: string | null) => void + onSetContextLayerLabel: (label: string | null) => void + onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void + onSetAreaLayerVisible: (visible: boolean) => void + onSetAreaLayerOpacity: (opacity: number) => void + onSetMapLayerVisible: (visible: boolean) => void + onSetMapLayerOpacity: (opacity: number) => void + onSetMapContentMode: (mode: 'dataset' | 'analysis') => void + onSelectMapFeature: (feature: GeoJSON.Feature | null) => void + onMapViewportChange: (viewport: MapViewportState) => void + onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void + onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise + onClearMapSelectionExtract: () => void + onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise + onPersistMapResult: (payload: MapResultExportRequest) => Promise + onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise + onSelectMapQaReferenceDataset: (datasetId: string) => void + onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise + onOpenMapSelectionQualityEvidence: () => void + onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise + onSelectOrthophotoProduct: (productKey: string) => void + onClearQualityEvidence?: () => void + onRefreshProjectData: () => Promise + onOpenAssistant: () => void + onOpenExports: () => void +} diff --git a/frontend/src/components/map/useMapWorkspaceViewModel.ts b/frontend/src/components/map/useMapWorkspaceViewModel.ts new file mode 100644 index 00000000..1f346a0e --- /dev/null +++ b/frontend/src/components/map/useMapWorkspaceViewModel.ts @@ -0,0 +1,1472 @@ +/** + * Everything the map workspace derives from its props: selected themes, the + * datasets that answer them, the overlays, the temporal series, the handlers. + * + * Extracted so the two render paths — the explorer and the advanced workbench — + * can each be their own module while naming one typed object instead of a + * hundred loose values. `MapWorkspaceViewModel` is derived from what this + * returns, so the shape cannot drift from what it actually produces. + */ + +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { CoverageStatus, DatasetCreateResponse, MapResultExportRequest, VectorSelectionBBox } from '../../types' +import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' +import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts' +import { useTemporalComparison } from '../../hooks/useTemporalComparison' +import { useMapImageOverlays } from '../../hooks/useMapImageOverlays' +import { useMapRectangleSelection } from '../../hooks/useMapRectangleSelection' +import { useFullGisWorkflow } from '../../hooks/useFullGisWorkflow' +import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus' +import { bboxesEqual, copyText, datasetIntersectsSelection, downloadJsonFile, getFeatureBBox, getFeatureCollectionBBox, getFeatureGeometrySummary, isMunicipalityAreaName, operationalScopeProjectLabel, parseBboxInput, persistedDatasetSupportsSelection, productCoversZones, safeFileStem, selectedAreaCoverageZones, selectedFeatureCollection, selectionAnalysisScale, selectionAreaSquareMetres, selectionDimensions, selectionFeatureLimit, selectionMetricLabel, splitSelectionBbox } from './mapWorkspaceUtils' +import { COVERAGE_THEME_BY_MAP_THEME, DATA_THEMES, DATA_THEME_MAP_STYLES, DEFAULT_AREA_SELECTION_FILENAME, DEFAULT_SELECTED_FEATURE_FILENAME, EMPTY_TEMPORAL_SERIES, coverageZoneLabel, datasetCoversSelectedArea, datasetProductKey, floodScenarioLabel, isPartitionedBathymetry, isPartitionedRaster, listThemeTemporalSeries, pickThemeDataset, productSupportsSelection, rasterPartitionsForDataset, themeIdForDataset, type DataTheme, type DataThemeId, type OnDemandMapProduct, type PlannedOnDemandMapProduct, type TemporalSeriesGroup } from './mapWorkspaceThemes' +import type { MapWorkspaceProps } from './mapWorkspaceProps' + +export function useMapWorkspaceViewModel({ + + readOnly = false, + secondaryResultsContainer = null, + selectedProjectId, + projects, + areas, + selectedMapAreaId, + areaFeatureCollection, + mapFeatureCollection, + qualityEvidenceGeoJson = null, + qualityEvidenceFeatureCount = 0, + qualityEvidenceLoading = false, + qualityEvidenceError = null, + qualityEvidenceWarnings = [], + mapLayerLabel, + mapLayerSourceLabel, + mapLayerProvenance, + mapLayerVisible, + mapLayerOpacity, + areaLayerVisible, + areaLayerOpacity, + mapFeatureCount, + areaFeatureCount, + viewportVectorEnabled, + viewportVectorStatus, + viewportVectorTone, + fitMapDataOnChange, + mapContentMode, + analysisLayerAvailable, + selectedMapFeature, + selectedFeature = selectedMapFeature, + mapSelectionBbox, + mapSelectionResult, + mapSelectionLoading, + mapSelectionError, + coverage, + coverageLoading, + coverageError, + coverageDurationMs, + coverageBudgetExceeded, + workspaceLoading, + workspaceError, + selectionExporting, + selectionExportError, + latestSelectionExportPath, + selectionDatasetSaving, + selectionDatasetError, + latestSelectionDataset, + latestSelectionDatasetName, + mapQaReferenceDatasets, + selectedMapQaReferenceDatasetId, + mapSelectionQaRunning, + mapSelectionQaError, + mapSelectionQaResult, + latestMapSelectionQualityCheckId, + orthophotoAnalysisStage, + orthophotoAnalysisStatus, + orthophotoAnalysisError, + orthophotoAnalysisRunning, + orthophotoAnalysisQuality, + orthophotoAnalysisDetectionCount, + orthophotoProducts, + selectedOrthophotoProductKey, + orthophotoResult, + orthophotoImageUrl, + availableMapDatasets, + selectedMapDatasetId, + onSelectMapArea, + onActivateMunicipality, + onSetContextSourceLabel, + onSetContextLayerLabel, + onOpenDatasetInMap, + onSetAreaLayerVisible, + onSetAreaLayerOpacity, + onSetMapLayerVisible, + onSetMapLayerOpacity, + onSetMapContentMode, + onSelectMapFeature, + onMapViewportChange, + onSetMapSelectionBbox, + onRunMapSelectionExtract, + onClearMapSelectionExtract, + onExportMapSelection, + onPersistMapResult, + onDeriveMapSelectionDataset, + onSelectMapQaReferenceDataset, + onRunMapSelectionQa, + onOpenMapSelectionQualityEvidence, + onRunOrthophotoAnalysis, + onSelectOrthophotoProduct, + onClearQualityEvidence, + onRefreshProjectData, + onOpenAssistant, + onOpenExports, +}: MapWorkspaceProps) { + const [advancedMode, setAdvancedMode] = useState(false) + useEffect(() => { + if (readOnly && advancedMode) setAdvancedMode(false) + }, [advancedMode, readOnly]) + const [activeThemeId, setActiveThemeId] = useState(() => { + const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null + return themeIdForDataset(selectedDataset) ?? 'buildings' + }) + const [selectedThemeIds, setSelectedThemeIds] = useState([]) + const [themeFilter, setThemeFilter] = useState('') + const [resultsPanelOpen, setResultsPanelOpen] = useState(false) + const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null + const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied' + const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId) + const selectedCoverageZones = useMemo( + () => selectedAreaCoverageZones(selectedMapArea?.name), + [selectedMapArea?.name], + ) + const flandersScopeSelected = Boolean( + selectedCoverageZones?.includes('flanders') + || (!selectedCoverageZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME), + ) + const walloniaScopeSelected = Boolean(selectedCoverageZones?.includes('wallonia')) + const { + themeInsights, + themeInsightsLoading: themeResultsLoading, + themeInsightsError: themeResultsError, + loadThemeInsights, + clearThemeInsights, + } = useMapThemeSelectionInsights(selectedProjectId, onRefreshProjectData) + const { + products: officialMapProducts, + loading: officialMapProductsLoading, + error: officialMapProductsError, + resolveCoverage, + resolveCoveragePartitions, + } = useOfficialMapProducts(selectedProjectId) + const { + temporalComparison, + temporalComparisonLoading, + temporalComparisonError, + compareTemporalSnapshots, + clearTemporalComparison, + } = useTemporalComparison(selectedProjectId) + const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current') + const [selectedFloodHazardDatasetId, setSelectedFloodHazardDatasetId] = useState('') + const [selectedDhmvProductKey, setSelectedDhmvProductKey] = useState<'dtm_1m' | 'dsm_1m'>('dtm_1m') + const [selectedFloodHazardProductKey, setSelectedFloodHazardProductKey] = useState('pluviaal_current_t100') + const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('') + const [earlierDatasetId, setEarlierDatasetId] = useState('') + const [laterDatasetId, setLaterDatasetId] = useState('') + const rectangle = useMapRectangleSelection(mapSelectionBbox) + const { + drawing: bboxSelectionMode, + setDrawing: setBboxSelectionMode, + bboxInput, + setBboxInput, + } = rectangle + const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState(null) + const mapAnalysisRequestSequence = useRef(0) + const regionalScopeSelected = Boolean(selectedMapArea && !isMunicipalityAreaName(selectedMapArea.name)) + const featureProperties = selectedMapFeature?.properties ?? null + const isBathymetryProfile = featureProperties?.['provider'] === 'vmm_vha_bathymetry_profiles' + || featureProperties?.['measurement_semantics'] === 'historical_cross_section_profile_point' + const bathymetryDocumentUrl = typeof featureProperties?.['source_document_url'] === 'string' + && featureProperties['source_document_url'].startsWith('https://vha.waterinfo.be/') + ? featureProperties['source_document_url'] + : null + const featureSummaryEntries = featureProperties + ? Object.entries(featureProperties) + .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object') + .slice(0, 6) + : [] + const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : [] + const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature) + const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null + const selectedFeatureBbox = useMemo(() => getFeatureBBox(selectedMapFeature), [selectedMapFeature]) + const activeLayerBbox = useMemo(() => getFeatureCollectionBBox(mapFeatureCollection), [mapFeatureCollection]) + const selectedAreaBbox = useMemo(() => getFeatureCollectionBBox(areaFeatureCollection), [areaFeatureCollection]) + const currentSelectionBbox = parseBboxInput(bboxInput) + const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? [] + const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12) + const selectedFeatureStem = safeFileStem( + featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature', + ) + const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson` + const selectedMapDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null + const usesDefaultOsmBasemap = !import.meta.env.VITE_MAP_STYLE_URL + const floodHazardDatasets = useMemo( + () => { + const scoped = availableMapDatasets + .filter( + (dataset) => + dataset.source_name === 'vmm_flood_hazard' + && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), + ) + .sort((left, right) => floodScenarioLabel(left).localeCompare(floodScenarioLabel(right), 'nl')) + if (!regionalScopeSelected) { + return scoped + } + const products = new Map() + for (const dataset of scoped) { + const key = datasetProductKey(dataset) + if (key && !products.has(key)) { + products.set(key, dataset) + } + } + return Array.from(products.values()) + }, + [availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId], + ) + const themeDatasetMap = useMemo(() => { + const result = Object.fromEntries( + DATA_THEMES.map((theme) => [ + theme.id, + pickThemeDataset( + availableMapDatasets, + theme, + selectedMapAreaId, + selectedMapArea?.name, + regionalScopeSelected, + ), + ]), + ) as Record + const selectedFloodHazard = floodHazardDatasets.find( + (dataset) => + dataset.id === selectedFloodHazardDatasetId + || (flandersScopeSelected && datasetProductKey(dataset) === selectedFloodHazardProductKey), + ) + if (selectedFloodHazard) { + result.flood_hazard = selectedFloodHazard + } else if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) { + result.flood_hazard = null + } + if (flandersScopeSelected && officialMapProducts.dhmv.length > 0) { + result.elevation = availableMapDatasets.find( + (dataset) => + dataset.source_name === 'digitaal_vlaanderen_dhmv' + && datasetProductKey(dataset) === selectedDhmvProductKey + && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), + ) ?? null + } + if (walloniaScopeSelected && officialMapProducts.spwTerrain.some((product) => product.configured)) { + result.elevation = availableMapDatasets.find( + (dataset) => + dataset.source_name === 'spw_terrain' + && datasetProductKey(dataset) === 'spw_mnt_1m_2021_2022' + && datasetCoversSelectedArea(dataset, selectedMapAreaId, selectedMapArea?.name, regionalScopeSelected), + ) ?? null + } + if (flandersScopeSelected && officialMapProducts.thematic.length > 0) { + for (const product of officialMapProducts.thematic) { + if (!result[product.theme]) { + result[product.theme] = null + } + } + } + if (flandersScopeSelected && officialMapProducts.grb.length > 0) { + for (const product of officialMapProducts.grb) { + if (!result[product.key]) { + result[product.key] = null + } + } + } + if (officialMapProducts.officialVector.length > 0) { + for (const product of officialMapProducts.officialVector.filter((item) => + productCoversZones(item.coverage_zones, selectedCoverageZones), + )) { + if (!result[product.theme]) { + result[product.theme] = null + } + } + } + return result + }, [ + availableMapDatasets, + flandersScopeSelected, + floodHazardDatasets, + officialMapProducts.dhmv.length, + officialMapProducts.spwTerrain, + officialMapProducts.floodHazard.length, + officialMapProducts.grb, + officialMapProducts.officialVector, + officialMapProducts.thematic, + regionalScopeSelected, + selectedDhmvProductKey, + selectedFloodHazardDatasetId, + selectedFloodHazardProductKey, + selectedMapArea?.name, + selectedMapAreaId, + selectedCoverageZones, + walloniaScopeSelected, + ]) + const themePartitionMap = useMemo( + () => + Object.fromEntries( + DATA_THEMES.map((theme) => [ + theme.id, + rasterPartitionsForDataset( + availableMapDatasets, + themeDatasetMap[theme.id], + selectedMapAreaId, + selectedMapArea?.name, + regionalScopeSelected, + ), + ]), + ) as Record, + [availableMapDatasets, regionalScopeSelected, selectedMapArea?.name, selectedMapAreaId, themeDatasetMap], + ) + const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0] + const activeCoverageTheme = COVERAGE_THEME_BY_MAP_THEME[activeTheme.id] + const activeCoverageItems = coverage?.items.filter((item) => item.theme === activeCoverageTheme) ?? [] + const coverageCounts = useMemo( + () => coverage?.items.reduce>( + (counts, item) => ({ ...counts, [item.status]: counts[item.status] + 1 }), + { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, + ) ?? { operational: 0, partial: 0, not_configured: 0, unsupported: 0 }, + [coverage], + ) + const onDemandProductsForZones = useCallback((zones: string[] | null): OnDemandMapProduct[] => { + const result: OnDemandMapProduct[] = [] + const effectiveZones = zones ?? selectedCoverageZones + const includesFlanders = Boolean( + effectiveZones?.includes('flanders') + || (!effectiveZones && activeScopeProject?.name === FLANDERS_WORKSPACE_PROJECT_NAME), + ) + const includesWallonia = Boolean(effectiveZones?.includes('wallonia')) + if (includesFlanders) { + for (const product of officialMapProducts.thematic) { + result.push({ + kind: 'thematic_raster', + productKey: product.key, + displayName: product.display_name, + theme: product.theme, + availabilityLabel: `${product.native_resolution_m} m · ${product.observation_year} · automatisch bij selectie`, + attribution: product.attribution, + limitationMessage: product.limitation_message, + coverageZones: ['flanders'], + }) + } + for (const product of officialMapProducts.grb) { + result.push({ + kind: 'grb', + productKey: product.key, + displayName: product.display_name, + theme: product.key, + availabilityLabel: 'officiële vectorbron · automatisch bij selectie', + attribution: product.attribution, + limitationMessage: product.limitation_message, + coverageZones: ['flanders'], + }) + } + for (const source of officialMapProducts.bathymetry.filter( + (item) => + item.key === 'vha_inland_profiles' + && item.acquisition_supported + && item.configured, + )) { + result.push({ + kind: 'bathymetry_profiles', + productKey: source.key, + displayName: source.display_name, + theme: 'bathymetry', + availabilityLabel: 'historische profielpunten · automatisch bij selectie', + attribution: source.attribution, + limitationMessage: source.limitation_message, + coverageZones: ['flanders'], + }) + } + } + const latestWalous = officialMapProducts.walous + .filter((product) => product.configured && productCoversZones(product.coverage_zones, effectiveZones)) + .sort((left, right) => right.observation_year - left.observation_year)[0] + if (latestWalous) { + result.push({ + kind: 'walous', + productKey: latestWalous.key, + historyProductKeys: officialMapProducts.walous + .filter( + (product) => + product.configured + && product.key !== latestWalous.key + && productCoversZones(product.coverage_zones, effectiveZones), + ) + .map((product) => product.key), + displayName: latestWalous.display_name, + theme: 'land_cover', + availabilityLabel: `${latestWalous.analysis_resolution_m ?? latestWalous.native_resolution_m} m analyse · ${latestWalous.observation_year} · automatisch bij selectie`, + attribution: latestWalous.attribution, + limitationMessage: latestWalous.limitation_message, + coverageZones: latestWalous.coverage_zones, + }) + } + for (const product of officialMapProducts.officialVector.filter((item) => + productCoversZones(item.coverage_zones, effectiveZones), + )) { + result.push({ + kind: 'official_vector', + productKey: product.key, + displayName: product.display_name, + theme: product.theme, + availabilityLabel: `${product.observation_label} · officiële vectorbron · automatisch bij selectie`, + attribution: product.attribution, + limitationMessage: product.limitation_message, + coverageZones: product.coverage_zones, + }) + } + const dhmvProduct = includesFlanders + ? officialMapProducts.dhmv.find((product) => product.key === selectedDhmvProductKey) + : null + if (dhmvProduct) { + result.push({ + kind: 'dhmv', + productKey: dhmvProduct.key, + displayName: dhmvProduct.display_name, + theme: 'elevation', + availabilityLabel: `${dhmvProduct.native_resolution_m} m · ${dhmvProduct.acquisition_period} · automatisch bij selectie`, + attribution: dhmvProduct.attribution, + limitationMessage: dhmvProduct.limitation_message, + coverageZones: ['flanders'], + }) + } + const spwTerrainProduct = includesWallonia + ? officialMapProducts.spwTerrain.find((product) => product.configured) + : null + if (spwTerrainProduct) { + result.push({ + kind: 'spw_terrain', + productKey: spwTerrainProduct.key, + displayName: spwTerrainProduct.display_name, + theme: 'elevation', + availabilityLabel: `${spwTerrainProduct.analysis_resolution_m} m analyse · ${spwTerrainProduct.acquisition_period} · automatisch bij selectie`, + attribution: spwTerrainProduct.attribution, + limitationMessage: spwTerrainProduct.limitation_message, + coverageZones: spwTerrainProduct.coverage_zones, + }) + } + const floodProduct = includesFlanders + ? officialMapProducts.floodHazard.find( + (product) => product.key === selectedFloodHazardProductKey, + ) + : null + if (floodProduct) { + result.push({ + kind: 'flood_hazard', + productKey: floodProduct.key, + displayName: floodProduct.display_name, + theme: 'flood_hazard', + availabilityLabel: `${floodProduct.native_resolution_m} m · ${floodProduct.climate_context} · T${floodProduct.return_period_years} · automatisch bij selectie`, + attribution: floodProduct.attribution, + limitationMessage: floodProduct.limitation_message, + coverageZones: ['flanders'], + }) + } + return result + }, [ + activeScopeProject?.name, + officialMapProducts, + selectedCoverageZones, + selectedDhmvProductKey, + selectedFloodHazardProductKey, + ]) + const onDemandProductMap = useMemo(() => { + const result = new Map() + const productsByTheme = new Map() + for (const product of onDemandProductsForZones(selectedCoverageZones)) { + productsByTheme.set(product.theme, [...(productsByTheme.get(product.theme) ?? []), product]) + } + for (const [theme, products] of productsByTheme) { + if (products.length === 1) { + const product = products[0] + const scopeZones = product.coverageZones.filter((zone) => selectedCoverageZones?.includes(zone) ?? true) + result.set(theme, selectedCoverageZones && selectedCoverageZones.length > 1 + ? { + ...product, + availabilityLabel: `${product.availabilityLabel} · alleen ${scopeZones.map(coverageZoneLabel).join(', ')}`, + } + : product) + continue + } + const coverageZones = Array.from(new Set( + products.flatMap((product) => product.coverageZones) + .filter((zone) => selectedCoverageZones?.includes(zone) ?? true), + )) + result.set(theme, { + ...products[0], + displayName: 'Officiële bron per regio', + availabilityLabel: `${coverageZones.map(coverageZoneLabel).join(', ')} · bron wordt na selectie bepaald`, + attribution: 'Officiële Belgische en gewestelijke databronnen', + limitationMessage: 'GeoIntel bepaalt na de getekende selectie welke regionale bron van toepassing is en voegt alleen semantisch gelijkwaardige resultaten samen.', + coverageZones, + }) + } + return result + }, [onDemandProductsForZones, selectedCoverageZones]) + const mapSelectionScale = mapSelectionBbox ? selectionAnalysisScale(mapSelectionBbox) : null + const selectionRelevantThemes = useMemo(() => { + if (!mapSelectionBbox || !coverage) { + return DATA_THEMES + } + const boundedThemes = new Set( + (mapSelectionBbox + ? onDemandProductsForZones(coverage.intersected_zones).filter( + (product) => productSupportsSelection(product, mapSelectionBbox), + ) + : []) + .map((product) => product.theme), + ) + return DATA_THEMES.filter((theme) => { + if (boundedThemes.has(theme.id)) { + return true + } + const dataset = themeDatasetMap[theme.id] + if (!dataset || !persistedDatasetSupportsSelection(dataset, mapSelectionBbox)) { + return false + } + const coverageTheme = COVERAGE_THEME_BY_MAP_THEME[theme.id] + return coverage.items.some( + (item) => item.theme === coverageTheme && item.status === 'operational', + ) + }) + }, [coverage, mapSelectionBbox, mapSelectionScale, onDemandProductsForZones, themeDatasetMap]) + const activeThemeSupportsCurrentSelection = selectionRelevantThemes.some((theme) => theme.id === activeTheme.id) + const activeOnDemandMapProduct = mapSelectionScale === 'overview' || themeDatasetMap[activeTheme.id] + ? null + : onDemandProductMap.get(activeTheme.id) ?? null + const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id] + const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection) + const liveJourneyError = mapSelectionError + ?? themeResultsError + ?? temporalComparisonError + ?? orthophotoAnalysisError + ?? coverageError + ?? workspaceError + const liveJourneyHasResult = Boolean( + mapSelectionResult + || themeInsights.length > 0 + || temporalComparison + || orthophotoResult + || analysisOverlayActive, + ) + const liveJourneyVerified = Boolean(mapSelectionQaResult || orthophotoAnalysisQuality) + const liveJourneyProcessing = Boolean( + mapSelectionLoading + || themeResultsLoading + || temporalComparisonLoading + || orthophotoAnalysisRunning, + ) + const liveJourneyValidating = Boolean( + mapSelectionQaRunning || orthophotoAnalysisStage === 'validating', + ) + const liveJourneyStatus = orthophotoAnalysisStatus + || (temporalComparisonLoading ? 'Officiële meetmomenten vergelijken…' : '') + || (themeResultsLoading ? 'Begrensde bronnen verwerken…' : '') + || (mapSelectionLoading ? 'Objecten binnen de selectie ophalen…' : '') + || (liveJourneyHasResult ? 'Resultaat op de kaart beschikbaar' : 'Klaar om de selectie te verwerken') + const liveJourneyResultLabel = liveJourneyVerified + ? 'Kwaliteitsbewijs beschikbaar' + : latestMapSelectionQualityCheckId + ? 'Bewaard kwaliteitsbewijs beschikbaar' + : 'Controleerbaar resultaat' + const selectedOrthophotoProduct = orthophotoProducts.find((item) => item.key === selectedOrthophotoProductKey) ?? null + const orthophotoImageOverlay = useMemo( + () => orthophotoResult && orthophotoImageUrl && orthophotoResult.bbox_epsg4326.length === 4 + ? { + url: orthophotoImageUrl, + bbox: orthophotoResult.bbox_epsg4326 as [number, number, number, number], + label: orthophotoResult.display_name, + opacity: 0.9, + } + : null, + [orthophotoImageUrl, orthophotoResult], + ) + const activeThemeDataset = themeDatasetMap[activeTheme.id] + const activeThemePartitions = themePartitionMap[activeTheme.id] + const regionalRasterThemeActive = regionalScopeSelected && isPartitionedRaster(activeThemeDataset) + const regionalBathymetryThemeActive = regionalScopeSelected && isPartitionedBathymetry(activeThemeDataset) + const regionalPartitionedThemeActive = regionalRasterThemeActive || regionalBathymetryThemeActive + const onDemandThemeActive = analysisMode === 'current' && Boolean(activeOnDemandMapProduct) + const regionalOnDemandThemeActive = regionalScopeSelected && onDemandThemeActive + const activeThemeAvailable = Boolean(activeThemeDataset) || onDemandThemeActive + + const visibleThemes = useMemo(() => { + const query = themeFilter.trim().toLocaleLowerCase('nl-BE') + if (!query) return DATA_THEMES + return DATA_THEMES.filter((theme) => theme.label.toLocaleLowerCase('nl-BE').includes(query)) + }, [themeFilter]) + const selectedThemes = useMemo( + () => DATA_THEMES.filter((theme) => selectedThemeIds.includes(theme.id)), + [selectedThemeIds], + ) + + useEffect(() => { + if ( + analysisMode !== 'current' + || activeThemeAvailable + || ( + selectedProjectId + && officialMapProductsLoading + && onDemandProductMap.size === 0 + && !officialMapProductsError + ) + ) { + return + } + const fallbackTheme = DATA_THEMES.find((theme) => + flandersScopeSelected + && theme.id === 'space_occupation' + && Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)), + ) ?? DATA_THEMES.find((theme) => + Boolean(themeDatasetMap[theme.id] || onDemandProductMap.get(theme.id)), + ) + if (!fallbackTheme) { + return + } + setActiveThemeId(fallbackTheme.id) + const fallbackDataset = themeDatasetMap[fallbackTheme.id] + if (fallbackDataset) { + onOpenDatasetInMap(fallbackDataset) + } + }, [ + activeThemeAvailable, + analysisMode, + flandersScopeSelected, + officialMapProductsError, + officialMapProductsLoading, + onDemandProductMap, + onOpenDatasetInMap, + selectedProjectId, + themeDatasetMap, + ]) + + const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde') + const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde') + const activeImageOverlays = useMapImageOverlays({ + selectedProjectId, + activeThemeId: activeTheme.id, + activeThemeDataset, + activeThemePartitions, + orthophotoImageOverlay, + floodScenarioLabel, + }) + const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length + const themeTemporalSeriesMap = useMemo( + () => + Object.fromEntries( + DATA_THEMES.map((theme) => [theme.id, listThemeTemporalSeries(availableMapDatasets, theme, selectedMapAreaId)]), + ) as Record, + [availableMapDatasets, selectedMapAreaId], + ) + const activeTemporalSeriesGroups = themeTemporalSeriesMap[activeTheme.id] + const availableEvolutionThemes = DATA_THEMES.filter((theme) => + themeTemporalSeriesMap[theme.id].some((group) => group.items.length >= 2), + ) + const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey) + ?? activeTemporalSeriesGroups[0] + const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES + const earlierTemporalOptions = activeTemporalSeries.slice(0, -1) + const selectedEarlierSnapshot = activeTemporalSeries.find((dataset) => dataset.id === earlierDatasetId) + const selectedLaterSnapshot = activeTemporalSeries.find((dataset) => dataset.id === laterDatasetId) + const selectedEarlierTime = new Date(selectedEarlierSnapshot?.observed_at ?? 0).getTime() + const laterTemporalOptions = activeTemporalSeries.filter( + (dataset) => new Date(dataset.observed_at ?? 0).getTime() > selectedEarlierTime, + ) + const temporalSelectionValid = Boolean( + selectedEarlierSnapshot + && selectedLaterSnapshot + && selectedEarlierSnapshot.id !== selectedLaterSnapshot.id + && selectedEarlierTime < new Date(selectedLaterSnapshot.observed_at ?? 0).getTime(), + ) + const activeSeriesIsDailyGrb = activeTemporalSeries.length >= 2 + && activeTemporalSeries.every((dataset) => dataset.source_name === 'grb') + && new Date(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at ?? 0).getTime() + - new Date(activeTemporalSeries[0].observed_at ?? 0).getTime() <= 7 * 24 * 60 * 60 * 1000 + + useEffect(() => { + const contextSourceLabel = analysisMode === 'evolution' ? activeTemporalSeriesGroup?.label ?? null + : regionalBathymetryThemeActive ? 'VHA-dwarsprofielen Vlaanderen' + : activeOnDemandMapProduct?.displayName ?? null + onSetContextSourceLabel(contextSourceLabel) + return () => onSetContextSourceLabel(null) + }, [ + activeTemporalSeriesGroup?.label, + activeOnDemandMapProduct?.displayName, + analysisMode, + onSetContextSourceLabel, + regionalBathymetryThemeActive, + ]) + + const themeResults = useMemo( + () => + themeInsights.flatMap((insight) => { + const theme = DATA_THEMES.find((candidate) => candidate.id === insight.themeId) + return theme ? [{ theme, dataset: insight.dataset, result: insight.result }] : [] + }), + [themeInsights], + ) + const activeThemeInsight = themeResults.find((item) => item.theme.id === activeThemeId) + const activeResultDataset = activeThemeInsight?.dataset ?? activeThemeDataset + const activeSelectionResult = activeThemeInsight?.result + ?? (!regionalPartitionedThemeActive && selectedMapDataset?.id === activeThemeDataset?.id ? mapSelectionResult : null) + useEffect(() => { + const contextLayerLabel = analysisMode === 'current' && activeOnDemandMapProduct + ? activeSelectionResult + ? `${( + activeSelectionResult.total_feature_count + ?? activeSelectionResult.feature_count + ).toLocaleString('nl-BE')} objecten gemeten` + : 'Automatisch bij selectie' + : null + onSetContextLayerLabel(contextLayerLabel) + return () => onSetContextLayerLabel(null) + }, [ + activeOnDemandMapProduct, + activeSelectionResult, + analysisMode, + onSetContextLayerLabel, + ]) + const explorerMapFeatureCollection = regionalBathymetryThemeActive + ? null + : analysisMode === 'evolution' && temporalComparison?.geojson.features.length + ? temporalComparison.geojson + : onDemandThemeActive + ? null + : mapFeatureCollection + const explorerSelectionFeatureCollection = analysisMode === 'current' + ? activeSelectionResult?.geojson ?? null + : null + const selectedAreaSquareMetres = useMemo( + () => + bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2 + ? selectedMapArea.area_m2 + : selectionAreaSquareMetres(mapSelectionBbox), + [mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2], + ) + const selectionScaleNotice = useMemo(() => { + if (!mapSelectionBbox || !mapSelectionScale) return null + const dimensions = selectionDimensions(mapSelectionBbox) + const widthKm = dimensions.widthMetres / 1000 + const heightKm = dimensions.heightMetres / 1000 + if (mapSelectionScale === 'regional') { + const partitionCount = splitSelectionBbox(mapSelectionBbox).length + return `Regionale selectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. De server verwerkt dit thema hervatbaar over ${partitionCount} of meer bronafhankelijke partities en presenteert één gezamenlijke status.` + } + if (mapSelectionScale === 'overview') { + return `Overzichtsselectie van ${widthKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} x ${heightKm.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} km. De server kiest automatisch bronlimieten, checkpoints en partities; resolutie en beschikbare dekking blijven die van de officiële bron.` + } + return null + }, [mapSelectionBbox, mapSelectionScale]) + const selectedResultTotal = activeSelectionResult?.total_feature_count ?? activeSelectionResult?.feature_count ?? 0 + const selectedDensity = selectedAreaSquareMetres && selectedAreaSquareMetres > 0 + ? selectedResultTotal / (selectedAreaSquareMetres / 1_000_000) + : null + const activeMetricValue = activeSelectionResult?.summary?.metric_value ?? selectedResultTotal + const activeMetricUnit = activeSelectionResult?.summary?.metric_unit ?? 'objecten' + const activeMetricLabel = activeSelectionResult?.summary?.metric_label ?? activeTheme.shortLabel + const activeSupportingMetrics = (activeSelectionResult?.summary?.metrics ?? []).filter( + (metric) => metric.metric_key !== activeSelectionResult?.summary?.primary_metric_key, + ) + const terrainReliefMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'relief_m') + const populationDensityMetric = activeSupportingMetrics.find((metric) => metric.metric_key === 'population_density_mean_per_ha') + const scoreMedianMetric = activeSupportingMetrics.find((metric) => metric.metric_key.endsWith('_median')) + const activeSecondaryMetric = activeMetricUnit === 'm TAW' || activeMetricUnit === 'm DNG' + ? terrainReliefMetric ? `${terrainReliefMetric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits: 2 })} m reliëf` : null + : activeMetricUnit === 'inwoners' + ? populationDensityMetric ? selectionMetricLabel(populationDensityMetric) : null + : activeMetricUnit.startsWith('score') + ? scoreMedianMetric ? selectionMetricLabel(scoreMedianMetric) : null + : selectedAreaSquareMetres && selectedAreaSquareMetres > 0 + ? activeMetricUnit === 'ha' + ? `${((activeMetricValue * 10_000) / selectedAreaSquareMetres * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}% dekking` + : `${(activeMetricValue / (selectedAreaSquareMetres / 1_000_000)).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} ${activeMetricUnit} / km2` + : null + const activeSecondaryLabel = activeMetricUnit === 'ha' + ? 'Aandeel selectie' + : activeMetricUnit === 'm TAW' || activeMetricUnit === 'm DNG' + ? 'Reliëf' + : activeMetricUnit === 'inwoners' + ? 'Gemiddelde dichtheid' + : activeMetricUnit.startsWith('score') + ? 'Mediaan' + : 'Dichtheid' + const selectedResultProperties = useMemo(() => { + const keys = new Map>() + for (const feature of activeSelectionResult?.geojson.features ?? []) { + for (const [key, value] of Object.entries(feature.properties ?? {})) { + if (value === null || value === undefined || typeof value === 'object' || key.endsWith('_id')) { + continue + } + const values = keys.get(key) ?? new Set() + if (values.size < 4) { + values.add(String(value)) + } + keys.set(key, values) + } + } + return Array.from(keys.entries()) + .filter(([, values]) => values.size > 0) + .slice(0, 8) + .map(([key, values]) => ({ key, values: Array.from(values) })) + }, [activeSelectionResult]) + + useEffect(() => { + rectangle.showBbox((mapSelectionBbox)) + }, [mapSelectionBbox]) + + useEffect(() => { + setSelectedTemporalSeriesKey((current) => + activeTemporalSeriesGroups.some((group) => group.key === current) + ? current + : activeTemporalSeriesGroups[0]?.key ?? '', + ) + }, [activeTemporalSeriesGroups]) + + useEffect(() => { + const selected = floodHazardDatasets.find( + (dataset) => datasetProductKey(dataset) === selectedFloodHazardProductKey, + ) + if (selected) { + if (selected.id !== selectedFloodHazardDatasetId) { + setSelectedFloodHazardDatasetId(selected.id) + } + return + } + if (flandersScopeSelected && officialMapProducts.floodHazard.length > 0) { + setSelectedFloodHazardDatasetId('') + return + } + const preferred = floodHazardDatasets.find( + (dataset) => dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100', + ) ?? floodHazardDatasets[0] + setSelectedFloodHazardDatasetId(preferred?.id ?? '') + if (preferred && datasetProductKey(preferred)) { + setSelectedFloodHazardProductKey(datasetProductKey(preferred)) + } + }, [ + flandersScopeSelected, + floodHazardDatasets, + officialMapProducts.floodHazard.length, + selectedFloodHazardDatasetId, + selectedFloodHazardProductKey, + ]) + + useEffect(() => { + const first = activeTemporalSeries[0] + const last = activeTemporalSeries[activeTemporalSeries.length - 1] + setEarlierDatasetId(first?.id ?? '') + setLaterDatasetId(last?.id ?? '') + clearTemporalComparison() + }, [activeTemporalSeries]) + + useEffect(() => { + if (advancedMode || !activeThemeDataset || selectedMapDataset?.id === activeThemeDataset.id) { + return + } + onOpenDatasetInMap(activeThemeDataset) + }, [activeTheme, activeThemeDataset, advancedMode, onOpenDatasetInMap, selectedMapDataset]) + + useEffect(() => { + if (!selectedMapDataset) { + return + } + const selectedProductKey = datasetProductKey(selectedMapDataset) + if ( + selectedMapDataset.source_name === 'digitaal_vlaanderen_dhmv' + && (selectedProductKey === 'dtm_1m' || selectedProductKey === 'dsm_1m') + ) { + setSelectedDhmvProductKey(selectedProductKey) + } + if (selectedMapDataset.source_name === 'vmm_flood_hazard' && selectedProductKey) { + setSelectedFloodHazardProductKey(selectedProductKey) + setSelectedFloodHazardDatasetId(selectedMapDataset.id) + } + const matchingThemeId = themeIdForDataset(selectedMapDataset) + if (matchingThemeId) { + setActiveThemeId(matchingThemeId) + } + }, [selectedMapDataset]) + + const downloadSelectedMapFeature = () => { + if (!selectedFeatureGeoJson) { + return + } + downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson, 'application/geo+json') + } + + const copySelectedMapFeatureProperties = () => { + copyText(JSON.stringify(featureProperties ?? {}, null, 2)) + } + + const setSelectionBbox = (bbox: VectorSelectionBBox | null) => { + onSetMapSelectionBbox(bbox) + rectangle.showBbox((bbox)) + } + + const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => ( + bbox && selectedMapArea ? selectedMapArea.id : undefined + ) + + const startBboxSelection = () => { + rectangle.reset() + clearThemeInsights() + clearTemporalComparison() + setResultsPanelOpen(false) + setBboxSelectionMode(true) + } + + const handleMapCoordinateSelect = (coordinate: [number, number]) => { + // The first click only places a corner; there is no rectangle to act on yet. + const bbox = rectangle.placeCorner(coordinate) + if (!bbox) return + setSelectionBbox(bbox) + clearThemeInsights() + clearTemporalComparison() + setResultsPanelOpen(false) + } + + const runAreaExtract = () => { + const bbox = parseBboxInput(bboxInput) + if (!bbox) { + return + } + void analyzeSelection(bbox, areaIdForSelection(bbox)) + } + + const clearAreaSelection = () => { + mapAnalysisRequestSequence.current += 1 + setMapAnalysisDurationMs(null) + rectangle.reset() + rectangle.showBbox((null)) + setResultsPanelOpen(false) + clearThemeInsights() + clearTemporalComparison() + onClearMapSelectionExtract() + } + + const handleSelectMapArea = (areaId: string) => { + clearAreaSelection() + onSelectMapArea(areaId) + } + + const downloadAreaSelection = () => { + if (!mapSelectionResult) { + return + } + downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson, 'application/geo+json') + } + + const copyAreaSelection = () => { + copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2)) + } + + const downloadActiveThemeResult = () => { + if (!activeSelectionResult) { + return + } + if (activeResultDataset?.dataset_type === 'raster') { + downloadJsonFile(`${activeTheme.id}-analysis.json`, { + project_id: selectedProjectId, + area_id: areaIdForSelection(mapSelectionBbox) ?? null, + area_name: selectedMapArea?.name ?? null, + theme: activeTheme, + dataset_id: activeResultDataset.id, + dataset_name: activeResultDataset.name, + source_name: activeResultDataset.source_name, + result: activeSelectionResult, + }) + return + } + downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson, 'application/geo+json') + } + + const copyActiveThemeResult = () => { + copyText(JSON.stringify(activeSelectionResult ?? {}, null, 2)) + } + + const downloadTemporalComparison = () => { + if (!temporalComparison) { + return + } + downloadJsonFile(`${activeTheme.id}-evolution-${temporalComparison.earlier.observed_at.slice(0, 10)}-${temporalComparison.later.observed_at.slice(0, 10)}.json`, temporalComparison) + } + + const copyTemporalComparison = () => { + copyText(JSON.stringify(temporalComparison ?? {}, null, 2)) + } + + const persistActiveResultAndOpenDownloads = async () => { + if (!selectedProjectId || !mapSelectionBbox) { + onOpenExports() + return + } + const areaId = areaIdForSelection(mapSelectionBbox) + const payload: MapResultExportRequest | null = analysisMode === 'evolution' + ? temporalComparison && earlierDatasetId && laterDatasetId + ? { + project_id: selectedProjectId, + mode: 'evolution', + bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, + earlier_dataset_id: earlierDatasetId, + later_dataset_id: laterDatasetId, + area_id: areaId, + theme_id: activeTheme.id, + name: `${activeTheme.id}-evolution`, + } + : null + : activeSelectionResult && activeResultDataset + ? { + project_id: selectedProjectId, + mode: 'current', + bbox: { ...mapSelectionBbox, crs: 'EPSG:4326' }, + dataset_id: activeResultDataset.id, + area_id: areaId, + partitioned: regionalPartitionedThemeActive, + product_key: regionalRasterThemeActive + ? String(activeResultDataset.source_metadata?.['product_key'] ?? '') || undefined + : undefined, + partition_scope_key: regionalBathymetryThemeActive + ? String(activeResultDataset.source_metadata?.['partition_scope_key'] ?? 'flanders') + : undefined, + theme_id: activeTheme.id, + name: `${activeTheme.id}-analysis`, + } + : null + if (!payload) { + onOpenExports() + return + } + const persisted = await onPersistMapResult(payload) + if (persisted) { + onOpenExports() + } + } + + const saveAreaSelectionExport = () => { + const bbox = parseBboxInput(bboxInput) + if (!bbox) { + return + } + onExportMapSelection(bbox, areaIdForSelection(bbox)) + } + + const saveAreaSelectionDataset = () => { + const bbox = parseBboxInput(bboxInput) + if (!bbox) { + return + } + onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox)) + } + + const openSelectedDatabaseLayer = (datasetId: string) => { + const dataset = availableMapDatasets.find((item) => item.id === datasetId) + if (dataset) { + onOpenDatasetInMap(dataset) + } + } + + const selectDataTheme = (theme: DataTheme) => { + const temporalGroup = themeTemporalSeriesMap[theme.id][0] + const dataset = analysisMode === 'evolution' + ? temporalGroup?.items[temporalGroup.items.length - 1] ?? null + : themeDatasetMap[theme.id] + const onDemandProduct = analysisMode === 'current' + ? onDemandProductMap.get(theme.id) + : null + if (!dataset && !onDemandProduct) { + return + } + setSelectedThemeIds((current) => ( + current.includes(theme.id) + ? current.filter((themeId) => themeId !== theme.id) + : [...current, theme.id] + )) + setActiveThemeId(theme.id) + clearThemeInsights() + clearTemporalComparison() + if (dataset) { + onOpenDatasetInMap(dataset) + } + } + + const setExplorerMode = (mode: 'current' | 'evolution') => { + setAnalysisMode(mode) + setSelectedThemeIds([]) + setResultsPanelOpen(false) + clearThemeInsights() + clearTemporalComparison() + if (mode !== 'evolution' || activeTemporalSeriesGroups.length > 0) { + return + } + const fallbackTheme = availableEvolutionThemes[0] + const fallbackGroup = fallbackTheme ? themeTemporalSeriesMap[fallbackTheme.id][0] : null + const fallbackDataset = fallbackGroup?.items[fallbackGroup.items.length - 1] + if (fallbackTheme && fallbackDataset) { + setActiveThemeId(fallbackTheme.id) + onOpenDatasetInMap(fallbackDataset) + } + } + + const handleAnalysisModeKeyDown = ( + event: KeyboardEvent, + mode: 'current' | 'evolution', + ) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return + event.preventDefault() + const nextMode = event.key === 'ArrowLeft' || event.key === 'Home' + ? 'current' + : event.key === 'ArrowRight' || event.key === 'End' + ? 'evolution' + : mode + setExplorerMode(nextMode) + window.requestAnimationFrame(() => { + document.getElementById(`geo-analysis-tab-${nextMode}`)?.focus() + }) + } + + const loadSelectedThemeResult = async (bbox: VectorSelectionBBox, areaId?: string) => { + let resolvedZones = selectedCoverageZones + const scale = selectionAnalysisScale(bbox) + if (analysisMode === 'current' && selectedProjectId) { + const resolvedCoverage = await resolveCoverage({ + minx: bbox.min_x, + miny: bbox.min_y, + maxx: bbox.max_x, + maxy: bbox.max_y, + }) + if (!resolvedCoverage) { + clearThemeInsights() + return + } + resolvedZones = resolvedCoverage.intersected_zones + } + let resolvedProducts: PlannedOnDemandMapProduct[] = [] + if (analysisMode === 'current') { + const zoneProducts = resolvedZones + ? onDemandProductsForZones(resolvedZones) + : [] + if (scale === 'detail' || !selectedProjectId || scale === 'overview') { + resolvedProducts = zoneProducts + .filter((product) => productSupportsSelection(product, bbox)) + .map((product) => ({ + ...product, + acquisitionBboxes: [bbox], + })) + } else { + const detailTiles = splitSelectionBbox(bbox) + const tileCoverage = await resolveCoveragePartitions(detailTiles) + if (!tileCoverage) { + clearThemeInsights() + return + } + const grouped = new Map() + for (const item of tileCoverage) { + for (const product of onDemandProductsForZones(item.coverage.intersected_zones)) { + if (product.kind === 'thematic_raster' || !productSupportsSelection(product, bbox)) continue + const key = `${product.kind}:${product.productKey}` + const existing = grouped.get(key) + if (existing) { + existing.acquisitionBboxes.push(item.bbox) + } else { + grouped.set(key, { ...product, acquisitionBboxes: [item.bbox] }) + } + } + } + for (const product of zoneProducts.filter( + (candidate) => candidate.kind === 'thematic_raster' && productSupportsSelection(candidate, bbox), + )) { + grouped.set(`${product.kind}:${product.productKey}`, { + ...product, + acquisitionBboxes: [bbox], + }) + } + resolvedProducts = [...grouped.values()] + } + } + const availableThemes: Array> = [] + const resultFeatureLimit = selectionFeatureLimit(bbox) + for (const theme of selectedThemes) { + const dataset = themeDatasetMap[theme.id] + const partitioned = Boolean( + dataset + && regionalScopeSelected + && (isPartitionedRaster(dataset) || isPartitionedBathymetry(dataset)), + ) + const coveringPartitions = partitioned + ? themePartitionMap[theme.id].filter((partition) => datasetIntersectsSelection(partition, bbox)) + : [] + const persistedCoverageAvailable = Boolean( + dataset + && persistedDatasetSupportsSelection(dataset, bbox) + && (!partitioned || coveringPartitions.length > 0), + ) + if (dataset && persistedCoverageAvailable) { + availableThemes.push({ + themeId: theme.id, + dataset, + datasetIds: coveringPartitions.map((partition) => partition.id), + featureLimit: resultFeatureLimit, + partitioned, + }) + continue + } + const onDemandProducts = resolvedProducts.filter((product) => product.theme === theme.id) + if (onDemandProducts.length > 0) { + for (const onDemandProduct of onDemandProducts) { + availableThemes.push({ + themeId: theme.id, + acquisition: { + kind: onDemandProduct.kind, + productKey: onDemandProduct.productKey, + displayName: onDemandProduct.displayName, + historyProductKeys: onDemandProduct.historyProductKeys, + coverageZone: onDemandProduct.coverageZones.find((zone) => resolvedZones?.includes(zone)), + serverOrchestrated: scale !== 'detail', + }, + acquisitionBboxes: onDemandProduct.acquisitionBboxes, + featureLimit: resultFeatureLimit, + }) + } + continue + } + } + await loadThemeInsights(bbox, availableThemes, areaId) + } + + const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => { + if (analysisMode === 'current' && selectedThemes.length === 0) { + return + } + setResultsPanelOpen(true) + const requestId = mapAnalysisRequestSequence.current + 1 + mapAnalysisRequestSequence.current = requestId + const startedAt = Date.now() + setMapAnalysisDurationMs(null) + setSelectionBbox(bbox) + const tasks: Array> = analysisMode === 'current' + ? [loadSelectedThemeResult(bbox, areaId)] + : [] + const activeDatasetSupportsSelection = !activeThemeDataset + || persistedDatasetSupportsSelection(activeThemeDataset, bbox) + if ( + analysisMode === 'current' + && advancedMode + && activeThemeAvailable && !regionalPartitionedThemeActive && !onDemandThemeActive + && activeDatasetSupportsSelection + ) { + tasks.push(onRunMapSelectionExtract(bbox, areaId)) + } + if (analysisMode === 'evolution' && temporalSelectionValid) { + tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox, areaId)) + } + try { + await Promise.all(tasks) + } finally { + if (mapAnalysisRequestSequence.current === requestId) { + setMapAnalysisDurationMs(Date.now() - startedAt) + } + } + } + + const runTemporalComparison = () => { + if (!mapSelectionBbox || !temporalSelectionValid) { + return + } + void compareTemporalSnapshots( + earlierDatasetId, + laterDatasetId, + mapSelectionBbox, + areaIdForSelection(mapSelectionBbox), + ) + } + + const handleMapBboxPreview = (bbox: VectorSelectionBBox) => { + setSelectionBbox(bbox) + } + + const handleMapBboxSelect = (bbox: VectorSelectionBBox) => { + rectangle.reset() + clearThemeInsights() + clearTemporalComparison() + setResultsPanelOpen(false) + setSelectionBbox(bbox) + } + + const runQuickAoiExtract = () => { + const bbox = selectedAreaBbox ?? activeLayerBbox + if (!bbox) { + return + } + setSelectionBbox(bbox) + void analyzeSelection(bbox, areaIdForSelection(bbox)) + } + + const fullWorkflow = useFullGisWorkflow({ + selectedDataset: selectedMapDataset, + latestSelectionDataset, + qaReferenceDatasetId: selectedMapQaReferenceDatasetId, + resolveBbox: () => currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox, + resolveAreaId: areaIdForSelection, + setSelectionBbox, + onRunSelectionExtract: onRunMapSelectionExtract, + onDeriveDataset: onDeriveMapSelectionDataset, + onExportSelection: onExportMapSelection, + onRunQa: onRunMapSelectionQa, + }) + const { + running: fullWorkflowRunning, + status: fullWorkflowStatus, + error: fullWorkflowError, + mode: fullWorkflowMode, + setMode: setFullWorkflowMode, + run: runFullGisWorkflow, + } = fullWorkflow + + return { + activeCoverageItems, + activeImageOverlays, + activeLayerBbox, + activeMetricLabel, + activeOnDemandMapProduct, + activeResultDataset, + activeScopeLabel, + activeSecondaryLabel, + activeSecondaryMetric, + activeSelectionResult, + activeSeriesIsDailyGrb, + activeSupportingMetrics, + activeTemporalSeries, + activeTemporalSeriesGroup, + activeTemporalSeriesGroups, + activeTheme, + activeThemeDataset, + activeThemeMapStyle, + activeThemePartitions, + advancedMode, + analysisMode, + analysisOverlayActive, + analyzeSelection, + areaIdForSelection, + areaSelectionPreviewFeatures, + bathymetryDocumentUrl, + bboxInput, + bboxSelectionMode, + clearAreaSelection, + clearTemporalComparison, + clearThemeInsights, + copyActiveThemeResult, + copyAreaSelection, + copySelectedMapFeatureProperties, + copyTemporalComparison, + coverageCounts, + currentSelectionBbox, + downloadActiveThemeResult, + downloadAreaSelection, + downloadSelectedMapFeature, + downloadTemporalComparison, + earlierDatasetId, + earlierTemporalOptions, + explorerMapFeatureCollection, + explorerSelectionFeatureCollection, + featureExtractionEntries, + featureGeometrySummary, + featureProperties, + featureSummaryEntries, + flandersScopeSelected, + floodHazardDatasets, + fullWorkflowError, + fullWorkflowMode, + fullWorkflowRunning, + fullWorkflowStatus, + handleAnalysisModeKeyDown, + handleMapBboxPreview, + handleMapBboxSelect, + handleMapCoordinateSelect, + handleSelectMapArea, + isBathymetryProfile, + laterDatasetId, + laterTemporalOptions, + liveJourneyError, + liveJourneyHasResult, + liveJourneyProcessing, + liveJourneyResultLabel, + liveJourneyStatus, + liveJourneyValidating, + liveJourneyVerified, + mapAnalysisDurationMs, + municipalityAreaCount, + officialMapProducts, + officialMapProductsError, + officialMapProductsLoading, + onDemandProductMap, + openSelectedDatabaseLayer, + persistActiveResultAndOpenDownloads, + rectangle, + regionalBathymetryThemeActive, + regionalScopeSelected, + resultsPanelOpen, + runAreaExtract, + runFullGisWorkflow, + runQuickAoiExtract, + runTemporalComparison, + saveAreaSelectionDataset, + saveAreaSelectionExport, + selectDataTheme, + selectedAreaBbox, + selectedAreaSquareMetres, + selectedDensity, + selectedDhmvProductKey, + selectedFeatureBbox, + selectedFloodHazardProductKey, + selectedLaterSnapshot, + selectedMapArea, + selectedMapDataset, + selectedOrthophotoProduct, + selectedResultProperties, + selectedThemeIds, + selectedThemes, + selectionRelevantThemes, + selectionScaleNotice, + setAdvancedMode, + setBboxInput, + setEarlierDatasetId, + setExplorerMode, + setFullWorkflowMode, + setLaterDatasetId, + setResultsPanelOpen, + setSelectedDhmvProductKey, + setSelectedFloodHazardDatasetId, + setSelectedFloodHazardProductKey, + setSelectedTemporalSeriesKey, + setSelectionBbox, + setThemeFilter, + startBboxSelection, + temporalComparison, + temporalComparisonError, + temporalComparisonLoading, + temporalSelectionValid, + thematicLegendMax, + thematicLegendMin, + themeDatasetMap, + themeFilter, + themeInsights, + themeResults, + themeResultsError, + themeResultsLoading, + themeTemporalSeriesMap, + usesDefaultOsmBasemap, + visibleThemes, + walloniaScopeSelected, + } +} + +export type MapWorkspaceViewModel = ReturnType