Add map feature extraction workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user