Add map area selection extraction
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-25 01:59:57 +02:00
parent 00de5dbcb7
commit 851d7220df
18 changed files with 1063 additions and 2 deletions
+289 -1
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
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]> = []
@@ -52,6 +54,75 @@ function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
}
}
function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const points = collection?.features.flatMap((feature) => 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 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',
@@ -116,6 +187,10 @@ interface MapWorkspaceProps {
areaFeatureCount: number
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
mapSelectionResult: VectorSelectionResponse | null
mapSelectionLoading: boolean
mapSelectionError: string | null
availableMapDatasets: DatasetCreateResponse[]
onSelectMapArea: (areaId: string) => void
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
@@ -124,6 +199,9 @@ interface MapWorkspaceProps {
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void
onClearMapSelectionExtract: () => void
}
export function MapWorkspace({
@@ -142,6 +220,10 @@ export function MapWorkspace({
areaFeatureCount,
selectedMapFeature,
selectedFeature = selectedMapFeature,
mapSelectionBbox,
mapSelectionResult,
mapSelectionLoading,
mapSelectionError,
availableMapDatasets,
onSelectMapArea,
onOpenDatasetInMap,
@@ -150,7 +232,13 @@ export function MapWorkspace({
onSetMapLayerVisible,
onSetMapLayerOpacity,
onSelectMapFeature,
onSetMapSelectionBbox,
onRunMapSelectionExtract,
onClearMapSelectionExtract,
}: MapWorkspaceProps): JSX.Element {
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
const featureProperties = selectedMapFeature?.properties ?? null
const featureSummaryEntries = featureProperties
@@ -161,11 +249,21 @@ export function MapWorkspace({
const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : []
const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature)
const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null
const selectedFeatureBbox = getFeatureBBox(selectedMapFeature)
const activeLayerBbox = getFeatureCollectionBBox(mapFeatureCollection)
const selectedAreaBbox = getFeatureCollectionBBox(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`
useEffect(() => {
setBboxInput(bboxToInputState(mapSelectionBbox))
}, [mapSelectionBbox])
const downloadSelectedMapFeature = () => {
if (!selectedFeatureGeoJson) {
return
@@ -177,6 +275,53 @@ export function MapWorkspace({
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))
}
return (
<section className="map-workspace-shell" data-testid="map-workspace">
<div className="panel-title-row">
@@ -318,15 +463,158 @@ export function MapWorkspace({
data={mapFeatureCollection}
areaData={areaFeatureCollection}
selectedFeature={selectedFeature}
selectionData={mapSelectionResult?.geojson ?? null}
selectionBbox={mapSelectionBbox}
bboxSelectionMode={bboxSelectionMode}
visible={mapLayerVisible}
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
/>
</div>
<div className="map-inspection-surface">
<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>
</div>
{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>