Add map area selection extraction
This commit is contained in:
@@ -20,6 +20,7 @@ import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
||||
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
||||
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
||||
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
||||
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
|
||||
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
||||
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
|
||||
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||
@@ -367,6 +368,19 @@ function App(): JSX.Element {
|
||||
datasetContent,
|
||||
selectedDataset,
|
||||
})
|
||||
const {
|
||||
mapSelectionBbox,
|
||||
mapSelectionResult,
|
||||
mapSelectionLoading,
|
||||
mapSelectionError,
|
||||
runMapSelectionExtract,
|
||||
resetMapSelectionExtract,
|
||||
setMapSelectionBbox,
|
||||
} = useMapSelectionExtract({
|
||||
selectedProjectId,
|
||||
selectedDataset,
|
||||
isVectorDatasetType,
|
||||
})
|
||||
const {
|
||||
loadingDemoWorkflow,
|
||||
demoWorkflowMessage,
|
||||
@@ -784,6 +798,10 @@ function App(): JSX.Element {
|
||||
mapFeatureCount={mapFeatureCount}
|
||||
areaFeatureCount={areaFeatureCount}
|
||||
selectedMapFeature={selectedMapFeature}
|
||||
mapSelectionBbox={mapSelectionBbox}
|
||||
mapSelectionResult={mapSelectionResult}
|
||||
mapSelectionLoading={mapSelectionLoading}
|
||||
mapSelectionError={mapSelectionError}
|
||||
availableMapDatasets={availableMapDatasets}
|
||||
selectedFeature={selectedMapFeature}
|
||||
onSelectMapArea={setSelectedMapAreaId}
|
||||
@@ -793,6 +811,9 @@ function App(): JSX.Element {
|
||||
onSetMapLayerVisible={setMapLayerVisible}
|
||||
onSetMapLayerOpacity={setMapLayerOpacity}
|
||||
onSelectMapFeature={setSelectedMapFeature}
|
||||
onSetMapSelectionBbox={setMapSelectionBbox}
|
||||
onRunMapSelectionExtract={runMapSelectionExtract}
|
||||
onClearMapSelectionExtract={resetMapSelectionExtract}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@ interface GeoMapProps {
|
||||
data: GeoJSON.FeatureCollection | null
|
||||
areaData?: GeoJSON.FeatureCollection | null
|
||||
selectedFeature?: GeoJSON.Feature | null
|
||||
selectionData?: GeoJSON.FeatureCollection | null
|
||||
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
|
||||
bboxSelectionMode?: boolean
|
||||
visible?: boolean
|
||||
opacity?: number
|
||||
areaVisible?: boolean
|
||||
areaOpacity?: number
|
||||
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||
onMapCoordinateSelect?: (coordinate: [number, number]) => void
|
||||
}
|
||||
|
||||
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
|
||||
@@ -57,25 +61,73 @@ function mergeFeatureCollections(collections: Array<GeoJSON.FeatureCollection |
|
||||
return features.length > 0 ? { type: 'FeatureCollection', features } : null
|
||||
}
|
||||
|
||||
function bboxToFeatureCollection(
|
||||
bbox: { min_x: number; min_y: number; max_x: number; max_y: number } | null | undefined,
|
||||
): GeoJSON.FeatureCollection {
|
||||
if (!bbox) {
|
||||
return EMPTY_FEATURE_COLLECTION
|
||||
}
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[bbox.min_x, bbox.min_y],
|
||||
[bbox.max_x, bbox.min_y],
|
||||
[bbox.max_x, bbox.max_y],
|
||||
[bbox.min_x, bbox.max_y],
|
||||
[bbox.min_x, bbox.min_y],
|
||||
],
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
layer_type: 'selection_bbox',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function GeoMap({
|
||||
data,
|
||||
areaData = null,
|
||||
selectedFeature = null,
|
||||
selectionData = null,
|
||||
selectionBbox = null,
|
||||
bboxSelectionMode = false,
|
||||
visible = true,
|
||||
opacity = 0.4,
|
||||
areaVisible = true,
|
||||
areaOpacity = 0.18,
|
||||
onFeatureSelect,
|
||||
onMapCoordinateSelect,
|
||||
}: GeoMapProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
|
||||
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
|
||||
const bboxSelectionModeRef = useRef(bboxSelectionMode)
|
||||
const [mapStyleReady, setMapStyleReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
onFeatureSelectRef.current = onFeatureSelect
|
||||
}, [onFeatureSelect])
|
||||
|
||||
useEffect(() => {
|
||||
onMapCoordinateSelectRef.current = onMapCoordinateSelect
|
||||
}, [onMapCoordinateSelect])
|
||||
|
||||
useEffect(() => {
|
||||
bboxSelectionModeRef.current = bboxSelectionMode
|
||||
if (mapRef.current) {
|
||||
mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : ''
|
||||
}
|
||||
}, [bboxSelectionMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) {
|
||||
return
|
||||
@@ -92,6 +144,10 @@ function GeoMap({
|
||||
setMapStyleReady(true)
|
||||
})
|
||||
map.on('click', (event) => {
|
||||
if (bboxSelectionModeRef.current) {
|
||||
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
|
||||
return
|
||||
}
|
||||
const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId))
|
||||
if (layers.length === 0) {
|
||||
onFeatureSelectRef.current?.(null)
|
||||
@@ -327,6 +383,86 @@ function GeoMap({
|
||||
})
|
||||
}, [selectedFeature, mapStyleReady])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
|
||||
return
|
||||
}
|
||||
|
||||
const bboxCollection = bboxToFeatureCollection(selectionBbox)
|
||||
if (map.getSource('selection-bbox')) {
|
||||
;(map.getSource('selection-bbox') as maplibregl.GeoJSONSource).setData(bboxCollection)
|
||||
} else {
|
||||
map.addSource('selection-bbox', { type: 'geojson', data: bboxCollection })
|
||||
map.addLayer({
|
||||
id: 'selection-bbox-fill',
|
||||
type: 'fill',
|
||||
source: 'selection-bbox',
|
||||
paint: {
|
||||
'fill-color': '#38bdf8',
|
||||
'fill-opacity': 0.12,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'selection-bbox-line',
|
||||
type: 'line',
|
||||
source: 'selection-bbox',
|
||||
paint: {
|
||||
'line-color': '#0369a1',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 1],
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [selectionBbox, mapStyleReady])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
|
||||
return
|
||||
}
|
||||
|
||||
const resultCollection = selectionData ?? EMPTY_FEATURE_COLLECTION
|
||||
if (map.getSource('selection-result')) {
|
||||
;(map.getSource('selection-result') as maplibregl.GeoJSONSource).setData(resultCollection)
|
||||
return
|
||||
}
|
||||
|
||||
map.addSource('selection-result', { type: 'geojson', data: resultCollection })
|
||||
map.addLayer({
|
||||
id: 'selection-result-fill',
|
||||
type: 'fill',
|
||||
source: 'selection-result',
|
||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
||||
paint: {
|
||||
'fill-color': '#7c3aed',
|
||||
'fill-opacity': 0.24,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'selection-result-line',
|
||||
type: 'line',
|
||||
source: 'selection-result',
|
||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
||||
paint: {
|
||||
'line-color': '#5b21b6',
|
||||
'line-width': 3,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'selection-result-circle',
|
||||
type: 'circle',
|
||||
source: 'selection-result',
|
||||
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
||||
paint: {
|
||||
'circle-color': '#7c3aed',
|
||||
'circle-radius': 6,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 2,
|
||||
},
|
||||
})
|
||||
}, [selectionData, mapStyleReady])
|
||||
|
||||
return <div className="map-container" ref={containerRef} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { datasetsApi } from '../services/api'
|
||||
import { formatError } from '../lib/formatError'
|
||||
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
|
||||
|
||||
interface MapSelectionExtractOptions {
|
||||
selectedProjectId: string | null
|
||||
selectedDataset: DatasetCreateResponse | null
|
||||
isVectorDatasetType: (datasetType: string) => boolean
|
||||
}
|
||||
|
||||
export function useMapSelectionExtract({
|
||||
selectedProjectId,
|
||||
selectedDataset,
|
||||
isVectorDatasetType,
|
||||
}: MapSelectionExtractOptions) {
|
||||
const [mapSelectionBbox, setMapSelectionBbox] = useState<VectorSelectionBBox | null>(null)
|
||||
const [mapSelectionResult, setMapSelectionResult] = useState<VectorSelectionResponse | null>(null)
|
||||
const [mapSelectionLoading, setMapSelectionLoading] = useState(false)
|
||||
const [mapSelectionError, setMapSelectionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMapSelectionBbox(null)
|
||||
setMapSelectionResult(null)
|
||||
setMapSelectionError(null)
|
||||
}, [selectedProjectId, selectedDataset?.id])
|
||||
|
||||
const runMapSelectionExtract = async (bbox: VectorSelectionBBox) => {
|
||||
if (!selectedProjectId || !selectedDataset) {
|
||||
setMapSelectionError('Open a vector dataset before extracting a map area.')
|
||||
return
|
||||
}
|
||||
if (!isVectorDatasetType(selectedDataset.dataset_type)) {
|
||||
setMapSelectionError('Area extraction requires an active vector dataset.')
|
||||
return
|
||||
}
|
||||
|
||||
setMapSelectionLoading(true)
|
||||
setMapSelectionError(null)
|
||||
setMapSelectionBbox(bbox)
|
||||
try {
|
||||
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
|
||||
bbox: { ...bbox, crs: 'EPSG:4326' },
|
||||
limit: 250,
|
||||
})
|
||||
setMapSelectionResult(response)
|
||||
} catch (error) {
|
||||
setMapSelectionResult(null)
|
||||
setMapSelectionError(formatError(error, 'Area extraction failed'))
|
||||
} finally {
|
||||
setMapSelectionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetMapSelectionExtract = () => {
|
||||
setMapSelectionBbox(null)
|
||||
setMapSelectionResult(null)
|
||||
setMapSelectionError(null)
|
||||
}
|
||||
|
||||
return {
|
||||
mapSelectionBbox,
|
||||
mapSelectionResult,
|
||||
mapSelectionLoading,
|
||||
mapSelectionError,
|
||||
runMapSelectionExtract,
|
||||
resetMapSelectionExtract,
|
||||
setMapSelectionBbox,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
JobRead,
|
||||
VectorBBoxResponse,
|
||||
VectorStatsResponse,
|
||||
VectorSelectionRequest,
|
||||
VectorSelectionResponse,
|
||||
VectorSummary,
|
||||
RasterNdviRequest,
|
||||
RasterNdwiRequest,
|
||||
@@ -70,6 +72,8 @@ export const datasetsApi = {
|
||||
apiGet<VectorSummary>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/summary`),
|
||||
vectorStats: (projectId: string, datasetId: string): Promise<VectorStatsResponse> =>
|
||||
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
|
||||
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
|
||||
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload),
|
||||
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
|
||||
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/clip`, payload),
|
||||
vectorBuffer: (projectId: string, datasetId: string, payload: { distance_m: number; dissolve?: boolean; output_name?: string }) =>
|
||||
|
||||
@@ -2599,6 +2599,76 @@ button.entity-card {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.bbox-select-surface {
|
||||
display: grid;
|
||||
gap: 0.72rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0.85rem;
|
||||
border: 1px solid rgba(3, 105, 161, 0.24);
|
||||
border-left: 4px solid #0369a1;
|
||||
border-radius: 8px;
|
||||
padding: 0.72rem;
|
||||
background: linear-gradient(180deg, #ffffff, #f6fbff);
|
||||
}
|
||||
|
||||
.bbox-select-surface .panel-title-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bbox-select-status {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0.58rem 0.66rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.bbox-select-status span {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.bbox-select-status strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bbox-select-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bbox-select-grid label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bbox-select-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bbox-select-actions button {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.bbox-selection-result {
|
||||
display: grid;
|
||||
gap: 0.64rem;
|
||||
min-width: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.68rem;
|
||||
}
|
||||
|
||||
.feature-extract-surface {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
|
||||
@@ -274,6 +274,27 @@ export interface VectorStatsResponse {
|
||||
crs?: string | null
|
||||
}
|
||||
|
||||
export interface VectorSelectionBBox {
|
||||
min_x: number
|
||||
min_y: number
|
||||
max_x: number
|
||||
max_y: number
|
||||
crs?: 'EPSG:4326'
|
||||
}
|
||||
|
||||
export interface VectorSelectionRequest {
|
||||
bbox: VectorSelectionBBox
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface VectorSelectionResponse {
|
||||
selection_bbox: VectorSelectionBBox
|
||||
feature_count: number
|
||||
limit: number
|
||||
truncated: boolean
|
||||
geojson: GeoJSON.FeatureCollection
|
||||
}
|
||||
|
||||
export interface DatasetListResponse {
|
||||
items: DatasetCreateResponse[]
|
||||
total: number
|
||||
|
||||
Reference in New Issue
Block a user