1259 lines
50 KiB
TypeScript
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>
|
|
)
|
|
}
|