Add map feature extraction workflow
This commit is contained in:
@@ -10,6 +10,8 @@ Data and Map workspaces include mobile-density CSS for file inputs, dataset acti
|
||||
|
||||
Map workspace now surfaces the selected AOI, active layer and rendered feature state before controls, then separates layer controls, provenance, the MapLibre frame and the feature inspector into focused surfaces. The underlying overlay, opacity and feature-selection behavior is unchanged.
|
||||
|
||||
Map workspace selection now also includes a `Selection & extract` panel. Clicking a rendered vector, detection, segmentation or change feature highlights it on the map, summarizes geometry type, coordinate count and EPSG:4326 bbox, shows properties as a table, and offers client-side selected-feature GeoJSON download plus property copy actions. This extracts only the currently loaded/clicked feature; rectangle or polygon spatial extraction against PostGIS remains a later backend query workflow.
|
||||
|
||||
QA/QC Results now separates persisted check summary, candidate/reference evidence, refresh/filter controls and result history into focused surfaces. Existing filters, refresh behavior, metric cards and stored result rendering are unchanged, with denser mobile grids for the same controls.
|
||||
|
||||
Change Detection now follows the same analysis workspace hierarchy: vector input controls, error/empty states, result metrics and warnings are separated into focused surfaces while the existing compare action and map overlay result flow remain unchanged.
|
||||
|
||||
@@ -785,6 +785,7 @@ function App(): JSX.Element {
|
||||
areaFeatureCount={areaFeatureCount}
|
||||
selectedMapFeature={selectedMapFeature}
|
||||
availableMapDatasets={availableMapDatasets}
|
||||
selectedFeature={selectedMapFeature}
|
||||
onSelectMapArea={setSelectedMapAreaId}
|
||||
onOpenDatasetInMap={openDatasetInMap}
|
||||
onSetAreaLayerVisible={setAreaLayerVisible}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
interface GeoMapProps {
|
||||
data: GeoJSON.FeatureCollection | null
|
||||
areaData?: GeoJSON.FeatureCollection | null
|
||||
selectedFeature?: GeoJSON.Feature | null
|
||||
visible?: boolean
|
||||
opacity?: number
|
||||
areaVisible?: boolean
|
||||
@@ -12,6 +13,11 @@ interface GeoMapProps {
|
||||
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||
}
|
||||
|
||||
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
}
|
||||
|
||||
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
|
||||
const coordinates: [number, number][] = []
|
||||
const walk = (coords: unknown) => {
|
||||
@@ -54,6 +60,7 @@ function mergeFeatureCollections(collections: Array<GeoJSON.FeatureCollection |
|
||||
function GeoMap({
|
||||
data,
|
||||
areaData = null,
|
||||
selectedFeature = null,
|
||||
visible = true,
|
||||
opacity = 0.4,
|
||||
areaVisible = true,
|
||||
@@ -270,6 +277,56 @@ function GeoMap({
|
||||
}
|
||||
}, [areaVisible, areaOpacity, areaData, mapStyleReady])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedCollection: GeoJSON.FeatureCollection = selectedFeature
|
||||
? { type: 'FeatureCollection', features: [selectedFeature] }
|
||||
: EMPTY_FEATURE_COLLECTION
|
||||
|
||||
if (map.getSource('selected-feature')) {
|
||||
;(map.getSource('selected-feature') as maplibregl.GeoJSONSource).setData(selectedCollection)
|
||||
return
|
||||
}
|
||||
|
||||
map.addSource('selected-feature', { type: 'geojson', data: selectedCollection })
|
||||
map.addLayer({
|
||||
id: 'selected-feature-fill',
|
||||
type: 'fill',
|
||||
source: 'selected-feature',
|
||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
||||
paint: {
|
||||
'fill-color': '#fde047',
|
||||
'fill-opacity': 0.32,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'selected-feature-line',
|
||||
type: 'line',
|
||||
source: 'selected-feature',
|
||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
||||
paint: {
|
||||
'line-color': '#854d0e',
|
||||
'line-width': 4,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'selected-feature-circle',
|
||||
type: 'circle',
|
||||
source: 'selected-feature',
|
||||
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
||||
paint: {
|
||||
'circle-color': '#fde047',
|
||||
'circle-radius': 7,
|
||||
'circle-stroke-color': '#854d0e',
|
||||
'circle-stroke-width': 2,
|
||||
},
|
||||
})
|
||||
}, [selectedFeature, mapStyleReady])
|
||||
|
||||
return <div className="map-container" ref={containerRef} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,105 @@
|
||||
import GeoMap from '../GeoMap'
|
||||
import type { AreaRead, DatasetCreateResponse } from '../../types'
|
||||
|
||||
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.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 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
|
||||
@@ -16,6 +115,7 @@ interface MapWorkspaceProps {
|
||||
mapFeatureCount: number
|
||||
areaFeatureCount: number
|
||||
selectedMapFeature: GeoJSON.Feature | null
|
||||
selectedFeature?: GeoJSON.Feature | null
|
||||
availableMapDatasets: DatasetCreateResponse[]
|
||||
onSelectMapArea: (areaId: string) => void
|
||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||
@@ -41,6 +141,7 @@ export function MapWorkspace({
|
||||
mapFeatureCount,
|
||||
areaFeatureCount,
|
||||
selectedMapFeature,
|
||||
selectedFeature = selectedMapFeature,
|
||||
availableMapDatasets,
|
||||
onSelectMapArea,
|
||||
onOpenDatasetInMap,
|
||||
@@ -57,6 +158,24 @@ export function MapWorkspace({
|
||||
.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 selectedFeatureStem = safeFileStem(
|
||||
featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature',
|
||||
)
|
||||
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
|
||||
|
||||
const downloadSelectedMapFeature = () => {
|
||||
if (!selectedFeatureGeoJson) {
|
||||
return
|
||||
}
|
||||
downloadJsonFile(selectedFeatureFilename, selectedFeatureGeoJson)
|
||||
}
|
||||
|
||||
const copySelectedMapFeatureProperties = () => {
|
||||
copyText(JSON.stringify(featureProperties ?? {}, null, 2))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
||||
@@ -198,6 +317,7 @@ export function MapWorkspace({
|
||||
<GeoMap
|
||||
data={mapFeatureCollection}
|
||||
areaData={areaFeatureCollection}
|
||||
selectedFeature={selectedFeature}
|
||||
visible={mapLayerVisible}
|
||||
opacity={mapLayerOpacity}
|
||||
areaVisible={areaLayerVisible}
|
||||
@@ -207,6 +327,75 @@ export function MapWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="map-inspection-surface">
|
||||
<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>
|
||||
|
||||
@@ -2599,6 +2599,92 @@ button.entity-card {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-extract-surface {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
margin-bottom: 0.85rem;
|
||||
border: 1px solid rgba(15, 118, 110, 0.24);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 0.72rem;
|
||||
background: linear-gradient(180deg, #ffffff, #f7fbf8);
|
||||
}
|
||||
|
||||
.feature-extract-surface .panel-title-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-extract-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-extract-grid > div {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0.55rem 0.62rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.feature-extract-grid span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.feature-extract-grid strong {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.feature-extract-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.feature-extract-actions button {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.feature-property-table {
|
||||
max-height: 18rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.feature-property-table table {
|
||||
min-width: 32rem;
|
||||
}
|
||||
|
||||
.feature-extract-empty {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 8px;
|
||||
padding: 0.68rem 0.72rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.feature-extract-empty p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.lab-block + .lab-block {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user