Add map feature extraction workflow
This commit is contained in:
@@ -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