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
View File
@@ -785,6 +785,7 @@ function App(): JSX.Element {
areaFeatureCount={areaFeatureCount}
selectedMapFeature={selectedMapFeature}
availableMapDatasets={availableMapDatasets}
selectedFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId}
onOpenDatasetInMap={openDatasetInMap}
onSetAreaLayerVisible={setAreaLayerVisible}
+57
View File
@@ -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>
+86
View File
@@ -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;
}