Add map feature extraction workflow
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:05:51 +02:00
parent 0f490832cc
commit 561304c7f1
9 changed files with 415 additions and 0 deletions
@@ -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>