Files
geointel/frontend/src/components/map/MapWorkspace.tsx
T
Codex 1ba479b50b
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: stream municipality vectors by viewport
2026-07-14 10:47:23 +02:00

1259 lines
50 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
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 {
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
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
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
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => 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
onClearQualityEvidence?: () => void
}
export function MapWorkspace({
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,
selectedMapFeature,
selectedFeature = selectedMapFeature,
mapSelectionBbox,
mapSelectionResult,
mapSelectionLoading,
mapSelectionError,
selectionExporting,
selectionExportError,
latestSelectionExportPath,
selectionDatasetSaving,
selectionDatasetError,
latestSelectionDataset,
latestSelectionDatasetName,
mapQaReferenceDatasets,
selectedMapQaReferenceDatasetId,
mapSelectionQaRunning,
mapSelectionQaError,
mapSelectionQaResult,
latestMapSelectionQualityCheckId,
availableMapDatasets,
selectedMapDatasetId,
onSelectMapArea,
onOpenDatasetInMap,
onSetAreaLayerVisible,
onSetAreaLayerOpacity,
onSetMapLayerVisible,
onSetMapLayerOpacity,
onSelectMapFeature,
onMapViewportChange,
onSetMapSelectionBbox,
onRunMapSelectionExtract,
onClearMapSelectionExtract,
onExportMapSelection,
onDeriveMapSelectionDataset,
onSelectMapQaReferenceDataset,
onRunMapSelectionQa,
onOpenMapSelectionQualityEvidence,
onClearQualityEvidence,
}: MapWorkspaceProps): JSX.Element {
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
useEffect(() => {
setBboxInput(bboxToInputState(mapSelectionBbox))
}, [mapSelectionBbox])
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)
setBboxSelectionMode(true)
}
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
if (!firstSelectionCorner) {
setFirstSelectionCorner(coordinate)
return
}
const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate)
setSelectionBbox(bbox)
setFirstSelectionCorner(null)
setBboxSelectionMode(false)
}
const runAreaExtract = () => {
const bbox = parseBboxInput(bboxInput)
if (!bbox) {
return
}
onRunMapSelectionExtract(bbox)
}
const clearAreaSelection = () => {
setBboxSelectionMode(false)
setFirstSelectionCorner(null)
setBboxInput(bboxToInputState(null))
onClearMapSelectionExtract()
}
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 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 runQuickAoiExtract = () => {
const bbox = selectedAreaBbox ?? activeLayerBbox
if (!bbox) {
return
}
setSelectionBbox(bbox)
onRunMapSelectionExtract(bbox)
}
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)
}
}
return (
<section className="map-workspace-shell" data-testid="map-workspace">
<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">
<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
value={selectedMapAreaId}
onChange={(event) => onSelectMapArea(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>
)
}