MapWorkspace.tsx opened with ~590 lines of theme catalogue, dataset matching and label formatting above a 3.200-line component. None of it is React, all of it is independently testable, and both render paths read from it, so it belongs beside the pure helpers that already live in mapWorkspaceUtils. The contract tests that read MapWorkspace.tsx would have gone red for a move that changes no behaviour at all — 24 of them. That is the brittleness the frontend_contract helper exists to remove, so it gains read_map_workspace(): the workspace is one feature spread over several modules, and a contract belongs to the feature rather than to whichever file currently holds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3397 lines
152 KiB
TypeScript
3397 lines
152 KiB
TypeScript
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 MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights'
|
||
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
|
||
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
||
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
|
||
import { TemporalTrendChart } from './TemporalTrendChart'
|
||
import { MunicipalitySearch } from './MunicipalitySearch'
|
||
import { LiveAnalysisJourney } from './LiveAnalysisJourney'
|
||
import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay'
|
||
import { terrainImageUrl } from '../../lib/terrainImage'
|
||
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
||
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
|
||
import { bathymetryRasterImageUrl } from '../../lib/bathymetryRaster'
|
||
import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus'
|
||
import {
|
||
MAP_ANALYSIS_BUDGET_MS,
|
||
exceedsPerformanceBudget,
|
||
formatPerformanceDuration,
|
||
} from '../../lib/performanceBudget'
|
||
import {
|
||
bboxToInputState,
|
||
bboxesEqual,
|
||
copyText,
|
||
datasetIntersectsSelection,
|
||
downloadJsonFile,
|
||
formatArea,
|
||
formatBboxLabel,
|
||
formatPercentage,
|
||
formatTemporalMetric,
|
||
getFeatureBBox,
|
||
getFeatureCollectionBBox,
|
||
getFeatureGeometrySummary,
|
||
isMunicipalityAreaName,
|
||
normalizeBboxFromCorners,
|
||
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'
|
||
|
||
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<AreaRead | null>
|
||
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<VectorSelectionResponse | null>
|
||
onClearMapSelectionExtract: () => void
|
||
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
|
||
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
|
||
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
|
||
onSelectMapQaReferenceDataset: (datasetId: string) => void
|
||
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
|
||
onOpenMapSelectionQualityEvidence: () => void
|
||
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
|
||
onSelectOrthophotoProduct: (productKey: string) => void
|
||
onClearQualityEvidence?: () => void
|
||
onRefreshProjectData: () => Promise<unknown>
|
||
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<DataThemeId>(() => {
|
||
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
||
return themeIdForDataset(selectedDataset) ?? 'buildings'
|
||
})
|
||
const [selectedThemeIds, setSelectedThemeIds] = useState<DataThemeId[]>([])
|
||
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<DataThemeId>(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 [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
||
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
||
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
|
||
const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false)
|
||
const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.')
|
||
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
||
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
|
||
const [mapAnalysisDurationMs, setMapAnalysisDurationMs] = useState<number | null>(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<string, DatasetCreateResponse>()
|
||
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<DataThemeId, DatasetCreateResponse | null>
|
||
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<DataThemeId, DatasetCreateResponse[]>,
|
||
[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<Record<CoverageStatus, number>>(
|
||
(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<DataThemeId, OnDemandMapProduct>()
|
||
const productsByTheme = new Map<DataThemeId, OnDemandMapProduct[]>()
|
||
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 terrainImageOverlays = useMemo(
|
||
() =>
|
||
activeTheme.id === 'elevation' && selectedProjectId
|
||
? activeThemePartitions.flatMap((dataset) => {
|
||
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
||
return ['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')
|
||
&& Array.isArray(bounds)
|
||
&& bounds.length === 4
|
||
? [{
|
||
url: terrainImageUrl(selectedProjectId, dataset.id),
|
||
bbox: bounds.map(Number) as [number, number, number, number],
|
||
label: getDatasetDisplayName(dataset),
|
||
opacity: 0.82,
|
||
}]
|
||
: []
|
||
})
|
||
: [],
|
||
[activeTheme.id, activeThemePartitions, selectedProjectId],
|
||
)
|
||
const floodHazardImageOverlays = useMemo(
|
||
() =>
|
||
activeTheme.id === 'flood_hazard' && selectedProjectId
|
||
? activeThemePartitions.flatMap((dataset) => {
|
||
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
||
return dataset.source_name === 'vmm_flood_hazard'
|
||
&& Array.isArray(bounds)
|
||
&& bounds.length === 4
|
||
? [{
|
||
url: floodHazardImageUrl(selectedProjectId, dataset.id),
|
||
bbox: bounds.map(Number) as [number, number, number, number],
|
||
label: floodScenarioLabel(dataset),
|
||
opacity: 0.82,
|
||
}]
|
||
: []
|
||
})
|
||
: [],
|
||
[activeTheme.id, activeThemePartitions, selectedProjectId],
|
||
)
|
||
const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster'
|
||
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
||
: null
|
||
const thematicRasterImageOverlays = useMemo(
|
||
() => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4
|
||
? [{
|
||
url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
||
bbox: thematicRasterBounds.map(Number) as [number, number, number, number],
|
||
label: getDatasetDisplayName(activeThemeDataset),
|
||
opacity: 0.78,
|
||
}]
|
||
: [],
|
||
[activeThemeDataset, selectedProjectId, thematicRasterBounds],
|
||
)
|
||
const walousRasterBounds = activeThemeDataset?.source_name === 'spw_walous_land_cover'
|
||
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
||
: null
|
||
const walousRasterImageOverlays = useMemo(
|
||
() => activeThemeDataset?.source_name === 'spw_walous_land_cover' && selectedProjectId && Array.isArray(walousRasterBounds) && walousRasterBounds.length === 4
|
||
? [{
|
||
url: walousRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
||
bbox: walousRasterBounds.map(Number) as [number, number, number, number],
|
||
label: getDatasetDisplayName(activeThemeDataset),
|
||
opacity: 0.82,
|
||
}]
|
||
: [],
|
||
[activeThemeDataset, selectedProjectId, walousRasterBounds],
|
||
)
|
||
const bathymetryRasterBounds = activeThemeDataset?.source_name === 'spw_bathymetry'
|
||
? activeThemeDataset.source_metadata?.['bbox_epsg4326']
|
||
: null
|
||
const bathymetryRasterImageOverlays = useMemo(
|
||
() => activeThemeDataset?.source_name === 'spw_bathymetry' && selectedProjectId && Array.isArray(bathymetryRasterBounds) && bathymetryRasterBounds.length === 4
|
||
? [{
|
||
url: bathymetryRasterImageUrl(selectedProjectId, activeThemeDataset.id),
|
||
bbox: bathymetryRasterBounds.map(Number) as [number, number, number, number],
|
||
label: 'Waterbodemhoogte in mDNG',
|
||
opacity: 0.86,
|
||
}]
|
||
: [],
|
||
[activeThemeDataset, bathymetryRasterBounds, selectedProjectId],
|
||
)
|
||
const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde')
|
||
const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde')
|
||
const activeImageOverlays = useMemo(
|
||
() => bathymetryRasterImageOverlays.length > 0
|
||
? bathymetryRasterImageOverlays
|
||
: walousRasterImageOverlays.length > 0
|
||
? walousRasterImageOverlays
|
||
: thematicRasterImageOverlays.length > 0
|
||
? thematicRasterImageOverlays
|
||
: floodHazardImageOverlays.length > 0
|
||
? floodHazardImageOverlays
|
||
: terrainImageOverlays.length > 0
|
||
? terrainImageOverlays
|
||
: orthophotoImageOverlay ? [orthophotoImageOverlay] : [],
|
||
[bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays, walousRasterImageOverlays],
|
||
)
|
||
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<DataThemeId, TemporalSeriesGroup[]>,
|
||
[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<string, Set<string>>()
|
||
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<string>()
|
||
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(() => {
|
||
setBboxInput(bboxToInputState(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)
|
||
setBboxInput(bboxToInputState(bbox))
|
||
}
|
||
|
||
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
|
||
bbox && selectedMapArea ? selectedMapArea.id : undefined
|
||
)
|
||
|
||
const startBboxSelection = () => {
|
||
setFirstSelectionCorner(null)
|
||
clearThemeInsights()
|
||
clearTemporalComparison()
|
||
setResultsPanelOpen(false)
|
||
setBboxSelectionMode(true)
|
||
}
|
||
|
||
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
|
||
if (!firstSelectionCorner) {
|
||
setFirstSelectionCorner(coordinate)
|
||
return
|
||
}
|
||
const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate)
|
||
setSelectionBbox(bbox)
|
||
setFirstSelectionCorner(null)
|
||
setBboxSelectionMode(false)
|
||
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)
|
||
setBboxSelectionMode(false)
|
||
setFirstSelectionCorner(null)
|
||
setBboxInput(bboxToInputState(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<HTMLButtonElement>,
|
||
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<string, PlannedOnDemandMapProduct>()
|
||
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<MapThemeQuery<DataThemeId>> = []
|
||
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<Promise<unknown>> = 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) => {
|
||
setFirstSelectionCorner(null)
|
||
setBboxSelectionMode(false)
|
||
clearThemeInsights()
|
||
clearTemporalComparison()
|
||
setResultsPanelOpen(false)
|
||
setSelectionBbox(bbox)
|
||
}
|
||
|
||
const runQuickAoiExtract = () => {
|
||
const bbox = selectedAreaBbox ?? activeLayerBbox
|
||
if (!bbox) {
|
||
return
|
||
}
|
||
setSelectionBbox(bbox)
|
||
void analyzeSelection(bbox, areaIdForSelection(bbox))
|
||
}
|
||
|
||
const runFullGisWorkflow = async () => {
|
||
const bbox = currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox
|
||
if (fullWorkflowMode === 'reuse') {
|
||
if (!latestSelectionDataset) {
|
||
setFullWorkflowError('Bewaar eerst een kaartselectie voordat je het laatste resultaat opnieuw gebruikt.')
|
||
return
|
||
}
|
||
if (!selectedMapQaReferenceDatasetId) {
|
||
setFullWorkflowError('Kies eerst een referentielaag voor de kwaliteitscontrole.')
|
||
return
|
||
}
|
||
setFullWorkflowRunning(true)
|
||
setFullWorkflowError(null)
|
||
try {
|
||
setFullWorkflowStatus('Laatste bewaarde resultaatlaag opnieuw controleren...')
|
||
const qaResult = await onRunMapSelectionQa(latestSelectionDataset)
|
||
setFullWorkflowStatus(qaResult ? 'Het laatste bewaarde resultaat is opnieuw gebruikt en gecontroleerd.' : 'Het laatste resultaat is gebruikt, maar de kwaliteitscontrole is niet afgerond.')
|
||
} catch (error) {
|
||
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
|
||
setFullWorkflowStatus('De werkstroom is gestopt.')
|
||
} finally {
|
||
setFullWorkflowRunning(false)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!selectedMapDataset || !bbox) {
|
||
setFullWorkflowError('Kies een kaartlaag en een werkgebied of laagbegrenzing.')
|
||
return
|
||
}
|
||
|
||
setFullWorkflowRunning(true)
|
||
setFullWorkflowError(null)
|
||
try {
|
||
setFullWorkflowStatus('1/4 Bewaarde kaartobjecten selecteren...')
|
||
setSelectionBbox(bbox)
|
||
const selectionAreaId = areaIdForSelection(bbox)
|
||
const selection = await onRunMapSelectionExtract(bbox, selectionAreaId)
|
||
if (!selection) {
|
||
setFullWorkflowError('De ruimtelijke selectie kon niet worden afgerond.')
|
||
setFullWorkflowStatus('Stopped at query.')
|
||
return
|
||
}
|
||
|
||
setFullWorkflowStatus('2/4 Saving derived result dataset...')
|
||
const derived = await onDeriveMapSelectionDataset(bbox, selectionAreaId)
|
||
if (!derived) {
|
||
setFullWorkflowError('De afgeleide resultaatlaag kon niet worden aangemaakt.')
|
||
setFullWorkflowStatus('Stopped at dataset save.')
|
||
return
|
||
}
|
||
|
||
setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...')
|
||
await onExportMapSelection(bbox, selectionAreaId)
|
||
|
||
if (selectedMapQaReferenceDatasetId) {
|
||
setFullWorkflowStatus('4/4 Kwaliteit vergelijken met de gekozen referentielaag...')
|
||
const qaResult = await onRunMapSelectionQa(derived)
|
||
setFullWorkflowStatus(qaResult ? 'De volledige GIS-werkstroom en kwaliteitscontrole zijn afgerond.' : 'Resultaat en download zijn gereed; de kwaliteitscontrole is niet afgerond.')
|
||
} else {
|
||
setFullWorkflowStatus('Resultaat en download zijn gereed. Kies een referentielaag om de kwaliteit te controleren.')
|
||
}
|
||
} catch (error) {
|
||
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
|
||
setFullWorkflowStatus('De werkstroom is gestopt.')
|
||
} finally {
|
||
setFullWorkflowRunning(false)
|
||
}
|
||
}
|
||
|
||
if (!advancedMode) {
|
||
return (
|
||
<section
|
||
className="geo-explorer"
|
||
data-testid="map-workspace"
|
||
aria-label={`Gebiedsverkenner ${activeScopeLabel}`}
|
||
aria-busy={workspaceLoading}
|
||
>
|
||
<header className="geo-explorer-header">
|
||
<div>
|
||
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
|
||
<h2>Gebied analyseren</h2>
|
||
<p>Verken vrij op de kaart, gebruik optioneel een officiële grens en vraag daarna inzichten op voor uw selectie.</p>
|
||
</div>
|
||
<div className="geo-explorer-header-tools">
|
||
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
|
||
<button
|
||
id="geo-analysis-tab-current"
|
||
className={analysisMode === 'current' ? 'active' : ''}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={analysisMode === 'current'}
|
||
aria-controls="geo-explorer-results"
|
||
tabIndex={analysisMode === 'current' ? 0 : -1}
|
||
onClick={() => setExplorerMode('current')}
|
||
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'current')}
|
||
>
|
||
Laatste toestand
|
||
</button>
|
||
<button
|
||
id="geo-analysis-tab-evolution"
|
||
className={analysisMode === 'evolution' ? 'active' : ''}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={analysisMode === 'evolution'}
|
||
aria-controls="geo-explorer-results"
|
||
tabIndex={analysisMode === 'evolution' ? 0 : -1}
|
||
onClick={() => setExplorerMode('evolution')}
|
||
onKeyDown={(event) => handleAnalysisModeKeyDown(event, 'evolution')}
|
||
>
|
||
Evolutie
|
||
</button>
|
||
</div>
|
||
{!readOnly ? (
|
||
<button
|
||
className="secondary-action geo-explorer-advanced"
|
||
type="button"
|
||
onClick={() => setAdvancedMode(true)}
|
||
aria-expanded={advancedMode}
|
||
aria-controls="geo-advanced-workbench"
|
||
aria-label="Geavanceerde werkbank"
|
||
title="Geavanceerde werkbank"
|
||
>
|
||
<SlidersHorizontal aria-hidden="true" />
|
||
<span>Geavanceerde werkbank</span>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
{readOnly ? (
|
||
<div className="geo-guest-preview-note" role="note">
|
||
<MapPinned aria-hidden="true" />
|
||
<div>
|
||
<strong>Interactieve demo met bewaarde voorbeelddata</strong>
|
||
<span>U kunt kaartlagen verkennen, een selectie meten en kwaliteitsbewijs bekijken. Nieuwe gebieden, bronimports en bewaarde analyses zijn uitgeschakeld.</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<MunicipalitySearch
|
||
projectId={selectedProjectId}
|
||
activeArea={selectedMapArea ?? null}
|
||
disabled={workspaceLoading}
|
||
onActivate={onActivateMunicipality}
|
||
/>
|
||
)}
|
||
|
||
{workspaceLoading ? (
|
||
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
||
<span className="geo-loading-indicator" aria-hidden="true" />
|
||
<div>
|
||
<strong>Databronnen worden gecontroleerd</strong>
|
||
<small>Beschikbaarheid verschijnt zodra de nationale werkruimte volledig is geladen.</small>
|
||
</div>
|
||
</div>
|
||
) : workspaceError ? (
|
||
<div className="geo-bootstrap-status geo-bootstrap-status-error" role="alert">
|
||
<div>
|
||
<strong>De werkruimte kon niet volledig worden geladen</strong>
|
||
<small>{workspaceError}</small>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="geo-explorer-layout">
|
||
<aside className="geo-theme-panel" aria-label="Thema’s en modellen kiezen">
|
||
<div className="geo-panel-heading">
|
||
<div>
|
||
<h3>Kies thema’s</h3>
|
||
<p>Alleen uw gekozen bronnen en modellen worden geanalyseerd.</p>
|
||
</div>
|
||
</div>
|
||
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
|
||
<span>Ingeladen bereik</span>
|
||
<strong>{activeScopeLabel}</strong>
|
||
<small>
|
||
{workspaceLoading
|
||
? 'Gebieden en bronnen worden geladen'
|
||
: readOnly
|
||
? `${municipalityAreaCount || 1} vooraf ingestelde demogrens; vrije kaartselectie blijft beschikbaar`
|
||
: municipalityAreaCount > 0
|
||
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
|
||
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
||
</small>
|
||
</div>
|
||
<label className="geo-theme-search">
|
||
<span className="sr-only">Zoek een thema of gegevensbron</span>
|
||
<Search aria-hidden="true" />
|
||
<input
|
||
type="search"
|
||
value={themeFilter}
|
||
onChange={(event) => setThemeFilter(event.target.value)}
|
||
placeholder="Zoek thema’s"
|
||
/>
|
||
</label>
|
||
<div className="geo-theme-list">
|
||
{visibleThemes.map((theme) => {
|
||
const dataset = themeDatasetMap[theme.id]
|
||
const onDemandProduct = onDemandProductMap.get(theme.id)
|
||
const temporalGroups = themeTemporalSeriesMap[theme.id]
|
||
const temporalGroup = temporalGroups[0]
|
||
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
||
const available = !workspaceLoading && (analysisMode === 'current'
|
||
? Boolean(dataset || (!readOnly && onDemandProduct))
|
||
: Boolean(dataset) && evolutionAvailable)
|
||
const active = selectedThemeIds.includes(theme.id)
|
||
return (
|
||
<button
|
||
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
|
||
disabled={!available}
|
||
key={theme.id}
|
||
type="button"
|
||
onClick={() => selectDataTheme(theme)}
|
||
aria-pressed={active}
|
||
>
|
||
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
||
<span>
|
||
<strong>{theme.label}</strong>
|
||
<small>
|
||
{workspaceLoading
|
||
? 'Controleren…'
|
||
: analysisMode === 'evolution'
|
||
? evolutionAvailable ? `${temporalGroup.items.length} meetmomenten` : 'Geen tijdreeks'
|
||
: dataset
|
||
? 'Lokaal beschikbaar'
|
||
: onDemandProduct
|
||
? readOnly ? 'Niet in demo' : 'Op aanvraag'
|
||
: 'Niet beschikbaar'}
|
||
</small>
|
||
</span>
|
||
<i>{active ? 'Gekozen' : available ? 'Kies' : '—'}</i>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="geo-theme-actions" aria-live="polite">
|
||
<span>{selectedThemes.length === 0 ? 'Nog niets gekozen' : `${selectedThemes.length} gekozen`}</span>
|
||
<button
|
||
className="primary-action"
|
||
type="button"
|
||
disabled={!mapSelectionBbox || selectedThemes.length === 0 || themeResultsLoading || mapSelectionLoading}
|
||
onClick={() => mapSelectionBbox && void analyzeSelection(mapSelectionBbox, areaIdForSelection(mapSelectionBbox))}
|
||
>
|
||
<Play aria-hidden="true" />
|
||
Analyseer selectie
|
||
</button>
|
||
<small>{mapSelectionBbox ? 'Start alleen de gekozen analyses.' : 'Teken eerst een gebied.'}</small>
|
||
</div>
|
||
|
||
<div className="geo-source-summary">
|
||
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
|
||
<strong>
|
||
{workspaceLoading
|
||
? 'Databronnen worden geladen'
|
||
: analysisOverlayActive
|
||
? mapLayerLabel
|
||
: analysisMode === 'evolution'
|
||
? activeTemporalSeriesGroup?.label ?? 'Nog geen historische reeks ingeladen'
|
||
: regionalBathymetryThemeActive
|
||
? 'VHA-dwarsprofielen Vlaanderen'
|
||
: activeThemeDataset
|
||
? getDatasetDisplayName(activeThemeDataset)
|
||
: activeOnDemandMapProduct?.displayName ?? 'Geen databron beschikbaar'}
|
||
</strong>
|
||
<small>
|
||
{workspaceLoading
|
||
? 'Even geduld; ontbrekende bronnen worden pas na de laadcontrole gemeld.'
|
||
: analysisOverlayActive
|
||
? `${mapLayerSourceLabel} · AI-resultaat, controle vereist`
|
||
: analysisMode === 'evolution'
|
||
? activeTemporalSeries.length >= 2
|
||
? `${activeTemporalSeries.length} officiële meetmomenten · ${formatObservationDate(activeTemporalSeries[0].observed_at)} tot ${formatObservationDate(activeTemporalSeries[activeTemporalSeries.length - 1].observed_at)}`
|
||
: 'Voor dit thema is nog geen tweede officieel meetmoment beschikbaar.'
|
||
: regionalBathymetryThemeActive
|
||
? `${activeThemePartitions.length} gecontroleerde gemeentepartities · selectie wordt ruimtelijk samengevoegd`
|
||
: activeThemeDataset
|
||
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatDatasetObservation(activeThemeDataset)}`
|
||
: activeOnDemandMapProduct
|
||
? `${activeOnDemandMapProduct.attribution} · wordt alleen voor de gekozen selectie ingeladen`
|
||
: activeTheme.description}
|
||
</small>
|
||
</div>
|
||
{officialMapProductsLoading && selectedProjectId ? (
|
||
<p className="geo-data-notice">Beschikbare officiële regionale kaartbronnen worden gecontroleerd…</p>
|
||
) : null}
|
||
{officialMapProductsError && selectedProjectId ? (
|
||
<p className="error">{officialMapProductsError}</p>
|
||
) : null}
|
||
|
||
{analysisMode === 'current' && activeTheme.id === 'elevation' && flandersScopeSelected && officialMapProducts.dhmv.length > 0 ? (
|
||
<label className="geo-scope-select">
|
||
Hoogtemodel
|
||
<select
|
||
aria-label="Hoogtemodel"
|
||
value={selectedDhmvProductKey}
|
||
onChange={(event) => {
|
||
const productKey = event.target.value as 'dtm_1m' | 'dsm_1m'
|
||
setSelectedDhmvProductKey(productKey)
|
||
clearThemeInsights()
|
||
const dataset = availableMapDatasets.find(
|
||
(item) =>
|
||
item.source_name === 'digitaal_vlaanderen_dhmv'
|
||
&& datasetProductKey(item) === productKey
|
||
&& datasetCoversSelectedArea(
|
||
item,
|
||
selectedMapAreaId,
|
||
selectedMapArea?.name,
|
||
regionalScopeSelected,
|
||
),
|
||
)
|
||
if (dataset) {
|
||
onOpenDatasetInMap(dataset)
|
||
}
|
||
}}
|
||
>
|
||
{officialMapProducts.dhmv.map((product) => (
|
||
<option key={product.key} value={product.key}>{product.display_name}</option>
|
||
))}
|
||
</select>
|
||
<small>DTM meet het maaiveld; DSM bevat ook gebouwen en vegetatie.</small>
|
||
</label>
|
||
) : null}
|
||
{analysisMode === 'current' && activeTheme.id === 'elevation' && walloniaScopeSelected && officialMapProducts.spwTerrain.some((product) => product.configured) ? (
|
||
<p className="geo-data-notice">SPW MNT 2021-2022 · 1 m bron · 5 m begrensde analyse · hoogte in m DNG.</p>
|
||
) : null}
|
||
|
||
{analysisMode === 'current' && activeTheme.id === 'flood_hazard' && officialMapProducts.floodHazard.length > 0 ? (
|
||
<label className="geo-scope-select">
|
||
Overstromingsscenario
|
||
<select
|
||
aria-label="Overstromingsscenario"
|
||
value={selectedFloodHazardProductKey}
|
||
onChange={(event) => {
|
||
const productKey = event.target.value
|
||
const dataset = floodHazardDatasets.find((item) => datasetProductKey(item) === productKey)
|
||
setSelectedFloodHazardProductKey(productKey)
|
||
setSelectedFloodHazardDatasetId(dataset?.id ?? '')
|
||
clearThemeInsights()
|
||
if (dataset) {
|
||
onOpenDatasetInMap(dataset)
|
||
}
|
||
}}
|
||
>
|
||
{officialMapProducts.floodHazard.map((product) => (
|
||
<option key={product.key} value={product.key}>{product.display_name}</option>
|
||
))}
|
||
</select>
|
||
<small>Gemodelleerde maximale waterdiepte voor de gekozen kans en klimaatprojectie; geen actuele waterstand.</small>
|
||
</label>
|
||
) : analysisMode === 'current' && activeTheme.id === 'flood_hazard' && floodHazardDatasets.length > 0 ? (
|
||
<label className="geo-scope-select">
|
||
Overstromingsscenario
|
||
<select
|
||
aria-label="Overstromingsscenario"
|
||
value={activeThemeDataset?.id ?? ''}
|
||
onChange={(event) => {
|
||
const dataset = floodHazardDatasets.find((item) => item.id === event.target.value)
|
||
setSelectedFloodHazardDatasetId(event.target.value)
|
||
clearThemeInsights()
|
||
if (dataset) {
|
||
onOpenDatasetInMap(dataset)
|
||
}
|
||
}}
|
||
>
|
||
{floodHazardDatasets.map((dataset) => (
|
||
<option key={dataset.id} value={dataset.id}>{floodScenarioLabel(dataset)}</option>
|
||
))}
|
||
</select>
|
||
<small>Elke meting blijft gekoppeld aan deze kans en klimaatprojectie.</small>
|
||
</label>
|
||
) : null}
|
||
|
||
{analysisMode === 'evolution' ? (
|
||
<>
|
||
<div className="geo-time-controls" aria-label="Meetmomenten vergelijken">
|
||
{activeTemporalSeriesGroups.length > 1 ? (
|
||
<label className="geo-series-control">
|
||
Reeks
|
||
<select
|
||
value={activeTemporalSeriesGroup?.key ?? ''}
|
||
onChange={(event) => {
|
||
setSelectedTemporalSeriesKey(event.target.value)
|
||
clearTemporalComparison()
|
||
}}
|
||
>
|
||
{activeTemporalSeriesGroups.map((group) => (
|
||
<option key={group.key} value={group.key}>{group.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
<label>
|
||
Van
|
||
<select
|
||
value={earlierDatasetId}
|
||
onChange={(event) => {
|
||
const nextEarlierId = event.target.value
|
||
const nextEarlier = activeTemporalSeries.find((dataset) => dataset.id === nextEarlierId)
|
||
setEarlierDatasetId(nextEarlierId)
|
||
if (
|
||
nextEarlier
|
||
&& new Date(selectedLaterSnapshot?.observed_at ?? 0).getTime()
|
||
<= new Date(nextEarlier.observed_at ?? 0).getTime()
|
||
) {
|
||
setLaterDatasetId(activeTemporalSeries[activeTemporalSeries.length - 1]?.id ?? '')
|
||
}
|
||
clearTemporalComparison()
|
||
}}
|
||
disabled={earlierTemporalOptions.length === 0}
|
||
>
|
||
{earlierTemporalOptions.map((dataset) => (
|
||
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Naar
|
||
<select value={laterDatasetId} onChange={(event) => { setLaterDatasetId(event.target.value); clearTemporalComparison() }} disabled={laterTemporalOptions.length === 0}>
|
||
{laterTemporalOptions.map((dataset) => (
|
||
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="primary-action"
|
||
type="button"
|
||
disabled={!mapSelectionBbox || !temporalSelectionValid || temporalComparisonLoading}
|
||
onClick={runTemporalComparison}
|
||
>
|
||
{temporalComparisonLoading ? 'Vergelijken…' : 'Vergelijk periode'}
|
||
</button>
|
||
</div>
|
||
{activeSeriesIsDailyGrb ? (
|
||
<p className="geo-data-notice">
|
||
Dagelijkse GRB-edities tonen wijzigingen in de officiële registratie. Ze bewijzen niet dat een fysieke verandering exact tussen deze twee kalenderdagen plaatsvond.
|
||
</p>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
|
||
</aside>
|
||
|
||
<div className="geo-map-stage">
|
||
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
|
||
<div className="geo-panel-heading geo-map-step">
|
||
<div>
|
||
<h3>Baken uw onderzoeksvraag af</h3>
|
||
<p>
|
||
{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.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="geo-map-actions">
|
||
<button
|
||
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
|
||
disabled={!selectedProjectId || workspaceLoading || mapSelectionLoading || themeResultsLoading}
|
||
type="button"
|
||
onClick={startBboxSelection}
|
||
>
|
||
<BoxSelect aria-hidden="true" />
|
||
<span>{bboxSelectionMode ? 'Teken op de kaart…' : 'Teken rechthoek'}</span>
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!selectedAreaBbox || workspaceLoading || mapSelectionLoading || themeResultsLoading}
|
||
type="button"
|
||
|
||
onClick={() => {
|
||
if (!selectedAreaBbox) return
|
||
clearThemeInsights()
|
||
clearTemporalComparison()
|
||
setResultsPanelOpen(false)
|
||
setSelectionBbox(selectedAreaBbox)
|
||
}}
|
||
>
|
||
<MapPinned aria-hidden="true" />
|
||
<span>Volledig werkgebied</span>
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!mapSelectionBbox}
|
||
type="button"
|
||
onClick={clearAreaSelection}
|
||
aria-label="Wis selectie"
|
||
>
|
||
<Trash2 aria-hidden="true" />
|
||
<span>Wis selectie</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
|
||
<GeoMap
|
||
data={explorerMapFeatureCollection}
|
||
dataFillColor={activeThemeMapStyle.fill}
|
||
dataLineColor={activeThemeMapStyle.line}
|
||
areaData={areaFeatureCollection}
|
||
selectedFeature={selectedFeature}
|
||
selectionData={explorerSelectionFeatureCollection}
|
||
imageOverlays={activeImageOverlays}
|
||
selectionBbox={mapSelectionBbox}
|
||
bboxSelectionMode={bboxSelectionMode}
|
||
visible={mapLayerVisible}
|
||
opacity={mapLayerOpacity}
|
||
areaVisible={areaLayerVisible}
|
||
areaOpacity={areaLayerOpacity}
|
||
fitDataOnChange={fitMapDataOnChange}
|
||
onFeatureSelect={onSelectMapFeature}
|
||
onMapCoordinateSelect={handleMapCoordinateSelect}
|
||
onMapBboxPreview={handleMapBboxPreview}
|
||
onMapBboxSelect={handleMapBboxSelect}
|
||
onViewportChange={onMapViewportChange}
|
||
/>
|
||
<LiveAnalysisJourney
|
||
selectionMode={bboxSelectionMode}
|
||
hasSelection={Boolean(mapSelectionBbox)}
|
||
sourceLoading={workspaceLoading || coverageLoading || officialMapProductsLoading}
|
||
sourceReady={selectedThemes.length > 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}
|
||
/>
|
||
<div className="geo-map-legend" aria-label="Kaartlegende">
|
||
<span><i className="geo-legend-area" /> Werkgebied</span>
|
||
{thematicRasterImageOverlays.length > 0 || walousRasterImageOverlays.length > 0 ? (
|
||
<span className="geo-legend-thematic">
|
||
<i className={`geo-legend-ramp geo-legend-ramp-${activeTheme.id}`} />
|
||
<small>{thematicLegendMin} → {thematicLegendMax}</small>
|
||
</span>
|
||
) : activeImageOverlays.length > 0 ? (
|
||
<span>
|
||
<i className="geo-legend-imagery" /> {activeImageOverlays[0].label}
|
||
{activeImageOverlays.length > 1 ? ` · ${activeImageOverlays.length} gemeenten` : ''}
|
||
</span>
|
||
) : null}
|
||
{analysisOverlayActive ? (
|
||
<>
|
||
<span><i className="geo-legend-layer geo-legend-layer-buildings" /> AI-kandidaten</span>
|
||
<span><i className="geo-legend-selection" /> Selectie</span>
|
||
</>
|
||
) : analysisMode === 'evolution' && temporalComparison?.object_changes.available ? (
|
||
<>
|
||
<span><i className="geo-legend-added" /> Nieuw</span>
|
||
<span><i className="geo-legend-removed" /> Verdwenen</span>
|
||
<span><i className="geo-legend-modified" /> Gewijzigd</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span><i className={`geo-legend-layer geo-legend-layer-${activeTheme.id}`} /> {activeTheme.shortLabel}</span>
|
||
<span><i className="geo-legend-selection" /> Selectie</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
{bboxSelectionMode ? (
|
||
<div className="geo-draw-instruction" role="status">
|
||
<strong>Rechthoek tekenen</strong>
|
||
<span>Houd de linkermuisknop ingedrukt, sleep over het gewenste gebied en laat los.</span>
|
||
</div>
|
||
) : null}
|
||
{viewportVectorEnabled && viewportVectorStatus ? (
|
||
<div className={`geo-viewport-status geo-viewport-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
|
||
{viewportVectorStatus}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{secondaryResultsContainer ? null : (
|
||
<button
|
||
className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'}
|
||
type="button"
|
||
aria-controls="geo-explorer-results"
|
||
aria-expanded={resultsPanelOpen}
|
||
aria-label={resultsPanelOpen ? 'Sluit inzichten' : 'Open inzichten'}
|
||
onClick={() => setResultsPanelOpen((open) => !open)}
|
||
>
|
||
{resultsPanelOpen ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />}
|
||
<span>{resultsPanelOpen ? 'Sluit inzichten' : 'Inzichten'}</span>
|
||
</button>
|
||
)}
|
||
|
||
<SecondaryDisplayTarget container={secondaryResultsContainer}>
|
||
<aside
|
||
id={secondaryResultsContainer ? 'geo-secondary-results' : 'geo-explorer-results'}
|
||
className={secondaryResultsContainer ? 'geo-results-panel geo-results-panel-open geo-results-panel-secondary' : resultsPanelOpen ? 'geo-results-panel geo-results-panel-open' : 'geo-results-panel'}
|
||
aria-label="Gebiedsanalyse"
|
||
aria-live="polite"
|
||
aria-hidden={secondaryResultsContainer ? false : !resultsPanelOpen}
|
||
>
|
||
<div className="geo-panel-heading">
|
||
<div>
|
||
<h3>Inzichten</h3>
|
||
<p>Resultaten van de gekozen analyses.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
|
||
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
|
||
<div>
|
||
<span>Beeldanalyse</span>
|
||
<strong>{selectedOrthophotoProduct?.supports_detection ? 'Gebouwen herkennen op luchtbeeld' : 'Historisch luchtbeeld bekijken'}</strong>
|
||
<small>
|
||
{selectedOrthophotoProduct?.supports_detection
|
||
? 'Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.'
|
||
: 'Officieel historisch mozaïek. Geen vergelijking met de actuele GRB-toestand.'}
|
||
</small>
|
||
</div>
|
||
<label className="geo-orthophoto-product">
|
||
<span>Luchtbeeld</span>
|
||
<select
|
||
value={selectedOrthophotoProductKey}
|
||
onChange={(event) => onSelectOrthophotoProduct(event.target.value)}
|
||
disabled={orthophotoAnalysisRunning}
|
||
>
|
||
{orthophotoProducts.map((product) => (
|
||
<option key={product.key} value={product.key}>{product.display_name}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="primary-action"
|
||
disabled={orthophotoAnalysisRunning}
|
||
type="button"
|
||
onClick={() => void onRunOrthophotoAnalysis(mapSelectionBbox)}
|
||
>
|
||
{orthophotoAnalysisStage === 'acquiring'
|
||
? 'Luchtbeeld ophalen...'
|
||
: orthophotoAnalysisStage === 'detecting'
|
||
? 'Gebouwen herkennen...'
|
||
: orthophotoAnalysisStage === 'validating'
|
||
? 'Controleren...'
|
||
: orthophotoAnalysisStage === 'complete'
|
||
? selectedOrthophotoProduct?.supports_detection ? 'Opnieuw analyseren' : 'Opnieuw tonen'
|
||
: selectedOrthophotoProduct?.supports_detection ? 'Herken gebouwen' : 'Toon luchtbeeld'}
|
||
</button>
|
||
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
|
||
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
|
||
{orthophotoAnalysisQuality ? (
|
||
<>
|
||
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
|
||
<div><span>AI-kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
|
||
<div><span>GRB-bevestigd</span><strong>{orthophotoAnalysisQuality.matches.toLocaleString('nl-BE')}</strong></div>
|
||
<div><span>AI-only, onbevestigd</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')}</strong></div>
|
||
<div><span>GRB, gemist door AI</span><strong>{orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
|
||
<div><span>AI-precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
|
||
<div><span>AI-herkenningsgraad</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
|
||
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
|
||
</div>
|
||
<p className="geo-image-quality-context">
|
||
GRB is hier de gezagsbron voor geregistreerde gebouwen. AI-only vormen zijn onderzoeksvoorstellen en worden niet als officieel resultaat voorgesteld. De bewijslaag op de kaart toont elke categorie afzonderlijk.
|
||
</p>
|
||
{orthophotoAnalysisQuality.box_to_footprint_diagnostics ? (
|
||
<p className="geo-image-quality-context">
|
||
Rechthoekcontrole: {orthophotoAnalysisQuality.box_to_footprint_diagnostics.envelope_matches.toLocaleString('nl-BE')} matches,
|
||
waarvan {orthophotoAnalysisQuality.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count.toLocaleString('nl-BE')} mogelijke vormverschillen. De kerncijfers hierboven gebruiken strikte GRB-footprints.
|
||
</p>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!mapSelectionBbox ? (
|
||
<div className="geo-results-empty">
|
||
<strong>Nog geen gebied geselecteerd</strong>
|
||
<p>Teken een rechthoek, kies thema’s en start daarna de analyse.</p>
|
||
</div>
|
||
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
|
||
<div className="geo-results-loading" role="status">
|
||
<span />
|
||
<strong>De gekozen bronnen worden begrensd geladen en geanalyseerd…</strong>
|
||
</div>
|
||
) : analysisMode === 'current' && themeInsights.length === 0 && !mapSelectionResult ? (
|
||
<div className="geo-results-empty">
|
||
<strong>Nog niet geanalyseerd</strong>
|
||
<p>Kies één of meer thema’s en gebruik “Analyseer selectie”.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{analysisMode === 'evolution' ? (
|
||
temporalComparison ? (
|
||
<>
|
||
<div className="geo-primary-metrics geo-temporal-metrics">
|
||
<div>
|
||
<span>{formatObservationDate(temporalComparison.earlier.observed_at)}</span>
|
||
<strong>{formatTemporalMetric(temporalComparison.metric.earlier_value, temporalComparison.metric.unit)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{formatObservationDate(temporalComparison.later.observed_at)}</span>
|
||
<strong>{formatTemporalMetric(temporalComparison.metric.later_value, temporalComparison.metric.unit)}</strong>
|
||
</div>
|
||
<div className={temporalComparison.metric.absolute_change >= 0 ? 'positive' : 'negative'}>
|
||
<span>Verschil</span>
|
||
<strong>
|
||
{temporalComparison.metric.absolute_change >= 0 ? '+' : ''}
|
||
{formatTemporalMetric(temporalComparison.metric.absolute_change, temporalComparison.metric.unit)}
|
||
</strong>
|
||
<small>
|
||
{temporalComparison.metric.percent_change == null
|
||
? 'geen percentage bij nulwaarde'
|
||
: `${temporalComparison.metric.percent_change >= 0 ? '+' : ''}${temporalComparison.metric.percent_change.toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div className="geo-temporal-summary">
|
||
<div>
|
||
<span>Gebied</span>
|
||
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Meting</span>
|
||
<strong>{temporalComparison.metric.label}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Methode</span>
|
||
<strong>{temporalComparison.metric.is_estimate ? 'Ruimtelijke schatting' : 'Exact'}</strong>
|
||
</div>
|
||
</div>
|
||
<TemporalTrendChart
|
||
timeline={temporalComparison.timeline ?? []}
|
||
metricKey={temporalComparison.metric.metric_key}
|
||
/>
|
||
{(temporalComparison.metrics ?? []).filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key).length > 0 ? (
|
||
<div className="geo-supporting-metrics" aria-label="Aanvullende historische metingen">
|
||
{(temporalComparison.metrics ?? [])
|
||
.filter((metric) => metric.metric_key !== temporalComparison.metric.metric_key)
|
||
.map((metric) => (
|
||
<div key={metric.metric_key}>
|
||
<span>{metric.label}</span>
|
||
<strong>
|
||
{metric.absolute_change >= 0 ? '+' : ''}
|
||
{formatTemporalMetric(metric.absolute_change, metric.unit)}
|
||
</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{temporalComparison.object_changes.available ? (
|
||
<div className="geo-change-counts" aria-label="Objectwijzigingen">
|
||
<span><strong>{temporalComparison.object_changes.added_count ?? 0}</strong> nieuw</span>
|
||
<span><strong>{temporalComparison.object_changes.removed_count ?? 0}</strong> verdwenen</span>
|
||
<span><strong>{temporalComparison.object_changes.modified_count ?? 0}</strong> gewijzigd</span>
|
||
</div>
|
||
) : null}
|
||
{temporalComparison.warnings.map((warning) => (
|
||
<p className="geo-data-notice" key={warning}>{warning}</p>
|
||
))}
|
||
</>
|
||
) : (
|
||
<div className="geo-results-empty">
|
||
<strong>Klaar om te vergelijken</strong>
|
||
<p>Kies twee meetmomenten en gebruik “Vergelijk periode”. Bij een nieuwe rechthoek wordt de vergelijking automatisch herhaald.</p>
|
||
</div>
|
||
)
|
||
) : (
|
||
<>
|
||
<div className="geo-primary-metrics">
|
||
<div>
|
||
<span>Oppervlakte selectie</span>
|
||
<strong>{formatArea(selectedAreaSquareMetres)}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{activeMetricLabel}</span>
|
||
<strong>{activeSelectionResult ? resultMetricLabel(activeSelectionResult) : 'Geen resultaat'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>{activeSecondaryLabel}</span>
|
||
<strong>{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
{activeSupportingMetrics.length > 0 ? (
|
||
<div className="geo-supporting-metrics" aria-label="Aanvullende gebiedsmetingen">
|
||
{activeSupportingMetrics.map((metric) => (
|
||
<div key={metric.metric_key}>
|
||
<span>{metric.metric_label}</span>
|
||
<strong>{selectionMetricLabel(metric)}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="geo-theme-results">
|
||
<div className="geo-results-title-row">
|
||
<h4>Gekozen thema’s</h4>
|
||
<span>{themeResults.length}/{selectedThemes.length} uitgelezen</span>
|
||
</div>
|
||
{selectedThemes.flatMap((theme) => {
|
||
const dataset = themeDatasetMap[theme.id]
|
||
const items = themeResults.filter((result) => result.theme.id === theme.id)
|
||
const rows = items.length > 0 ? items : [null]
|
||
const themeSupportsCurrentSelection = selectionRelevantThemes.some((candidate) => candidate.id === theme.id)
|
||
return rows.map((item, index) => {
|
||
const resultDataset = item?.dataset ?? (themeSupportsCurrentSelection ? dataset : undefined)
|
||
const unavailableAtScale = !themeSupportsCurrentSelection
|
||
return (
|
||
<div className="geo-theme-result-row" key={`${theme.id}:${resultDataset?.id ?? index}`}>
|
||
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
|
||
<span>
|
||
<strong>{theme.label}</strong>
|
||
<small>
|
||
{resultDataset
|
||
? getDatasetSourceDisplayName(resultDataset)
|
||
: unavailableAtScale
|
||
? 'Niet geschikt voor deze selectieschaal of zone'
|
||
: 'Bronanalyse niet voltooid'}
|
||
</small>
|
||
</span>
|
||
<span className="geo-theme-result-value">
|
||
<b>{item ? resultMetricLabel(item.result) : unavailableAtScale ? 'Kies kleiner gebied' : 'Geen meting'}</b>
|
||
{item?.result.summary ? <small>{item.result.summary.metric_label}</small> : null}
|
||
</span>
|
||
</div>
|
||
)
|
||
})
|
||
})}
|
||
{selectionScaleNotice ? (
|
||
<p className="geo-data-notice">{selectionScaleNotice}</p>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{analysisMode === 'current' && activeSelectionResult?.truncated ? (
|
||
<p className="geo-data-notice">De telling is volledig; op de kaart en in de tabel worden maximaal {activeSelectionResult.limit.toLocaleString('nl-BE')} objecten getoond.</p>
|
||
) : null}
|
||
{analysisMode === 'current' && activeSelectionResult?.summary?.selection_edge_warning ? (
|
||
<p className="geo-data-notice">{activeSelectionResult.summary.selection_edge_warning}</p>
|
||
) : null}
|
||
{analysisMode === 'current' && activeSelectionResult?.summary?.warning ? (
|
||
<p className="geo-data-notice">{activeSelectionResult.summary.warning}</p>
|
||
) : null}
|
||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||
{themeResultsError ? <p className="error">{themeResultsError}</p> : null}
|
||
{temporalComparisonError ? <p className="error">{temporalComparisonError}</p> : null}
|
||
|
||
{analysisMode === 'current' && selectedResultProperties.length > 0 ? (
|
||
<details className="geo-result-details">
|
||
<summary>Kenmerken van de gevonden objecten</summary>
|
||
<dl>
|
||
{selectedResultProperties.map(({ key, values }) => (
|
||
<div key={key}>
|
||
<dt>{readablePropertyName(key)}</dt>
|
||
<dd>{values.join(', ')}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</details>
|
||
) : null}
|
||
|
||
{analysisMode === 'current' && selectedMapFeature ? (
|
||
<div className="geo-selected-feature">
|
||
<span>Geselecteerd object</span>
|
||
<strong>{String(selectedMapFeature.properties?.['name'] ?? selectedMapFeature.properties?.['source_feature_id'] ?? selectedMapFeature.id ?? 'Object')}</strong>
|
||
<small>Klik elders op de kaart om een ander object uit te lezen.</small>
|
||
</div>
|
||
) : null}
|
||
|
||
{analysisMode === 'current' ? (
|
||
<div className="geo-result-actions">
|
||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={downloadActiveThemeResult}>
|
||
{activeResultDataset?.dataset_type === 'raster' ? 'Download analyse' : 'Download GeoJSON'}
|
||
</button>
|
||
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeResult}>Kopieer gegevens</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{analysisMode === 'evolution' && temporalComparison ? (
|
||
<div className="geo-result-actions">
|
||
<button className="secondary-action" type="button" onClick={downloadTemporalComparison}>Download vergelijking</button>
|
||
<button className="secondary-action" type="button" onClick={copyTemporalComparison}>Kopieer vergelijking</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeSelectionResult || temporalComparison ? (
|
||
readOnly ? (
|
||
<div className="geo-result-next-actions geo-result-next-actions-readonly" aria-label="Gastresultaat">
|
||
<span>
|
||
<strong>Analyse klaar</strong>
|
||
<small>Dit resultaat blijft tijdelijk in de browser. Meld u aan als operator om analyses te bewaren of verder te verwerken.</small>
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="geo-result-next-actions" aria-label="Volgende stap">
|
||
<span>
|
||
<strong>Analyse klaar</strong>
|
||
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
|
||
</span>
|
||
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
|
||
<button
|
||
className="secondary-action"
|
||
type="button"
|
||
disabled={selectionExporting}
|
||
onClick={() => void persistActiveResultAndOpenDownloads()}
|
||
>
|
||
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
|
||
</button>
|
||
</div>
|
||
)
|
||
) : null}
|
||
</>
|
||
)}
|
||
{mapAnalysisDurationMs !== null ? (
|
||
<p
|
||
className={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
|
||
? 'geo-performance-status geo-performance-status-warning'
|
||
: 'geo-performance-status'}
|
||
role={exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS) ? 'alert' : 'status'}
|
||
>
|
||
Selectie geanalyseerd in {formatPerformanceDuration(mapAnalysisDurationMs)}.
|
||
{exceedsPerformanceBudget(mapAnalysisDurationMs, MAP_ANALYSIS_BUDGET_MS)
|
||
? ' Dit overschrijdt het releasebudget van 15 seconden.'
|
||
: ''}
|
||
</p>
|
||
) : null}
|
||
</aside>
|
||
</SecondaryDisplayTarget>
|
||
</div>
|
||
|
||
<footer className="geo-explorer-footer">
|
||
<span><strong>Werkgebied:</strong> {selectedMapArea?.name ?? 'Geen werkgebied geselecteerd'}</span>
|
||
<span>
|
||
<strong>Bron:</strong>{' '}
|
||
{analysisOverlayActive
|
||
? `${mapLayerLabel} · ${mapLayerSourceLabel}`
|
||
: analysisMode === 'evolution'
|
||
? activeTemporalSeriesGroup?.label ?? 'geen vergelijkbare tijdreeks'
|
||
: regionalBathymetryThemeActive
|
||
? `VHA-dwarsprofielen Vlaanderen · ${activeThemePartitions.length} gemeentepartities`
|
||
: activeThemeDataset
|
||
? getDatasetDisplayName(activeThemeDataset)
|
||
: activeOnDemandMapProduct?.displayName
|
||
?? 'niet beschikbaar'}
|
||
</span>
|
||
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
|
||
</footer>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section id="geo-advanced-workbench" className="map-workspace-shell" data-testid="map-workspace">
|
||
<button className="secondary-action" type="button" onClick={() => setAdvancedMode(false)}>
|
||
Terug naar gebiedsverkenner
|
||
</button>
|
||
<div className="panel-title-row">
|
||
<div>
|
||
<p className="eyebrow">Ruimtelijke controle</p>
|
||
<h2>Kaartwerkruimte</h2>
|
||
</div>
|
||
<span className="count-pill">
|
||
{mapFeatureCollection ? `${mapFeatureCount} objecten` : viewportVectorEnabled ? 'zoom in om te laden' : 'geen laag'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="map-control-surface" aria-label="Bediening van de kaartwerkruimte">
|
||
{usesDefaultOsmBasemap ? (
|
||
<div className="basemap-policy-notice" aria-label="Gebruik van de kaartondergrond">
|
||
<strong>Publieke kaartondergrond</strong>
|
||
<span>De publieke OpenStreetMap-ondergrond is actief. Configureer voor intensief gebruik een eigen kaartstijl.</span>
|
||
</div>
|
||
) : null}
|
||
<div className="map-toolbar">
|
||
<div className="map-layer-mode" aria-label="Soort kaartinhoud">
|
||
<span>Kaartinhoud</span>
|
||
<div role="group" aria-label="Bron van de kaartinhoud">
|
||
<button
|
||
type="button"
|
||
aria-pressed={mapContentMode === 'dataset'}
|
||
onClick={() => onSetMapContentMode('dataset')}
|
||
>
|
||
Database
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-pressed={mapContentMode === 'analysis'}
|
||
disabled={!analysisLayerAvailable}
|
||
onClick={() => onSetMapContentMode('analysis')}
|
||
>
|
||
Analyseresultaat
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<label>
|
||
Databaselaag
|
||
<select
|
||
value={selectedMapDatasetId}
|
||
onChange={(event) => openSelectedDatabaseLayer(event.target.value)}
|
||
disabled={availableMapDatasets.length === 0}
|
||
data-testid="map-database-layer-select"
|
||
>
|
||
<option value="">Kies een bewaarde vectorlaag</option>
|
||
{availableMapDatasets.map((dataset) => (
|
||
<option key={dataset.id} value={dataset.id}>
|
||
{dataset.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Werkgebied
|
||
<select
|
||
aria-label="Werkgebied"
|
||
value={selectedMapAreaId}
|
||
onChange={(event) => handleSelectMapArea(event.target.value)}
|
||
disabled={areas.length === 0}
|
||
data-testid="map-area-select"
|
||
>
|
||
<option value="">Geen werkgebied</option>
|
||
{areas.map((area) => (
|
||
<option key={area.id} value={area.id}>
|
||
{area.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="layer-control-card">
|
||
<label className="checkbox-row">
|
||
<input
|
||
checked={areaLayerVisible}
|
||
disabled={!areaFeatureCollection}
|
||
type="checkbox"
|
||
onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
|
||
data-testid="map-area-visible"
|
||
/>
|
||
Werkgebied
|
||
</label>
|
||
<input
|
||
aria-label="Dekking van het werkgebied"
|
||
disabled={!areaFeatureCollection}
|
||
max="0.7"
|
||
min="0.05"
|
||
step="0.05"
|
||
type="range"
|
||
value={areaLayerOpacity}
|
||
onChange={(event) => onSetAreaLayerOpacity(Number(event.target.value))}
|
||
data-testid="map-area-opacity"
|
||
/>
|
||
</div>
|
||
<div className="layer-control-card">
|
||
<label className="checkbox-row">
|
||
<input
|
||
checked={mapLayerVisible}
|
||
disabled={!mapFeatureCollection && !viewportVectorEnabled}
|
||
type="checkbox"
|
||
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
|
||
data-testid="map-layer-visible"
|
||
/>
|
||
Actieve laag
|
||
</label>
|
||
<input
|
||
aria-label="Dekking van de kaartlaag"
|
||
disabled={!mapFeatureCollection && !viewportVectorEnabled}
|
||
max="1"
|
||
min="0.05"
|
||
step="0.05"
|
||
type="range"
|
||
value={mapLayerOpacity}
|
||
onChange={(event) => onSetMapLayerOpacity(Number(event.target.value))}
|
||
data-testid="map-layer-opacity"
|
||
/>
|
||
</div>
|
||
<div className="map-status">
|
||
<strong>{mapLayerLabel}</strong>
|
||
<span>{selectedMapDataset ? `Databaselaag: ${selectedMapDataset.name}` : 'Geen databaselaag gekozen'}</span>
|
||
<span>{areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Geen werkgebied geladen'}</span>
|
||
<span>
|
||
{mapFeatureCollection
|
||
? `${mapFeatureCount} objecten geladen`
|
||
: viewportVectorEnabled
|
||
? 'Databaselaag gekozen; zichtbare objecten laden volgens de kaartuitsnede'
|
||
: 'Geen vector- of resultaatlaag geladen'}
|
||
</span>
|
||
{viewportVectorEnabled && viewportVectorStatus ? (
|
||
<span className={`viewport-vector-status viewport-vector-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
|
||
{viewportVectorStatus}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="map-frame-surface">
|
||
<GeoMap
|
||
data={mapFeatureCollection}
|
||
areaData={areaFeatureCollection}
|
||
selectedFeature={selectedFeature}
|
||
selectionData={mapSelectionResult?.geojson ?? null}
|
||
qaEvidenceData={qualityEvidenceGeoJson}
|
||
selectionBbox={mapSelectionBbox}
|
||
bboxSelectionMode={bboxSelectionMode}
|
||
visible={mapLayerVisible}
|
||
opacity={mapLayerOpacity}
|
||
areaVisible={areaLayerVisible}
|
||
areaOpacity={areaLayerOpacity}
|
||
fitDataOnChange={fitMapDataOnChange}
|
||
onFeatureSelect={onSelectMapFeature}
|
||
onMapCoordinateSelect={handleMapCoordinateSelect}
|
||
onViewportChange={onMapViewportChange}
|
||
/>
|
||
</div>
|
||
|
||
<details className="map-layer-details">
|
||
<summary>
|
||
<span>Details van de kaartlagen</span>
|
||
<strong>
|
||
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Kaartuitsnedelaag gekozen' : 'Geen actieve laag'}
|
||
</strong>
|
||
</summary>
|
||
<div className="map-context-summary" aria-label="Status van de kaartlagen">
|
||
<div>
|
||
<span>Werkgebied</span>
|
||
<strong>{selectedMapArea?.name ?? 'Geen gebied geselecteerd'}</strong>
|
||
<small>{areaFeatureCollection ? `${areaFeatureCount} werkgebiedobjecten geladen` : 'Werkgebiedlaag uitgeschakeld'}</small>
|
||
</div>
|
||
<div>
|
||
<span>Actieve kaartlaag</span>
|
||
<strong>{mapLayerLabel}</strong>
|
||
<small>{mapLayerSourceLabel}</small>
|
||
</div>
|
||
<div>
|
||
<span>Status kaartobjecten</span>
|
||
<strong>
|
||
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Wachten op detail van de kaartuitsnede' : 'Geen laag getekend'}
|
||
</strong>
|
||
<small>{mapLayerProvenance}</small>
|
||
</div>
|
||
<div>
|
||
<span>Kaartbewijs kwaliteitscontrole</span>
|
||
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewijsobjecten` : 'Geen bewijslaag'}</strong>
|
||
<small>{qualityEvidenceLoading ? 'Bewaard bewijs laden' : 'Overeenkomsten, onterecht gevonden en gemiste objecten'}</small>
|
||
</div>
|
||
</div>
|
||
<div className="layer-provenance-rail" aria-label="Herkomst van de actieve kaartlaag">
|
||
<div>
|
||
<span>Bron van de kaartlaag</span>
|
||
<strong>{mapLayerSourceLabel}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Herkomst</span>
|
||
<strong>{mapLayerProvenance}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Weergavestatus</span>
|
||
<strong>
|
||
{mapFeatureCollection ? `${mapFeatureCount} getekende objecten` : viewportVectorEnabled ? 'Laden volgens kaartuitsnede actief' : 'Geen actieve vector- of resultaatlaag'}
|
||
</strong>
|
||
</div>
|
||
<div>
|
||
<span>Kaartbewijs</span>
|
||
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} getekend` : 'uitgeschakeld'}</strong>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
|
||
<div className="qa-evidence-map-status" aria-label="Status van het kaartbewijs">
|
||
<div>
|
||
<span>Kaartbewijs kwaliteitscontrole</span>
|
||
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} bewaarde objecten` : 'Niet geladen'}</strong>
|
||
{qualityEvidenceError ? <p className="error">{qualityEvidenceError}</p> : null}
|
||
{qualityEvidenceWarnings.length > 0 ? (
|
||
<p className="muted">
|
||
{qualityEvidenceWarnings.length} {qualityEvidenceWarnings.length === 1 ? 'bewijsverwijzing kon' : 'bewijsverwijzingen konden'} niet worden teruggevonden.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="qa-evidence-legend" aria-label="Legenda van het kaartbewijs">
|
||
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-candidate" /> Overeenkomst resultaat</span>
|
||
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-reference" /> Overeenkomst referentie</span>
|
||
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-positive" /> Onterecht gevonden</span>
|
||
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-negative" /> Gemist</span>
|
||
</div>
|
||
{onClearQualityEvidence ? (
|
||
<button className="secondary-action" type="button" onClick={onClearQualityEvidence}>
|
||
Kaartbewijs wissen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{!mapFeatureCollection && !viewportVectorEnabled ? (
|
||
<div className="empty-state map-empty-state">
|
||
<strong>Geen actieve vector- of resultaatlaag</strong>
|
||
<p>Open een databron, beeldanalyse, segmentatie of veranderingsresultaat om het hier te tekenen.</p>
|
||
{availableMapDatasets.length > 0 ? (
|
||
<>
|
||
<p className="eyebrow">Open een beschikbare vectorlaag</p>
|
||
<div className="map-empty-action-grid">
|
||
{availableMapDatasets.map((dataset) => (
|
||
<button className="secondary-action" key={dataset.id} type="button" onClick={() => onOpenDatasetInMap(dataset)}>
|
||
<span>{dataset.name}</span>
|
||
<strong>Open op kaart</strong>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="muted">Nog geen gebruiksklare vectorlagen beschikbaar. Voeg eerst een vectorbestand toe.</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{mapSelectionBbox ? (
|
||
<section
|
||
className="coverage-resolution-surface"
|
||
aria-label="Datadekking van de kaartselectie"
|
||
aria-busy={coverageLoading}
|
||
>
|
||
<div className="panel-title-row">
|
||
<div>
|
||
<p className="eyebrow">Dekking van deze selectie</p>
|
||
<h3>{activeTheme.label}</h3>
|
||
</div>
|
||
{coverageLoading ? <span className="count-pill">controleren</span> : null}
|
||
</div>
|
||
{coverageError ? <p className="error" role="alert">{coverageError}</p> : null}
|
||
{coverageDurationMs !== null ? (
|
||
<p
|
||
className={coverageBudgetExceeded
|
||
? 'geo-performance-status geo-performance-status-warning'
|
||
: 'geo-performance-status'}
|
||
role={coverageBudgetExceeded ? 'alert' : 'status'}
|
||
>
|
||
Dekkingscontrole voltooid in {formatPerformanceDuration(coverageDurationMs)}.
|
||
{coverageBudgetExceeded ? ' Dit overschrijdt het releasebudget van 4 seconden.' : ''}
|
||
</p>
|
||
) : null}
|
||
{coverage ? (
|
||
<>
|
||
<div className="coverage-zone-row">
|
||
{coverage.intersected_zones.map((zone) => (
|
||
<span key={zone}>{coverageZoneLabel(zone)}</span>
|
||
))}
|
||
{coverage.outside_supported_scope ? <span className="coverage-zone-warning">deels buiten scope</span> : null}
|
||
</div>
|
||
<div className="coverage-active-theme-grid">
|
||
{activeCoverageItems.map((item) => (
|
||
<div className={`coverage-status-item coverage-status-${item.status}`} key={`${item.zone}:${item.theme}`}>
|
||
<span>{coverageZoneLabel(item.zone)}</span>
|
||
<strong>{coverageStatusLabel(item.status)}</strong>
|
||
<small>{item.source_names.join(', ') || 'Geen broncontract'}</small>
|
||
</div>
|
||
))}
|
||
{activeCoverageItems.length === 0 ? (
|
||
<p className="muted">Deze selectie raakt geen bewaarde Belgische land- of zeezone.</p>
|
||
) : null}
|
||
</div>
|
||
<div className="coverage-summary-row" aria-label="Samenvatting van alle themas">
|
||
<span>{coverageCounts.operational} beschikbaar</span>
|
||
<span>{coverageCounts.partial} gedeeltelijk</span>
|
||
<span>{coverageCounts.not_configured} niet gekoppeld</span>
|
||
<span>{coverageCounts.unsupported} niet ondersteund</span>
|
||
</div>
|
||
{coverage.warnings.map((warning) => <p className="muted" key={warning}>{warning}</p>)}
|
||
</>
|
||
) : !coverageLoading && !coverageError ? (
|
||
<p className="muted">De dekkingsmatrix wordt bepaald zodra de selectie volledig is.</p>
|
||
) : null}
|
||
</section>
|
||
) : null}
|
||
|
||
<div className="map-inspection-surface">
|
||
<div className="gis-test-run-surface" aria-label="Operationele GIS-controle">
|
||
<div className="panel-title-row">
|
||
<div>
|
||
<p className="eyebrow">Operationele GIS-controle</p>
|
||
<h3>Databaselaag doorzoeken</h3>
|
||
</div>
|
||
<span className="count-pill">{mapSelectionResult ? `${mapSelectionResult.feature_count} resultaten` : 'gereed'}</span>
|
||
</div>
|
||
<p className="muted">
|
||
Kies een bewaarde vectorlaag en doorzoek daarna de objecten in PostGIS binnen het werkgebied of de volledige laag.
|
||
</p>
|
||
<div className="gis-test-run-grid">
|
||
<div>
|
||
<span>Databaselaag</span>
|
||
<strong>{selectedMapDataset?.name ?? 'Kies een laag'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Begrenzing werkgebied</span>
|
||
<strong>{selectedAreaBbox ? 'beschikbaar' : 'ontbreekt'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Begrenzing kaartlaag</span>
|
||
<strong>{activeLayerBbox ? 'beschikbaar' : 'ontbreekt'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Resultaat</span>
|
||
<strong>{mapSelectionResult ? `${mapSelectionResult.feature_count} objecten` : 'nog niet uitgevoerd'}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="feature-extract-actions">
|
||
<button
|
||
className="primary-action"
|
||
disabled={!selectedMapDataset || !mapFeatureCollection || (!selectedAreaBbox && !activeLayerBbox) || mapSelectionLoading}
|
||
type="button"
|
||
onClick={runQuickAoiExtract}
|
||
>
|
||
{mapSelectionLoading ? 'Selectie uitvoeren...' : 'Werkgebied of laag doorzoeken'}
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!selectedAreaBbox}
|
||
type="button"
|
||
onClick={() => setSelectionBbox(selectedAreaBbox)}
|
||
>
|
||
Begrenzing werkgebied gebruiken
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!activeLayerBbox}
|
||
type="button"
|
||
onClick={() => setSelectionBbox(activeLayerBbox)}
|
||
>
|
||
Begrenzing kaartlaag gebruiken
|
||
</button>
|
||
</div>
|
||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||
<div className="guided-gis-flow" aria-label="Begeleide operationele GIS-werkstroom">
|
||
<div className="guided-gis-steps">
|
||
<div className={selectedMapDataset ? 'complete' : ''}>
|
||
<span>1</span>
|
||
<strong>Kaartlaag</strong>
|
||
<small>{selectedMapDataset ? selectedMapDataset.name : 'Kies een databaselaag'}</small>
|
||
</div>
|
||
<div className={currentSelectionBbox ? 'complete' : ''}>
|
||
<span>2</span>
|
||
<strong>Begrenzing</strong>
|
||
<small>{currentSelectionBbox ? 'Gebiedsbegrenzing gereed' : 'Gebruik het werkgebied of de laagbegrenzing'}</small>
|
||
</div>
|
||
<div className={mapSelectionResult ? 'complete' : ''}>
|
||
<span>3</span>
|
||
<strong>Selectie</strong>
|
||
<small>{mapSelectionResult ? `${mapSelectionResult.feature_count} bewaarde objecten` : 'Voer de ruimtelijke selectie uit'}</small>
|
||
</div>
|
||
<div className={latestSelectionDatasetName ? 'complete' : ''}>
|
||
<span>4</span>
|
||
<strong>Resultaatlaag</strong>
|
||
<small>{latestSelectionDatasetName ?? 'Bewaar het selectieresultaat'}</small>
|
||
</div>
|
||
<div className={mapSelectionQaResult ? 'complete' : ''}>
|
||
<span>5</span>
|
||
<strong>Kwaliteitscontrole</strong>
|
||
<small>{mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n.v.t.'}` : 'Vergelijk met een referentielaag'}</small>
|
||
</div>
|
||
<div className={latestSelectionExportPath ? 'complete' : ''}>
|
||
<span>6</span>
|
||
<strong>Download</strong>
|
||
<small>{latestSelectionExportPath ? 'GeoJSON-bestand gereed' : 'Bewaar een downloadbestand'}</small>
|
||
</div>
|
||
</div>
|
||
<div className="guided-gis-actions">
|
||
<label className="guided-gis-run-mode">
|
||
Uitvoermodus
|
||
<select
|
||
value={fullWorkflowMode}
|
||
onChange={(event) => setFullWorkflowMode(event.target.value === 'reuse' ? 'reuse' : 'new')}
|
||
disabled={fullWorkflowRunning}
|
||
>
|
||
<option value="new">Nieuwe resultaatlaag en download maken</option>
|
||
<option value="reuse" disabled={!latestSelectionDataset}>
|
||
Laatste resultaatlaag opnieuw controleren
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="primary-action guided-gis-full-run"
|
||
disabled={
|
||
fullWorkflowRunning ||
|
||
(fullWorkflowMode === 'new' && (!selectedMapDataset || !mapFeatureCollection || (!currentSelectionBbox && !selectedAreaBbox && !activeLayerBbox))) ||
|
||
(fullWorkflowMode === 'reuse' && (!latestSelectionDataset || !selectedMapQaReferenceDatasetId))
|
||
}
|
||
type="button"
|
||
onClick={runFullGisWorkflow}
|
||
>
|
||
{fullWorkflowRunning ? 'Volledige werkstroom uitvoeren...' : 'Volledige GIS-werkstroom uitvoeren'}
|
||
</button>
|
||
<div className="guided-gis-batch-status" aria-live="polite">
|
||
<strong>Selecteren, bewaren, controleren en downloaden</strong>
|
||
<span>{fullWorkflowStatus}</span>
|
||
</div>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!mapSelectionResult || !currentSelectionBbox || selectionDatasetSaving}
|
||
type="button"
|
||
onClick={saveAreaSelectionDataset}
|
||
>
|
||
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Resultaatlaag bewaren'}
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!mapSelectionResult || !currentSelectionBbox || selectionExporting}
|
||
type="button"
|
||
onClick={saveAreaSelectionExport}
|
||
>
|
||
{selectionExporting ? 'Download bewaren...' : 'GeoJSON-download bewaren'}
|
||
</button>
|
||
<label>
|
||
Referentielaag
|
||
<select
|
||
value={selectedMapQaReferenceDatasetId}
|
||
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
|
||
disabled={!latestSelectionDatasetName || mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
|
||
>
|
||
<option value="">Kies een referentielaag</option>
|
||
{mapQaReferenceDatasets.map((dataset) => (
|
||
<option key={dataset.id} value={dataset.id}>
|
||
{dataset.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="primary-action"
|
||
disabled={!latestSelectionDatasetName || !selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
|
||
type="button"
|
||
onClick={() => onRunMapSelectionQa()}
|
||
>
|
||
{mapSelectionQaRunning ? 'Kwaliteit controleren...' : 'Kwaliteit controleren'}
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!latestMapSelectionQualityCheckId}
|
||
type="button"
|
||
onClick={onOpenMapSelectionQualityEvidence}
|
||
>
|
||
Kaartbewijs openen
|
||
</button>
|
||
</div>
|
||
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
|
||
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
||
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
|
||
{fullWorkflowError ? <p className="error">{fullWorkflowError}</p> : null}
|
||
</div>
|
||
</div>
|
||
<details className="map-advanced-tools">
|
||
<summary>
|
||
<span>Geavanceerde selectie en inspectie</span>
|
||
<strong>Coördinaten, objectextractie en ruwe eigenschappen</strong>
|
||
</summary>
|
||
<div className="map-advanced-tools-body">
|
||
<div className="bbox-select-surface" aria-label="Gebiedsselectie en extractie">
|
||
<div className="panel-title-row">
|
||
<div>
|
||
<p className="eyebrow">Bewaarde vectorobjecten</p>
|
||
<h3>Gebiedsselectie</h3>
|
||
</div>
|
||
<span className="count-pill">
|
||
{mapSelectionResult ? `${mapSelectionResult.feature_count} geselecteerd` : bboxSelectionMode ? 'selecteren' : 'gereed'}
|
||
</span>
|
||
</div>
|
||
<div className="bbox-select-status">
|
||
<span>{bboxSelectionMode ? (firstSelectionCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'}</span>
|
||
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
|
||
</div>
|
||
<div className="bbox-select-grid" aria-label="Coördinaten van de gebiedsselectie">
|
||
<label>
|
||
Min lon
|
||
<input
|
||
inputMode="decimal"
|
||
value={bboxInput.min_x}
|
||
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_x: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Min lat
|
||
<input
|
||
inputMode="decimal"
|
||
value={bboxInput.min_y}
|
||
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_y: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Max lon
|
||
<input
|
||
inputMode="decimal"
|
||
value={bboxInput.max_x}
|
||
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_x: event.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Max lat
|
||
<input
|
||
inputMode="decimal"
|
||
value={bboxInput.max_y}
|
||
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_y: event.target.value }))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<div className="bbox-select-actions">
|
||
<button className="primary-action" type="button" onClick={startBboxSelection}>
|
||
Rechthoek op kaart tekenen
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!selectedFeatureBbox}
|
||
type="button"
|
||
onClick={() => setSelectionBbox(selectedFeatureBbox)}
|
||
>
|
||
Begrenzing van object gebruiken
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!selectedAreaBbox}
|
||
type="button"
|
||
onClick={() => setSelectionBbox(selectedAreaBbox)}
|
||
>
|
||
Begrenzing van werkgebied gebruiken
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!activeLayerBbox}
|
||
type="button"
|
||
onClick={() => setSelectionBbox(activeLayerBbox)}
|
||
>
|
||
Begrenzing van laag gebruiken
|
||
</button>
|
||
<button className="primary-action" disabled={!currentSelectionBbox || mapSelectionLoading} type="button" onClick={runAreaExtract}>
|
||
{mapSelectionLoading ? 'Objecten ophalen...' : 'Objecten in gebied ophalen'}
|
||
</button>
|
||
<button className="secondary-action" type="button" onClick={clearAreaSelection}>
|
||
Gebied wissen
|
||
</button>
|
||
</div>
|
||
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||
{mapSelectionResult ? (
|
||
<div className="bbox-selection-result" aria-label="Resultaat van de gebiedsselectie">
|
||
<div className="feature-extract-grid">
|
||
<div>
|
||
<span>Objecten</span>
|
||
<strong>{mapSelectionResult.feature_count}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Limiet</span>
|
||
<strong>{mapSelectionResult.limit}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Afgekapt</span>
|
||
<strong>{mapSelectionResult.truncated ? 'ja' : 'nee'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Bron</span>
|
||
<strong>Bewaarde databankobjecten</strong>
|
||
</div>
|
||
</div>
|
||
<div className="feature-extract-actions">
|
||
<button className="primary-action" type="button" onClick={downloadAreaSelection}>
|
||
GeoJSON downloaden
|
||
</button>
|
||
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
|
||
GeoJSON kopiëren
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!currentSelectionBbox || selectionExporting}
|
||
type="button"
|
||
onClick={saveAreaSelectionExport}
|
||
>
|
||
{selectionExporting ? 'Download bewaren...' : 'Gebiedsdownload bewaren'}
|
||
</button>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!currentSelectionBbox || selectionDatasetSaving}
|
||
type="button"
|
||
onClick={saveAreaSelectionDataset}
|
||
>
|
||
{selectionDatasetSaving ? 'Resultaatlaag bewaren...' : 'Als resultaatlaag bewaren'}
|
||
</button>
|
||
</div>
|
||
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
|
||
{latestSelectionExportPath ? (
|
||
<p className="muted">De geselecteerde download is bewaard.</p>
|
||
) : null}
|
||
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
|
||
{latestSelectionDatasetName ? (
|
||
<p className="muted">Bewaarde afgeleide laag: {latestSelectionDatasetName}</p>
|
||
) : null}
|
||
{latestSelectionDatasetName ? (
|
||
<div className="map-selection-qa-surface" aria-label="Map selection QA shortcut">
|
||
<label>
|
||
Referentielaag
|
||
<select
|
||
value={selectedMapQaReferenceDatasetId}
|
||
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
|
||
disabled={mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
|
||
>
|
||
<option value="">Kies een referentielaag</option>
|
||
{mapQaReferenceDatasets.map((dataset) => (
|
||
<option key={dataset.id} value={dataset.id}>
|
||
{dataset.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="primary-action"
|
||
disabled={!selectedMapQaReferenceDatasetId || mapSelectionQaRunning}
|
||
type="button"
|
||
onClick={() => onRunMapSelectionQa()}
|
||
>
|
||
{mapSelectionQaRunning ? 'Kwaliteit controleren...' : 'Bewaarde laag controleren'}
|
||
</button>
|
||
{mapSelectionQaError ? <p className="error">{mapSelectionQaError}</p> : null}
|
||
{mapSelectionQaResult ? (
|
||
<div className="map-selection-qa-evidence" aria-label="Map selection QA result">
|
||
<div className="panel-title-row">
|
||
<div>
|
||
<p className="eyebrow">Kaartbewijs</p>
|
||
<h4>Vergelijking van de bewaarde selectie</h4>
|
||
</div>
|
||
<button
|
||
className="secondary-action"
|
||
disabled={!latestMapSelectionQualityCheckId}
|
||
type="button"
|
||
onClick={onOpenMapSelectionQualityEvidence}
|
||
>
|
||
Kaartbewijs openen
|
||
</button>
|
||
</div>
|
||
<div className="feature-extract-grid">
|
||
<div>
|
||
<span>Precisie</span>
|
||
<strong>{mapSelectionQaResult.precision ?? 'n.v.t.'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Herkenningsgraad</span>
|
||
<strong>{mapSelectionQaResult.recall ?? 'n.v.t.'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>F1</span>
|
||
<strong>{mapSelectionQaResult.f1_score ?? 'n.v.t.'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Gemiddelde overlap</span>
|
||
<strong>{mapSelectionQaResult.mean_iou ?? 'n.v.t.'}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Overeenkomsten</span>
|
||
<strong>{mapSelectionQaResult.matches}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Onterecht gevonden</span>
|
||
<strong>{mapSelectionQaResult.false_positives}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Gemist</span>
|
||
<strong>{mapSelectionQaResult.false_negatives}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Status bewijs</span>
|
||
<strong>{latestMapSelectionQualityCheckId ? 'bewaard' : 'niet bewaard'}</strong>
|
||
</div>
|
||
</div>
|
||
{mapSelectionQaResult.warnings.length > 0 ? (
|
||
<div className="map-selection-qa-warnings" aria-label="Aandachtspunten bij de kwaliteitscontrole">
|
||
<span>Aandachtspunten</span>
|
||
<ul>
|
||
{mapSelectionQaResult.warnings.map((warning) => (
|
||
<li key={warning}>{warning}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{areaSelectionPreviewFeatures.length > 0 ? (
|
||
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Object</th>
|
||
<th>Klasse</th>
|
||
<th>Bronreferentie</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{areaSelectionPreviewFeatures.map((feature, index) => (
|
||
<tr key={String(feature.id ?? index)}>
|
||
<td>{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}</td>
|
||
<td>{String(feature.properties?.['feature_class'] ?? 'n.v.t.')}</td>
|
||
<td>{String(feature.properties?.['source_feature_id'] ?? 'n.v.t.')}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<p className="muted">Geen bewaarde vectorobjecten kruisen deze selectie.</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="feature-extract-surface" aria-label="Selectie en objectextractie">
|
||
<div className="panel-title-row">
|
||
<div>
|
||
<p className="eyebrow">Geselecteerd object</p>
|
||
<h3>Selectie en extractie</h3>
|
||
</div>
|
||
<span className="count-pill">{selectedMapFeature ? 'gereed' : 'wachten'}</span>
|
||
</div>
|
||
{selectedMapFeature ? (
|
||
<>
|
||
{isBathymetryProfile ? (
|
||
<div className="bathymetry-profile-summary" aria-label="Samenvatting VHA-dwarsprofiel">
|
||
<div>
|
||
<span>Waterloop</span>
|
||
<strong>{String(featureProperties?.['watercourse_name'] ?? 'Onbekende waterloop')}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Profiel</span>
|
||
<strong>{String(featureProperties?.['profile_number'] ?? 'n.v.t.')}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Meetdatum</span>
|
||
<strong>{String(featureProperties?.['measurement_date'] ?? 'Niet geregistreerd')}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Geregistreerde diepte</span>
|
||
<strong>
|
||
{typeof featureProperties?.['recorded_depth_m'] === 'number'
|
||
? `${featureProperties['recorded_depth_m'].toLocaleString('nl-BE')} m`
|
||
: 'Niet als veld beschikbaar'}
|
||
</strong>
|
||
</div>
|
||
{bathymetryDocumentUrl ? (
|
||
<a href={bathymetryDocumentUrl} target="_blank" rel="noreferrer">
|
||
Officieel profielblad openen
|
||
</a>
|
||
) : (
|
||
<small>Voor dit meetpunt is geen digitaal profielblad gekoppeld.</small>
|
||
)}
|
||
<p>
|
||
Historisch dwarsprofiel. Dit punt is geen continue actuele bodemkaart en levert zonder
|
||
gelijktijdig waterpeil geen actueel watervolume.
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<div className="feature-extract-grid" aria-label="Geometrie van het geselecteerde object">
|
||
<div>
|
||
<span>Geometrie</span>
|
||
<strong>{featureGeometrySummary.geometryType}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Coördinaten</span>
|
||
<strong>{featureGeometrySummary.coordinateCount}</strong>
|
||
</div>
|
||
<div>
|
||
<span>Eigenschappen</span>
|
||
<strong>{featureExtractionEntries.length}</strong>
|
||
</div>
|
||
<div>
|
||
<span>BBox EPSG:4326</span>
|
||
<strong>{featureGeometrySummary.bboxLabel}</strong>
|
||
</div>
|
||
</div>
|
||
<div className="feature-extract-actions">
|
||
<button className="primary-action" type="button" onClick={downloadSelectedMapFeature}>
|
||
Geselecteerde GeoJSON downloaden
|
||
</button>
|
||
<button className="secondary-action" type="button" onClick={copySelectedMapFeatureProperties}>
|
||
Eigenschappen kopiëren
|
||
</button>
|
||
<button className="secondary-action" type="button" onClick={() => onSelectMapFeature(null)}>
|
||
Selectie wissen
|
||
</button>
|
||
</div>
|
||
{featureExtractionEntries.length > 0 ? (
|
||
<div className="table-scroll feature-property-table" aria-label="Eigenschappen van het geselecteerde object">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Eigenschap</th>
|
||
<th>Waarde</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{featureExtractionEntries.map(([key, value]) => (
|
||
<tr key={key}>
|
||
<td>{key}</td>
|
||
<td>{typeof value === 'object' ? JSON.stringify(value) : String(value)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<p className="muted">Het geselecteerde object heeft geometrie maar geen bewaarde eigenschappen.</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="feature-extract-empty">
|
||
<strong>Geen object geselecteerd</strong>
|
||
<p>Klik op een zichtbaar kaartobject om de eigenschappen en GeoJSON te bekijken.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="feature-inspector">
|
||
<div className="panel-title-row">
|
||
<h3>Objectinspectie</h3>
|
||
<span className="count-pill">{selectedMapFeature?.geometry?.type ?? 'geen'}</span>
|
||
</div>
|
||
{selectedMapFeature ? (
|
||
<>
|
||
{featureSummaryEntries.length > 0 ? (
|
||
<div className="feature-summary-grid" aria-label="Samenvatting van het geselecteerde object">
|
||
{featureSummaryEntries.map(([key, value]) => (
|
||
<div className="feature-property-chip" key={key}>
|
||
<span>{key}</span>
|
||
<strong>{String(value)}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
|
||
</>
|
||
) : (
|
||
<p className="muted">Klik op een zichtbaar kaartobject om de eigenschappen te bekijken.</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|