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