diff --git a/CHANGELOG.md b/CHANGELOG.md index dfaf8b81..348efca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 105 Map feature extract (2026-06-25) + +- Added a `Selection & extract` panel to the Map workspace for clicked map features. +- Selected features are highlighted through a dedicated MapLibre GeoJSON source/layer. +- The extract panel now shows geometry type, coordinate count, EPSG:4326 bbox and a property table. +- Added client-side `Download selected GeoJSON`, `Copy selected properties` and `Clear selection` actions for the clicked feature. +- No backend API contracts, migrations, provider fetching, AI behavior or database persistence changed. + ## Sprint 102 Detection Lab handoff polish (2026-06-24) - Updated the raster tile manifest handoff to Detection Lab so it automatically selects `yolo-configured`. diff --git a/backend/tests/test_sprint105_map_feature_extract.py b/backend/tests/test_sprint105_map_feature_extract.py new file mode 100644 index 00000000..f83e34c3 --- /dev/null +++ b/backend/tests/test_sprint105_map_feature_extract.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_map_workspace_exposes_feature_extract_actions() -> None: + map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text( + encoding="utf-8" + ) + + assert "Selection & extract" in map_workspace + assert "downloadSelectedMapFeature" in map_workspace + assert "copySelectedMapFeatureProperties" in map_workspace + assert "selected-feature.geojson" in map_workspace + assert "Download selected GeoJSON" in map_workspace + assert "Copy selected properties" in map_workspace + assert "Clear selection" in map_workspace + assert "featureGeometrySummary" in map_workspace + assert "featureExtractionEntries" in map_workspace + + +def test_geomap_highlights_selected_feature_layer() -> None: + geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8") + app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + + assert "selectedFeature?: GeoJSON.Feature | null" in geomap + assert "selected-feature" in geomap + assert "selected-feature-fill" in geomap + assert "selected-feature-line" in geomap + assert "selected-feature-circle" in geomap + assert "selectedFeature={selectedMapFeature}" in app + + +def test_feature_extract_css_is_responsive_and_scannable() -> None: + css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8") + + assert ".feature-extract-surface" in css + assert ".feature-extract-grid" in css + assert ".feature-extract-actions" in css + assert ".feature-property-table" in css + assert ".feature-extract-empty" in css + assert "grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));" in css diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 61310db9..78b64833 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,29 @@ +## Sprint 105 Map feature extract (2026-06-25) + +Changed: +- Added a `Selection & extract` surface to `frontend/src/components/map/MapWorkspace.tsx`. +- Clicking a visible map feature now gives operators a focused extraction panel with geometry type, coordinate count, EPSG:4326 bbox, property count and property table. +- Added client-side `Download selected GeoJSON`, `Copy selected properties` and `Clear selection` actions for the clicked feature. +- Added a dedicated `selected-feature` MapLibre source with fill/line/circle highlight layers in `frontend/src/components/GeoMap.tsx`. +- Wired the selected feature highlight through `frontend/src/App.tsx`. +- Added responsive selection/extract CSS in `frontend/src/styles/app.css`. +- Updated `frontend/README.md`, `CHANGELOG.md` and `docs/TODO.md`. +- Added `backend/tests/test_sprint105_map_feature_extract.py`. + +Validation: +- RED: `python -m pytest backend\tests\test_sprint105_map_feature_extract.py -q` failed before implementation because the extract panel, selected-feature highlight layer and CSS contracts were absent. +- `python -m pytest backend\tests\test_sprint105_map_feature_extract.py backend\tests\test_sprint19_map_workbench.py backend\tests\test_sprint85_map_workspace_density.py -q` passed: 9 tests. +- `cd frontend && npm run typecheck` passed. +- `cd frontend && npm run build` passed. + +Limitations: +- This pass extracts the single currently clicked and loaded map feature only. +- Rectangle, lasso or polygon selection over persisted PostGIS `vector_features` still requires a backend spatial query endpoint and drawing workflow. +- No backend API contract, migration, provider fetching, AI dependency or persistence behavior changed. + +Next recommended pass: +- Add map area/rectangle selection backed by a PostGIS spatial-query endpoint for multi-feature extraction, then expose export handoff for the selected result set. + ## Sprint 100 raster tile Segmentation Lab handoff (2026-06-24) Changed: diff --git a/docs/TODO.md b/docs/TODO.md index d1675744..faa8064b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -75,6 +75,7 @@ This file now starts with the current implementation status. Older preparation/b - [x] Add Overview workspace panel hierarchy polish for readiness and recommended-action regions. - [x] Add Data workspace selected-summary and panel density polish. - [x] Add Map workspace panel hierarchy and layer-control density polish. +- [x] Add selected map feature extraction with highlight, property table, copy and GeoJSON download. - [x] Add QA/QC workspace result hierarchy and filter density polish. - [x] Add Change Detection panel hierarchy and analysis workspace density polish. - [x] Add AI Labs Detection/Segmentation hierarchy and result density polish. diff --git a/frontend/README.md b/frontend/README.md index 85f3f512..0299cb7c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62ae96ad..c8e172b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -785,6 +785,7 @@ function App(): JSX.Element { areaFeatureCount={areaFeatureCount} selectedMapFeature={selectedMapFeature} availableMapDatasets={availableMapDatasets} + selectedFeature={selectedMapFeature} onSelectMapArea={setSelectedMapAreaId} onOpenDatasetInMap={openDatasetInMap} onSetAreaLayerVisible={setAreaLayerVisible} diff --git a/frontend/src/components/GeoMap.tsx b/frontend/src/components/GeoMap.tsx index 11719dcd..36e5310f 100644 --- a/frontend/src/components/GeoMap.tsx +++ b/frontend/src/components/GeoMap.tsx @@ -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 { + 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
} diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 2fc3fdd2..060dcdcd 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -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 (
@@ -198,6 +317,7 @@ export function MapWorkspace({
+
+
+
+

Selected feature

+

{'Selection & extract'}

+
+ {selectedMapFeature ? 'ready' : 'waiting'} +
+ {selectedMapFeature ? ( + <> +
+
+ Geometry + {featureGeometrySummary.geometryType} +
+
+ Coordinates + {featureGeometrySummary.coordinateCount} +
+
+ Properties + {featureExtractionEntries.length} +
+
+ BBox EPSG:4326 + {featureGeometrySummary.bboxLabel} +
+
+
+ + + +
+ {featureExtractionEntries.length > 0 ? ( +
+ + + + + + + + + {featureExtractionEntries.map(([key, value]) => ( + + + + + ))} + +
PropertyValue
{key}{typeof value === 'object' ? JSON.stringify(value) : String(value)}
+
+ ) : ( +

The selected feature has geometry but no persisted properties.

+ )} + + ) : ( +
+ No feature selected +

Click a visible vector, detection, segmentation or change feature on the map to extract its attributes and GeoJSON.

+
+ )} +

Feature inspector

diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 8508b5ab..d6a69016 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; }