Files
geointel/frontend/src/components/map/MapWorkspace.tsx
T
Codex daccd3869a
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: add map-driven orthophoto analysis
2026-07-15 02:01:03 +02:00

2158 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, MapViewportState, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
import { useMapThemeSelectionInsights } from '../../hooks/useMapThemeSelectionInsights'
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
type DataThemeId = 'buildings' | 'population' | 'forest' | 'water' | 'roads' | 'parcels'
interface DataTheme {
id: DataThemeId
label: string
shortLabel: string
description: string
tokens: string[]
}
interface TemporalSeriesGroup {
key: string
label: string
items: DatasetCreateResponse[]
}
const DATA_THEMES: DataTheme[] = [
{
id: 'buildings',
label: 'Bebouwing',
shortLabel: 'Gebouwen',
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
},
{
id: 'population',
label: 'Bevolking',
shortLabel: 'Inwoners',
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
},
{
id: 'forest',
label: 'Bos & groen',
shortLabel: 'Bos',
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
},
{
id: 'water',
label: 'Water',
shortLabel: 'Water',
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
},
{
id: 'roads',
label: 'Wegen',
shortLabel: 'Wegen',
description: 'Wegen en wegsegmenten uit een persistente bron.',
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
},
{
id: 'parcels',
label: 'Percelen',
shortLabel: 'Percelen',
description: 'Kadastrale of administratieve perceelcontouren.',
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
},
]
const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
buildings: { fill: '#d45f3d', line: '#9f3e24' },
population: { fill: '#7559a6', line: '#5b3f88' },
forest: { fill: '#347950', line: '#225f3b' },
water: { fill: '#2676a8', line: '#155b85' },
roads: { fill: '#6b7280', line: '#4b5563' },
parcels: { fill: '#a7792f', line: '#7d571f' },
}
function datasetSearchText(dataset: DatasetCreateResponse): string {
return [
dataset.name,
dataset.original_filename,
dataset.source,
dataset.source_name,
dataset.reference_layer_name,
dataset.metadata_json?.['layer_name'],
dataset.source_metadata?.['layer_name'],
dataset.source_metadata?.['theme'],
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
const searchText = datasetSearchText(dataset)
return theme.tokens.some((token) => searchText.includes(token))
}
function pickThemeDataset(datasets: DatasetCreateResponse[], theme: DataTheme): DatasetCreateResponse | null {
const candidates = datasets.filter((dataset) => datasetMatchesTheme(dataset, theme))
candidates.sort((left, right) => {
const score = (dataset: DatasetCreateResponse) =>
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
(dataset.source_name === 'grb' ? 100_000 : 0) +
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
(dataset.dataset_role === 'reference' ? 10_000 : 0) +
(dataset.observed_at ? new Date(dataset.observed_at).getTime() / 100_000_000 : 0) +
(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0)
return score(right) - score(left)
})
return candidates[0] ?? null
}
function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
?.source_metadata?.['temporal_series_label']
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
return configuredLabel
}
const first = items[0]
const source = first?.source_name ?? first?.source ?? 'Tijdreeks'
const firstYear = first?.observed_at ? new Date(first.observed_at).getUTCFullYear() : null
const last = items[items.length - 1]
const lastYear = last?.observed_at ? new Date(last.observed_at).getUTCFullYear() : null
return firstYear && lastYear ? `${source} (${firstYear}-${lastYear})` : source
}
function listThemeTemporalSeries(datasets: DatasetCreateResponse[], theme: DataTheme): TemporalSeriesGroup[] {
const groups = new Map<string, DatasetCreateResponse[]>()
for (const dataset of datasets) {
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
continue
}
const items = groups.get(dataset.temporal_series_key) ?? []
items.push(dataset)
groups.set(dataset.temporal_series_key, items)
}
return Array.from(groups.entries())
.filter(([, items]) => items.length >= 2)
.map(([key, items]) => {
const ordered = [...items].sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
return { key, label: temporalSeriesLabel(ordered), items: ordered }
})
.sort((left, right) => {
if (right.items.length !== left.items.length) {
return right.items.length - left.items.length
}
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
return latest(right) - latest(left)
})
}
function formatObservationDate(value: string | null | undefined): string {
if (!value) {
return 'Geen peildatum'
}
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
}
function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
}
if (project.name === KEMPEN_PROJECT_NAME) {
return 'Kempen (28 gemeenten)'
}
return project.name
}
function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
}
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
return Math.max(0, widthMetres * heightMetres)
}
function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) {
return false
}
const tolerance = 1e-9
return (
Math.abs(left.min_x - right.min_x) < tolerance &&
Math.abs(left.min_y - right.min_y) < tolerance &&
Math.abs(left.max_x - right.max_x) < tolerance &&
Math.abs(left.max_y - right.max_y) < tolerance
)
}
function formatArea(areaSquareMetres: number | null): string {
if (areaSquareMetres === null) {
return 'Nog niet geselecteerd'
}
if (areaSquareMetres >= 1_000_000) {
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
}
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
}
function resultCountLabel(result: VectorSelectionResponse): string {
const total = result.total_feature_count ?? result.feature_count
return result.truncated && result.total_feature_count == null ? `${result.feature_count.toLocaleString('nl-BE')}+` : total.toLocaleString('nl-BE')
}
function resultMetricLabel(result: VectorSelectionResponse): string {
if (!result.summary) {
return resultCountLabel(result)
}
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
}
function formatTemporalMetric(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
function readablePropertyName(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
const points: Array<[number, number]> = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
points.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
if ('coordinates' in (geometry ?? {})) {
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
}
return points
}
function formatCoordinate(value: number): string {
return Number.isFinite(value) ? value.toFixed(6) : 'n/a'
}
function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
const points = collectGeometryPoints(feature?.geometry)
if (!feature?.geometry || points.length === 0) {
return {
bboxLabel: 'n/a',
coordinateCount: 0,
geometryType: feature?.geometry?.type ?? 'none',
}
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
Math.max(...xs),
)}, ${formatCoordinate(Math.max(...ys))}`
return {
bboxLabel,
coordinateCount: points.length,
geometryType: feature.geometry.type,
}
}
function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const bounds = featureCollectionBounds(collection)
if (!bounds) {
return null
}
return {
min_x: bounds.minX,
min_y: bounds.minY,
max_x: bounds.maxX,
max_y: bounds.maxY,
crs: 'EPSG:4326',
}
}
function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
const points = collectGeometryPoints(feature?.geometry)
if (points.length === 0) {
return null
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
return {
min_x: Math.min(...xs),
min_y: Math.min(...ys),
max_x: Math.max(...xs),
max_y: Math.max(...ys),
crs: 'EPSG:4326',
}
}
function normalizeBboxFromCorners(first: [number, number], second: [number, number]): VectorSelectionBBox {
return {
min_x: Math.min(first[0], second[0]),
min_y: Math.min(first[1], second[1]),
max_x: Math.max(first[0], second[0]),
max_y: Math.max(first[1], second[1]),
crs: 'EPSG:4326',
}
}
function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
if (!bbox) {
return 'n/a'
}
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
min_y: bbox ? String(bbox.min_y) : '',
max_x: bbox ? String(bbox.max_x) : '',
max_y: bbox ? String(bbox.max_y) : '',
}
}
function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
const min_x = Number(input.min_x)
const min_y = Number(input.min_y)
const max_x = Number(input.max_x)
const max_y = Number(input.max_y)
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
return null
}
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
}
function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [feature],
}
}
function safeFileStem(value: unknown): string {
const stem = String(value ?? 'selected-feature')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return stem || 'selected-feature'
}
function fallbackCopyText(text: string): void {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
function copyText(text: string): void {
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
return
}
fallbackCopyText(text)
}
function downloadJsonFile(filename: string, payload: unknown): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/geo+json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
interface MapWorkspaceProps {
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
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
availableMapDatasets: DatasetCreateResponse[]
selectedMapDatasetId: string
onSelectMapArea: (areaId: string) => 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) => Promise<unknown>
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox) => Promise<DatasetCreateResponse | null>
onSelectMapQaReferenceDataset: (datasetId: string) => void
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
onOpenMapSelectionQualityEvidence: () => void
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
onClearQualityEvidence?: () => void
}
export function MapWorkspace({
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,
selectionExporting,
selectionExportError,
latestSelectionExportPath,
selectionDatasetSaving,
selectionDatasetError,
latestSelectionDataset,
latestSelectionDatasetName,
mapQaReferenceDatasets,
selectedMapQaReferenceDatasetId,
mapSelectionQaRunning,
mapSelectionQaError,
mapSelectionQaResult,
latestMapSelectionQualityCheckId,
orthophotoAnalysisStage,
orthophotoAnalysisStatus,
orthophotoAnalysisError,
orthophotoAnalysisRunning,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
onOpenDatasetInMap,
onSetAreaLayerVisible,
onSetAreaLayerOpacity,
onSetMapLayerVisible,
onSetMapLayerOpacity,
onSetMapContentMode,
onSelectMapFeature,
onMapViewportChange,
onSetMapSelectionBbox,
onRunMapSelectionExtract,
onClearMapSelectionExtract,
onExportMapSelection,
onDeriveMapSelectionDataset,
onSelectMapQaReferenceDataset,
onRunMapSelectionQa,
onOpenMapSelectionQualityEvidence,
onRunOrthophotoAnalysis,
onClearQualityEvidence,
}: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false)
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>('buildings')
const {
themeInsights,
themeInsightsLoading: themeResultsLoading,
themeInsightsError: themeResultsError,
loadThemeInsights,
clearThemeInsights,
} = useMapThemeSelectionInsights<DataThemeId>(selectedProjectId)
const {
temporalComparison,
temporalComparisonLoading,
temporalComparisonError,
compareTemporalSnapshots,
clearTemporalComparison,
} = useTemporalComparison(selectedProjectId)
const [analysisMode, setAnalysisMode] = useState<'current' | 'evolution'>('current')
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('Ready to run persisted GIS workflow.')
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
const [fullWorkflowMode, setFullWorkflowMode] = useState<'new' | 'reuse'>('new')
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
const featureProperties = selectedMapFeature?.properties ?? 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 themeDatasetMap = useMemo(
() =>
Object.fromEntries(
DATA_THEMES.map((theme) => [theme.id, pickThemeDataset(availableMapDatasets, theme)]),
) as Record<DataThemeId, DatasetCreateResponse | null>,
[availableMapDatasets],
)
const activeTheme = DATA_THEMES.find((theme) => theme.id === activeThemeId) ?? DATA_THEMES[0]
const activeThemeMapStyle = DATA_THEME_MAP_STYLES[activeTheme.id]
const analysisOverlayActive = mapContentMode === 'analysis' && analysisLayerAvailable && Boolean(mapFeatureCollection)
const activeThemeDataset = themeDatasetMap[activeTheme.id]
const activeScopeProject = projects.find((project) => project.id === selectedProjectId) ?? null
const activeScopeLabel = activeScopeProject ? operationalScopeProjectLabel(activeScopeProject) : 'Werkgebied'
const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length
const activeTemporalSeriesGroups = useMemo(
() => listThemeTemporalSeries(availableMapDatasets, activeTheme),
[activeTheme, availableMapDatasets],
)
const activeTemporalSeriesGroup = activeTemporalSeriesGroups.find((group) => group.key === selectedTemporalSeriesKey)
?? activeTemporalSeriesGroups[0]
const activeTemporalSeries = activeTemporalSeriesGroup?.items ?? EMPTY_TEMPORAL_SERIES
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 activeSelectionResult = themeResults.find((item) => item.theme.id === activeThemeId)?.result ?? mapSelectionResult
const selectedAreaSquareMetres = useMemo(
() =>
bboxesEqual(mapSelectionBbox, selectedAreaBbox) && selectedMapArea?.area_m2
? selectedMapArea.area_m2
: selectionAreaSquareMetres(mapSelectionBbox),
[mapSelectionBbox, selectedAreaBbox, selectedMapArea?.area_m2],
)
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 activeSecondaryMetric = 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 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 first = activeTemporalSeries[0]
const last = activeTemporalSeries[activeTemporalSeries.length - 1]
setEarlierDatasetId(first?.id ?? '')
setLaterDatasetId(last?.id ?? '')
clearTemporalComparison()
}, [activeTemporalSeries])
useEffect(() => {
if (advancedMode || !activeThemeDataset || (selectedMapDataset && datasetMatchesTheme(selectedMapDataset, activeTheme))) {
return
}
onOpenDatasetInMap(activeThemeDataset)
}, [activeTheme, activeThemeDataset, advancedMode, onOpenDatasetInMap, selectedMapDataset])
useEffect(() => {
if (!selectedMapDataset) {
return
}
const matchingTheme = DATA_THEMES.find((theme) => datasetMatchesTheme(selectedMapDataset, theme))
if (matchingTheme) {
setActiveThemeId(matchingTheme.id)
}
}, [selectedMapDataset])
const downloadSelectedMapFeature = () => {
if (!selectedFeatureGeoJson) {
return
}
downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson)
}
const copySelectedMapFeatureProperties = () => {
copyText(JSON.stringify(featureProperties ?? {}, null, 2))
}
const setSelectionBbox = (bbox: VectorSelectionBBox | null) => {
onSetMapSelectionBbox(bbox)
setBboxInput(bboxToInputState(bbox))
}
const startBboxSelection = () => {
setFirstSelectionCorner(null)
clearThemeInsights()
clearTemporalComparison()
setBboxSelectionMode(true)
}
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
if (!firstSelectionCorner) {
setFirstSelectionCorner(coordinate)
return
}
const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate)
setSelectionBbox(bbox)
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox)
}
const runAreaExtract = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
void analyzeSelection(bbox)
}
const clearAreaSelection = () => {
setBboxSelectionMode(false)
setFirstSelectionCorner(null)
setBboxInput(bboxToInputState(null))
clearThemeInsights()
clearTemporalComparison()
onClearMapSelectionExtract()
}
const handleSelectMapArea = (areaId: string) => {
clearAreaSelection()
onSelectMapArea(areaId)
}
const downloadAreaSelection = () => {
if (!mapSelectionResult) {
return
}
downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson)
}
const copyAreaSelection = () => {
copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
}
const downloadActiveThemeSelection = () => {
if (!activeSelectionResult) {
return
}
downloadJsonFile(`${activeTheme.id}-selection.geojson`, activeSelectionResult.geojson)
}
const copyActiveThemeSelection = () => {
copyText(JSON.stringify(activeSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
}
const saveAreaSelectionExport = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
onExportMapSelection(bbox)
}
const saveAreaSelectionDataset = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
onDeriveMapSelectionDataset(bbox)
}
const openSelectedDatabaseLayer = (datasetId: string) => {
const dataset = availableMapDatasets.find((item) => item.id === datasetId)
if (dataset) {
onOpenDatasetInMap(dataset)
}
}
const selectDataTheme = (theme: DataTheme) => {
const dataset = themeDatasetMap[theme.id]
if (!dataset) {
return
}
setActiveThemeId(theme.id)
clearTemporalComparison()
onOpenDatasetInMap(dataset)
}
const setExplorerMode = (mode: 'current' | 'evolution') => {
setAnalysisMode(mode)
clearTemporalComparison()
}
const loadAllThemeResults = async (bbox: VectorSelectionBBox, areaId?: string) => {
const availableThemes = DATA_THEMES.flatMap((theme) => {
const dataset = themeDatasetMap[theme.id]
return dataset ? [{ themeId: theme.id, dataset }] : []
})
await loadThemeInsights(bbox, availableThemes, areaId)
}
const analyzeSelection = async (bbox: VectorSelectionBBox, areaId?: string) => {
setSelectionBbox(bbox)
const tasks: Array<Promise<unknown>> = [onRunMapSelectionExtract(bbox, areaId), loadAllThemeResults(bbox, areaId)]
if (analysisMode === 'evolution' && earlierDatasetId && laterDatasetId) {
tasks.push(compareTemporalSnapshots(earlierDatasetId, laterDatasetId, bbox))
}
await Promise.all(tasks)
}
const runTemporalComparison = () => {
if (!mapSelectionBbox || !earlierDatasetId || !laterDatasetId) {
return
}
void compareTemporalSnapshots(earlierDatasetId, laterDatasetId, mapSelectionBbox)
}
const handleMapBboxPreview = (bbox: VectorSelectionBBox) => {
setSelectionBbox(bbox)
}
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
void analyzeSelection(bbox)
}
const runQuickAoiExtract = () => {
const bbox = selectedAreaBbox ?? activeLayerBbox
if (!bbox) {
return
}
setSelectionBbox(bbox)
void analyzeSelection(bbox, selectedAreaBbox ? selectedMapArea?.id : undefined)
}
const runFullGisWorkflow = async () => {
const bbox = currentSelectionBbox ?? selectedAreaBbox ?? activeLayerBbox
if (fullWorkflowMode === 'reuse') {
if (!latestSelectionDataset) {
setFullWorkflowError('Save a map selection dataset before reusing the latest result.')
return
}
if (!selectedMapQaReferenceDatasetId) {
setFullWorkflowError('Select a reference dataset before reusing the latest result for QA/QC.')
return
}
setFullWorkflowRunning(true)
setFullWorkflowError(null)
try {
setFullWorkflowStatus('Reusing latest saved dataset for QA/QC...')
const qaResult = await onRunMapSelectionQa(latestSelectionDataset)
setFullWorkflowStatus(qaResult ? 'Reused latest saved dataset and completed QA/QC.' : 'Latest saved dataset reused, but QA/QC did not complete.')
} catch (error) {
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
setFullWorkflowStatus('Workflow stopped.')
} finally {
setFullWorkflowRunning(false)
}
return
}
if (!selectedMapDataset || !bbox) {
setFullWorkflowError('Select a database layer and AOI/layer extent before running the full workflow.')
return
}
setFullWorkflowRunning(true)
setFullWorkflowError(null)
try {
setFullWorkflowStatus('1/4 Querying persisted vector_features...')
setSelectionBbox(bbox)
const selection = await onRunMapSelectionExtract(bbox)
if (!selection) {
setFullWorkflowError('Persisted vector query did not complete.')
setFullWorkflowStatus('Stopped at query.')
return
}
setFullWorkflowStatus('2/4 Saving derived result dataset...')
const derived = await onDeriveMapSelectionDataset(bbox)
if (!derived) {
setFullWorkflowError('Derived result dataset was not created.')
setFullWorkflowStatus('Stopped at dataset save.')
return
}
setFullWorkflowStatus('3/4 Saving GeoJSON export artifact...')
await onExportMapSelection(bbox)
if (selectedMapQaReferenceDatasetId) {
setFullWorkflowStatus('4/4 Running QA/QC against selected reference...')
const qaResult = await onRunMapSelectionQa(derived)
setFullWorkflowStatus(qaResult ? 'Full GIS workflow complete with QA/QC result.' : 'Dataset/export complete; QA/QC did not complete.')
} else {
setFullWorkflowStatus('Dataset/export complete. Select a reference dataset to add QA/QC.')
}
} catch (error) {
setFullWorkflowError(error instanceof Error ? error.message : 'Full GIS workflow failed.')
setFullWorkflowStatus('Workflow stopped.')
} finally {
setFullWorkflowRunning(false)
}
}
if (!advancedMode) {
return (
<section className="geo-explorer" data-testid="map-workspace" aria-label={`Gebiedsverkenner ${activeScopeLabel}`}>
<header className="geo-explorer-header">
<div>
<p className="eyebrow">{activeScopeLabel} · geografische verkenner</p>
<h2>Wat bevindt zich in dit gebied?</h2>
<p>Kies een datathema, teken een rechthoek en lees de beschikbare gegevens meteen uit.</p>
</div>
<div className="geo-analysis-mode" role="tablist" aria-label="Analyseperiode">
<button
className={analysisMode === 'current' ? 'active' : ''}
type="button"
role="tab"
aria-selected={analysisMode === 'current'}
onClick={() => setExplorerMode('current')}
>
Laatste toestand
</button>
<button
className={analysisMode === 'evolution' ? 'active' : ''}
type="button"
role="tab"
aria-selected={analysisMode === 'evolution'}
onClick={() => setExplorerMode('evolution')}
>
Evolutie
</button>
</div>
<button className="secondary-action geo-explorer-advanced" type="button" onClick={() => setAdvancedMode(true)}>
Geavanceerde werkbank
</button>
</header>
<div className="geo-explorer-layout">
<aside className="geo-theme-panel" aria-label="Datathema kiezen">
<div className="geo-panel-heading">
<span>1</span>
<div>
<h3>Kies een datathema</h3>
<p>Dit zijn databronnen, geen AI-modellen.</p>
</div>
</div>
<div className="geo-loaded-scope" aria-label="Ingeladen regiobereik">
<span>Ingeladen bereik</span>
<strong>{activeScopeLabel}</strong>
<small>{municipalityAreaCount > 0 ? `${municipalityAreaCount} gemeenten en de volledige regio beschikbaar` : 'Regionale gegevens worden geladen'}</small>
</div>
<div className="geo-theme-list">
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const active = activeThemeId === theme.id
return (
<button
className={active ? 'geo-theme-option geo-theme-option-active' : 'geo-theme-option'}
disabled={!dataset}
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>{dataset ? `${(dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 0).toLocaleString('nl-BE')} objecten beschikbaar` : 'Bron nog niet ingeladen'}</small>
</span>
<i>{dataset ? 'Beschikbaar' : 'Ontbreekt'}</i>
</button>
)
})}
</div>
<div className="geo-source-summary">
<span>{analysisOverlayActive ? 'Actieve analyselaag' : analysisMode === 'evolution' ? 'Tijdreeks' : 'Actieve bron'}</span>
<strong>
{analysisOverlayActive
? mapLayerLabel
: analysisMode === 'evolution'
? activeTemporalSeriesGroup?.label ?? 'Geen tijdreeks beschikbaar'
: activeThemeDataset ? getDatasetDisplayName(activeThemeDataset) : 'Geen databron beschikbaar'}
</strong>
<small>
{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)}`
: 'Minstens twee expliciet gedateerde snapshots zijn vereist.'
: activeThemeDataset
? `${getDatasetSourceDisplayName(activeThemeDataset)} · ${formatObservationDate(activeThemeDataset.observed_at)}`
: activeTheme.description}
</small>
</div>
{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) => { setEarlierDatasetId(event.target.value); clearTemporalComparison() }} disabled={activeTemporalSeries.length < 2}>
{activeTemporalSeries.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={activeTemporalSeries.length < 2}>
{activeTemporalSeries.map((dataset) => (
<option key={dataset.id} value={dataset.id}>{formatObservationDate(dataset.observed_at)}</option>
))}
</select>
</label>
<button
className="primary-action"
type="button"
disabled={!mapSelectionBbox || !earlierDatasetId || !laterDatasetId || temporalComparisonLoading}
onClick={runTemporalComparison}
>
{temporalComparisonLoading ? 'Vergelijken…' : 'Vergelijk periode'}
</button>
</div>
) : null}
<label className="geo-scope-select">
Snel naar een gemeente (optioneel)
<select aria-label="Werkgebied" value={selectedMapAreaId} onChange={(event) => handleSelectMapArea(event.target.value)} disabled={areas.length === 0}>
{areas.map((area) => (
<option key={area.id} value={area.id}>{area.name}</option>
))}
</select>
</label>
</aside>
<div className="geo-map-stage">
<div className="geo-map-toolbar" aria-label="Gebied selecteren">
<div className="geo-panel-heading geo-map-step">
<span>2</span>
<div>
<h3>Selecteer een gebied</h3>
<p>{bboxSelectionMode ? 'Sleep nu een rechthoek op de kaart.' : 'Sleep een rechthoek of analyseer het volledige werkgebied.'}</p>
</div>
</div>
<div className="geo-map-actions">
<button
className={bboxSelectionMode ? 'primary-action geo-draw-active' : 'primary-action'}
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={startBboxSelection}
>
{bboxSelectionMode ? 'Teken op de kaart…' : 'Teken rechthoek'}
</button>
<button
className="secondary-action"
disabled={!activeThemeDataset || (analysisMode === 'evolution' && activeTemporalSeries.length < 2) || !selectedAreaBbox || mapSelectionLoading || themeResultsLoading}
type="button"
onClick={() => selectedAreaBbox && void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)}
>
Volledig werkgebied
</button>
<button className="secondary-action" disabled={!mapSelectionBbox} type="button" onClick={clearAreaSelection}>
Wis selectie
</button>
</div>
</div>
<div className={bboxSelectionMode ? 'geo-map-canvas geo-map-canvas-drawing' : 'geo-map-canvas'}>
<GeoMap
data={analysisMode === 'evolution' && temporalComparison?.geojson.features.length ? temporalComparison.geojson : mapFeatureCollection}
dataFillColor={activeThemeMapStyle.fill}
dataLineColor={activeThemeMapStyle.line}
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={analysisMode === 'current' ? mapSelectionResult?.geojson ?? null : null}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
fitDataOnChange={fitMapDataOnChange}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
onMapBboxPreview={handleMapBboxPreview}
onMapBboxSelect={handleMapBboxSelect}
onViewportChange={onMapViewportChange}
/>
<div className="geo-map-legend" aria-label="Kaartlegende">
<span><i className="geo-legend-area" /> Werkgebied</span>
{analysisOverlayActive ? (
<>
<span><i className="geo-legend-layer geo-legend-layer-buildings" /> Gevonden gebouwen</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>
<aside className="geo-results-panel" aria-label="Gebiedsanalyse">
<div className="geo-panel-heading">
<span>3</span>
<div>
<h3>Resultaten</h3>
<p>Alleen gemeten gegevens uit beschikbare bronnen.</p>
</div>
</div>
{analysisMode === 'current' && mapSelectionBbox ? (
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
<strong>Gebouwen herkennen op luchtbeeld</strong>
<small>Officieel luchtbeeld, lokaal AI-model en automatische controle met GRB.</small>
</div>
<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'
? 'Opnieuw analyseren'
: 'Herken gebouwen'}
</button>
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
</div>
) : null}
{!mapSelectionBbox ? (
<div className="geo-results-empty">
<strong>Nog geen gebied geselecteerd</strong>
<p>Teken een rechthoek op de kaart. De analyse start automatisch zodra je loslaat.</p>
</div>
) : mapSelectionLoading || themeResultsLoading || temporalComparisonLoading ? (
<div className="geo-results-loading" role="status">
<span />
<strong>Gegevens worden uit PostGIS gelezen</strong>
</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>
{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>{activeMetricUnit === 'ha' ? 'Aandeel selectie' : 'Dichtheid'}</span>
<strong>{activeSecondaryMetric ?? (selectedDensity === null ? 'n.v.t.' : `${selectedDensity.toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / km2`)}</strong>
</div>
</div>
<div className="geo-theme-results">
<div className="geo-results-title-row">
<h4>Alle beschikbare themas</h4>
<span>{themeResults.length} bevraagd</span>
</div>
{DATA_THEMES.map((theme) => {
const dataset = themeDatasetMap[theme.id]
const item = themeResults.find((result) => result.theme.id === theme.id)
return (
<div className="geo-theme-result-row" key={theme.id}>
<span className={`geo-theme-symbol geo-theme-symbol-${theme.id}`} aria-hidden="true" />
<span>
<strong>{theme.label}</strong>
<small>{dataset ? getDatasetSourceDisplayName(dataset) : 'Geen bron gekoppeld'}</small>
</span>
<b>{item ? resultMetricLabel(item.result) : dataset ? 'Niet bevraagd' : 'Bron ontbreekt'}</b>
</div>
)
})}
</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}
{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={downloadActiveThemeSelection}>Download GeoJSON</button>
<button className="secondary-action" disabled={!activeSelectionResult} type="button" onClick={copyActiveThemeSelection}>Kopieer gegevens</button>
</div>
) : null}
</>
)}
</aside>
</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'
: activeThemeDataset
? getDatasetDisplayName(activeThemeDataset)
: 'niet beschikbaar'}
</span>
{usesDefaultOsmBasemap ? <span><strong>Ondergrond:</strong> OpenStreetMap</span> : null}
</footer>
</section>
)
}
return (
<section 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">Spatial review</p>
<h2>Map workspace</h2>
</div>
<span className="count-pill">
{mapFeatureCollection ? `${mapFeatureCount} features` : viewportVectorEnabled ? 'zoom to load' : 'no layer'}
</span>
</div>
<div className="map-control-surface" aria-label="Map workspace controls">
{usesDefaultOsmBasemap ? (
<div className="basemap-policy-notice" aria-label="Basemap usage notice">
<strong>Local/demo basemap</strong>
<span>Public OpenStreetMap tiles are active. Configure VITE_MAP_STYLE_URL for production or heavier use.</span>
</div>
) : null}
<div className="map-toolbar">
<div className="map-layer-mode" aria-label="Map content mode">
<span>Map content</span>
<div role="group" aria-label="Map content source">
<button
type="button"
aria-pressed={mapContentMode === 'dataset'}
onClick={() => onSetMapContentMode('dataset')}
>
Database
</button>
<button
type="button"
aria-pressed={mapContentMode === 'analysis'}
disabled={!analysisLayerAvailable}
onClick={() => onSetMapContentMode('analysis')}
>
Analysis result
</button>
</div>
</div>
<label>
Database layer
<select
value={selectedMapDatasetId}
onChange={(event) => openSelectedDatabaseLayer(event.target.value)}
disabled={availableMapDatasets.length === 0}
data-testid="map-database-layer-select"
>
<option value="">Select persisted vector layer</option>
{availableMapDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<label>
Area
<select
aria-label="Werkgebied"
value={selectedMapAreaId}
onChange={(event) => handleSelectMapArea(event.target.value)}
disabled={areas.length === 0}
data-testid="map-area-select"
>
<option value="">No area</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"
/>
AOI
</label>
<input
aria-label="Area opacity"
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"
/>
Active layer
</label>
<input
aria-label="Layer opacity"
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 ? `DB layer: ${selectedMapDataset.name}` : 'No database layer selected'}</span>
<span>{areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'}</span>
<span>
{mapFeatureCollection
? `${mapFeatureCount} features loaded`
: viewportVectorEnabled
? 'Database layer selected; visible features load by viewport'
: 'No vector/result layer loaded'}
</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>Layer details</span>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport layer selected' : 'No active layer'}
</strong>
</summary>
<div className="map-context-summary" aria-label="Map layer status">
<div>
<span>Area of interest</span>
<strong>{selectedMapArea?.name ?? 'No area selected'}</strong>
<small>{areaFeatureCollection ? `${areaFeatureCount} AOI features loaded` : 'AOI overlay disabled'}</small>
</div>
<div>
<span>Active layer</span>
<strong>{mapLayerLabel}</strong>
<small>{mapLayerSourceLabel}</small>
</div>
<div>
<span>Feature state</span>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Awaiting viewport detail' : 'No layer rendered'}
</strong>
<small>{mapLayerProvenance}</small>
</div>
<div>
<span>QA/QC evidence</span>
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} evidence features` : 'No evidence overlay'}</strong>
<small>{qualityEvidenceLoading ? 'Loading persisted evidence' : 'Matches, false positives and false negatives'}</small>
</div>
</div>
<div className="layer-provenance-rail" aria-label="Active map layer provenance">
<div>
<span>Layer source</span>
<strong>{mapLayerSourceLabel}</strong>
</div>
<div>
<span>Provenance</span>
<strong>{mapLayerProvenance}</strong>
</div>
<div>
<span>Draw state</span>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport delivery active' : 'No active vector or result layer'}
</strong>
</div>
<div>
<span>QA evidence overlay</span>
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} rendered` : 'off'}</strong>
</div>
</div>
</details>
{qualityEvidenceGeoJson || qualityEvidenceError || qualityEvidenceWarnings.length > 0 ? (
<div className="qa-evidence-map-status" aria-label="QA/QC evidence map overlay status">
<div>
<span>QA/QC evidence overlay</span>
<strong>{qualityEvidenceGeoJson ? `${qualityEvidenceFeatureCount} persisted features` : 'Not loaded'}</strong>
{qualityEvidenceError ? <p className="error">{qualityEvidenceError}</p> : null}
{qualityEvidenceWarnings.length > 0 ? (
<p className="muted">{qualityEvidenceWarnings.length} evidence id{qualityEvidenceWarnings.length === 1 ? '' : 's'} could not be resolved.</p>
) : null}
</div>
<div className="qa-evidence-legend" aria-label="QA/QC evidence overlay legend">
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-candidate" /> Match candidate</span>
<span><i className="qa-evidence-swatch qa-evidence-swatch-match-reference" /> Match reference</span>
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-positive" /> False positive</span>
<span><i className="qa-evidence-swatch qa-evidence-swatch-false-negative" /> False negative</span>
</div>
{onClearQualityEvidence ? (
<button className="secondary-action" type="button" onClick={onClearQualityEvidence}>
Clear QA evidence
</button>
) : null}
</div>
) : null}
{!mapFeatureCollection && !viewportVectorEnabled ? (
<div className="empty-state map-empty-state">
<strong>No active vector or result layer</strong>
<p>Open a dataset, detection run, segmentation run or change result to draw it here.</p>
{availableMapDatasets.length > 0 ? (
<>
<p className="eyebrow">Open a ready vector dataset</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 in map</strong>
</button>
))}
</div>
</>
) : (
<p className="muted">No ready vector datasets available yet. Upload or seed a vector dataset first.</p>
)}
</div>
) : null}
<div className="map-inspection-surface">
<div className="gis-test-run-surface" aria-label="Operational GIS test run">
<div className="panel-title-row">
<div>
<p className="eyebrow">Operational GIS run</p>
<h3>Database selection test</h3>
</div>
<span className="count-pill">{mapSelectionResult ? `${mapSelectionResult.feature_count} hits` : 'ready'}</span>
</div>
<p className="muted">
Select a persisted vector layer, then query stored vector_features from PostGIS with the AOI or layer extent.
</p>
<div className="gis-test-run-grid">
<div>
<span>Database layer</span>
<strong>{selectedMapDataset?.name ?? 'Select a layer'}</strong>
</div>
<div>
<span>AOI bbox</span>
<strong>{selectedAreaBbox ? 'available' : 'missing'}</strong>
</div>
<div>
<span>Layer bbox</span>
<strong>{activeLayerBbox ? 'available' : 'missing'}</strong>
</div>
<div>
<span>Result</span>
<strong>{mapSelectionResult ? `${mapSelectionResult.feature_count} features` : 'not run'}</strong>
</div>
</div>
<div className="feature-extract-actions">
<button
className="primary-action"
disabled={!selectedMapDataset || !mapFeatureCollection || (!selectedAreaBbox && !activeLayerBbox) || mapSelectionLoading}
type="button"
onClick={runQuickAoiExtract}
>
{mapSelectionLoading ? 'Running test...' : 'Run AOI/layer query'}
</button>
<button
className="secondary-action"
disabled={!selectedAreaBbox}
type="button"
onClick={() => setSelectionBbox(selectedAreaBbox)}
>
Use AOI extent
</button>
<button
className="secondary-action"
disabled={!activeLayerBbox}
type="button"
onClick={() => setSelectionBbox(activeLayerBbox)}
>
Use layer extent
</button>
</div>
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
<div className="guided-gis-flow" aria-label="Guided operational GIS workflow">
<div className="guided-gis-steps">
<div className={selectedMapDataset ? 'complete' : ''}>
<span>1</span>
<strong>Layer</strong>
<small>{selectedMapDataset ? selectedMapDataset.name : 'Select database layer'}</small>
</div>
<div className={currentSelectionBbox ? 'complete' : ''}>
<span>2</span>
<strong>Extent</strong>
<small>{currentSelectionBbox ? 'AOI/layer bbox ready' : 'Use AOI or layer extent'}</small>
</div>
<div className={mapSelectionResult ? 'complete' : ''}>
<span>3</span>
<strong>Query</strong>
<small>{mapSelectionResult ? `${mapSelectionResult.feature_count} persisted features` : 'Run PostGIS selection'}</small>
</div>
<div className={latestSelectionDatasetName ? 'complete' : ''}>
<span>4</span>
<strong>Dataset</strong>
<small>{latestSelectionDatasetName ?? 'Save query result'}</small>
</div>
<div className={mapSelectionQaResult ? 'complete' : ''}>
<span>5</span>
<strong>QA/QC</strong>
<small>{mapSelectionQaResult ? `F1 ${mapSelectionQaResult.f1_score ?? 'n/a'}` : 'Compare with reference'}</small>
</div>
<div className={latestSelectionExportPath ? 'complete' : ''}>
<span>6</span>
<strong>Export</strong>
<small>{latestSelectionExportPath ? 'GeoJSON artifact ready' : 'Save handoff artifact'}</small>
</div>
</div>
<div className="guided-gis-actions">
<label className="guided-gis-run-mode">
Run mode
<select
value={fullWorkflowMode}
onChange={(event) => setFullWorkflowMode(event.target.value === 'reuse' ? 'reuse' : 'new')}
disabled={fullWorkflowRunning}
>
<option value="new">Create new dataset/export</option>
<option value="reuse" disabled={!latestSelectionDataset}>
Reuse latest saved dataset for QA
</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 ? 'Running full workflow...' : 'Run full GIS workflow'}
</button>
<div className="guided-gis-batch-status" aria-live="polite">
<strong>Query, save, QA and export</strong>
<span>{fullWorkflowStatus}</span>
</div>
<button
className="secondary-action"
disabled={!mapSelectionResult || !currentSelectionBbox || selectionDatasetSaving}
type="button"
onClick={saveAreaSelectionDataset}
>
{selectionDatasetSaving ? 'Saving dataset...' : 'Save result dataset'}
</button>
<button
className="secondary-action"
disabled={!mapSelectionResult || !currentSelectionBbox || selectionExporting}
type="button"
onClick={saveAreaSelectionExport}
>
{selectionExporting ? 'Saving export...' : 'Save GeoJSON export'}
</button>
<label>
Reference
<select
value={selectedMapQaReferenceDatasetId}
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
disabled={!latestSelectionDatasetName || mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
>
<option value="">Select reference</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 ? 'Running QA...' : 'Run QA/QC'}
</button>
<button
className="secondary-action"
disabled={!latestMapSelectionQualityCheckId}
type="button"
onClick={onOpenMapSelectionQualityEvidence}
>
Open evidence
</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>Advanced selection and inspection</span>
<strong>BBox, feature extract and raw properties</strong>
</summary>
<div className="map-advanced-tools-body">
<div className="bbox-select-surface" aria-label="Area selection and extract">
<div className="panel-title-row">
<div>
<p className="eyebrow">Persisted vector query</p>
<h3>Area selection</h3>
</div>
<span className="count-pill">
{mapSelectionResult ? `${mapSelectionResult.feature_count} selected` : bboxSelectionMode ? 'selecting' : 'ready'}
</span>
</div>
<div className="bbox-select-status">
<span>{bboxSelectionMode ? (firstSelectionCorner ? 'Click the opposite corner' : 'Click the first corner on the map') : 'BBox EPSG:4326'}</span>
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
</div>
<div className="bbox-select-grid" aria-label="Selection bbox inputs">
<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}>
Start map bbox
</button>
<button
className="secondary-action"
disabled={!selectedFeatureBbox}
type="button"
onClick={() => setSelectionBbox(selectedFeatureBbox)}
>
Use feature bbox
</button>
<button
className="secondary-action"
disabled={!selectedAreaBbox}
type="button"
onClick={() => setSelectionBbox(selectedAreaBbox)}
>
Use AOI bbox
</button>
<button
className="secondary-action"
disabled={!activeLayerBbox}
type="button"
onClick={() => setSelectionBbox(activeLayerBbox)}
>
Use layer bbox
</button>
<button className="primary-action" disabled={!currentSelectionBbox || mapSelectionLoading} type="button" onClick={runAreaExtract}>
{mapSelectionLoading ? 'Extracting...' : 'Run area extract'}
</button>
<button className="secondary-action" type="button" onClick={clearAreaSelection}>
Clear area
</button>
</div>
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
{mapSelectionResult ? (
<div className="bbox-selection-result" aria-label="Area selection result">
<div className="feature-extract-grid">
<div>
<span>Features</span>
<strong>{mapSelectionResult.feature_count}</strong>
</div>
<div>
<span>Limit</span>
<strong>{mapSelectionResult.limit}</strong>
</div>
<div>
<span>Truncated</span>
<strong>{mapSelectionResult.truncated ? 'yes' : 'no'}</strong>
</div>
<div>
<span>Source</span>
<strong>vector_features</strong>
</div>
</div>
<div className="feature-extract-actions">
<button className="primary-action" type="button" onClick={downloadAreaSelection}>
Download area GeoJSON
</button>
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
Copy area GeoJSON
</button>
<button
className="secondary-action"
disabled={!currentSelectionBbox || selectionExporting}
type="button"
onClick={saveAreaSelectionExport}
>
{selectionExporting ? 'Saving export...' : 'Save area export'}
</button>
<button
className="secondary-action"
disabled={!currentSelectionBbox || selectionDatasetSaving}
type="button"
onClick={saveAreaSelectionDataset}
>
{selectionDatasetSaving ? 'Saving dataset...' : 'Save as dataset'}
</button>
</div>
{selectionExportError ? <p className="error">{selectionExportError}</p> : null}
{latestSelectionExportPath ? (
<p className="muted">Saved selection artifact: {latestSelectionExportPath}</p>
) : null}
{selectionDatasetError ? <p className="error">{selectionDatasetError}</p> : null}
{latestSelectionDatasetName ? (
<p className="muted">Saved derived dataset: {latestSelectionDatasetName}</p>
) : null}
{latestSelectionDatasetName ? (
<div className="map-selection-qa-surface" aria-label="Map selection QA shortcut">
<label>
Reference dataset
<select
value={selectedMapQaReferenceDatasetId}
onChange={(event) => onSelectMapQaReferenceDataset(event.target.value)}
disabled={mapQaReferenceDatasets.length === 0 || mapSelectionQaRunning}
>
<option value="">Select reference</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 ? 'Running QA...' : 'Run QA on saved dataset'}
</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">QA evidence</p>
<h4>Saved selection comparison</h4>
</div>
<button
className="secondary-action"
disabled={!latestMapSelectionQualityCheckId}
type="button"
onClick={onOpenMapSelectionQualityEvidence}
>
Open QA/QC evidence
</button>
</div>
<div className="feature-extract-grid">
<div>
<span>Precision</span>
<strong>{mapSelectionQaResult.precision ?? 'n/a'}</strong>
</div>
<div>
<span>Recall</span>
<strong>{mapSelectionQaResult.recall ?? 'n/a'}</strong>
</div>
<div>
<span>F1</span>
<strong>{mapSelectionQaResult.f1_score ?? 'n/a'}</strong>
</div>
<div>
<span>Mean IoU</span>
<strong>{mapSelectionQaResult.mean_iou ?? 'n/a'}</strong>
</div>
<div>
<span>Matches</span>
<strong>{mapSelectionQaResult.matches}</strong>
</div>
<div>
<span>False positives</span>
<strong>{mapSelectionQaResult.false_positives}</strong>
</div>
<div>
<span>False negatives</span>
<strong>{mapSelectionQaResult.false_negatives}</strong>
</div>
<div>
<span>Quality check id</span>
<strong>{latestMapSelectionQualityCheckId ?? 'not persisted'}</strong>
</div>
</div>
{mapSelectionQaResult.warnings.length > 0 ? (
<div className="map-selection-qa-warnings" aria-label="Map selection QA warnings">
<span>Map selection QA warnings</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>Feature</th>
<th>Class</th>
<th>Source id</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/a')}</td>
<td>{String(feature.properties?.['source_feature_id'] ?? 'n/a')}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="muted">No persisted vector features intersect this selection.</p>
)}
</div>
) : null}
</div>
<div className="feature-extract-surface" aria-label="Selection and feature extract">
<div className="panel-title-row">
<div>
<p className="eyebrow">Selected feature</p>
<h3>{'Selection & extract'}</h3>
</div>
<span className="count-pill">{selectedMapFeature ? 'ready' : 'waiting'}</span>
</div>
{selectedMapFeature ? (
<>
<div className="feature-extract-grid" aria-label="Selected feature geometry summary">
<div>
<span>Geometry</span>
<strong>{featureGeometrySummary.geometryType}</strong>
</div>
<div>
<span>Coordinates</span>
<strong>{featureGeometrySummary.coordinateCount}</strong>
</div>
<div>
<span>Properties</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}>
Download selected GeoJSON
</button>
<button className="secondary-action" type="button" onClick={copySelectedMapFeatureProperties}>
Copy selected properties
</button>
<button className="secondary-action" type="button" onClick={() => onSelectMapFeature(null)}>
Clear selection
</button>
</div>
{featureExtractionEntries.length > 0 ? (
<div className="table-scroll feature-property-table" aria-label="Selected feature properties table">
<table>
<thead>
<tr>
<th>Property</th>
<th>Value</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">The selected feature has geometry but no persisted properties.</p>
)}
</>
) : (
<div className="feature-extract-empty">
<strong>No feature selected</strong>
<p>Click a visible vector, detection, segmentation or change feature on the map to extract its attributes and GeoJSON.</p>
</div>
)}
</div>
<div className="feature-inspector">
<div className="panel-title-row">
<h3>Feature inspector</h3>
<span className="count-pill">{selectedMapFeature?.geometry?.type ?? 'none'}</span>
</div>
{selectedMapFeature ? (
<>
{featureSummaryEntries.length > 0 ? (
<div className="feature-summary-grid" aria-label="Selected feature summary">
{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">Click a visible map feature to inspect its properties.</p>
)}
</div>
</div>
</details>
</div>
</section>
)
}