2158 lines
91 KiB
TypeScript
2158 lines
91 KiB
TypeScript
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 thema’s</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>
|
||
)
|
||
}
|