Improve map overlay ergonomics
This commit is contained in:
@@ -7,6 +7,15 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 63 Map overlay ergonomics (2026-06-18)
|
||||
|
||||
- Added an active layer provenance rail to the Map workspace, showing layer source, provenance and draw state from existing frontend state.
|
||||
- Added clear empty guidance when no vector/result layer is active on the map.
|
||||
- Added scan-friendly selected-feature property chips before the raw JSON inspector.
|
||||
- Tightened panel title alignment after the visual polish pass exposed a generic CSS selector specificity issue.
|
||||
- Added static regression coverage for the map provenance and feature-summary UI contracts.
|
||||
- No API contracts, migrations, backend behavior, provider fetching or AI model behavior changed.
|
||||
|
||||
## Sprint 62 Workbench visual polish (2026-06-18)
|
||||
|
||||
- Added a compact workspace command bar for fast switching between the primary workbench surfaces.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_map_workspace_exposes_layer_provenance_and_feature_summary() -> None:
|
||||
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "mapLayerSourceLabel" in app
|
||||
assert "mapLayerProvenance" in app
|
||||
assert "layer-provenance-rail" in map_workspace
|
||||
assert "feature-summary-grid" in map_workspace
|
||||
assert "feature-property-chip" in map_workspace
|
||||
assert ".layer-provenance-rail" in css
|
||||
assert ".feature-summary-grid" in css
|
||||
assert ".feature-property-chip" in css
|
||||
|
||||
|
||||
def test_map_workspace_has_clear_empty_result_layer_guidance() -> None:
|
||||
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "No active vector or result layer" in map_workspace
|
||||
assert "Open a dataset, detection run, segmentation run or change result to draw it here." in map_workspace
|
||||
@@ -2434,3 +2434,29 @@ Limitations:
|
||||
|
||||
Next recommended pass:
|
||||
- Continue with map/result overlay ergonomics, especially making selected dataset/result provenance easier to see beside the map.
|
||||
|
||||
## Sprint 63 Map overlay ergonomics (2026-06-18)
|
||||
|
||||
Changed:
|
||||
- Added active map layer source/provenance/draw-state context in the Map workspace using existing selected dataset, detection, segmentation and change-detection frontend state.
|
||||
- Added a clear map empty-state when no vector/result layer is active.
|
||||
- Added selected-feature property chips before the raw JSON feature inspector so common properties are scan-friendly.
|
||||
- Tightened panel-title alignment with a scoped CSS override after the broader visual polish exposed a specificity issue in the existing `section > div:not(...)` rule.
|
||||
- Added `backend/tests/test_sprint63_map_overlay_ergonomics.py`.
|
||||
- Updated `frontend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
|
||||
|
||||
Tested:
|
||||
- Red step: `cd backend && python -m pytest tests/test_sprint63_map_overlay_ergonomics.py -q` failed on missing map provenance/feature-summary UI.
|
||||
- `cd backend && python -m pytest tests/test_sprint63_map_overlay_ergonomics.py tests/test_sprint30_workbench_components.py tests/test_sprint53_selection_ergonomics.py -q` (`11 passed`)
|
||||
- `cd frontend && npm run typecheck`
|
||||
- `cd frontend && npm run build`
|
||||
- Local Chrome/Playwright check against `http://127.0.0.1:5175` with live API proxy: Map workspace opened, provenance rail present, no console warnings/errors, no horizontal page overflow.
|
||||
|
||||
Open:
|
||||
- Run full readiness and deploy Tower after this pass.
|
||||
|
||||
Limitations:
|
||||
- This is a frontend ergonomics pass only. It does not add new map layers, backend endpoints, provider fetching or geospatial processing.
|
||||
|
||||
Next recommended pass:
|
||||
- Continue with export/report handoff polish or add a live browser smoke that explicitly visits every workspace after deployment.
|
||||
|
||||
@@ -62,6 +62,7 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Decide next V1 stabilization focus: golden dataset expansion or frontend visual polish backlog.
|
||||
- [x] Expand golden QA/QC benchmark coverage across partial, perfect, no-overlap and MultiPolygon scenarios.
|
||||
- [x] Improve workbench shell visual polish, mobile navigation density and AI result readability.
|
||||
- [x] Improve Map workspace layer provenance, empty guidance and selected-feature summary readability.
|
||||
|
||||
## Sprint 8 status
|
||||
|
||||
@@ -334,3 +335,4 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Add backend error-envelope audit for expected user-error paths.
|
||||
- [x] Expand golden datasets beyond the original single building QA fixture pair.
|
||||
- [x] Add workbench visual polish pass for command bar, panel surfaces, empty states and mobile nav density.
|
||||
- [x] Add map/result overlay ergonomics for active layer provenance and feature property summaries.
|
||||
|
||||
@@ -232,6 +232,7 @@ The workbench now uses a task-based shell instead of a single long panel stack.
|
||||
- Existing API calls, hooks, MapLibre rendering and QA/AI/export flows are unchanged.
|
||||
- Stable navigation test anchors use `data-testid="workspace-nav-{workspace}"`.
|
||||
- Mobile workbench navigation uses horizontal rails for the primary nav and command bar, avoiding a tall menu stack before the active workspace content.
|
||||
- The Map workspace shows active layer source/provenance/draw-state context and selected-feature property chips before the raw JSON inspector.
|
||||
|
||||
## Raster dependency visibility
|
||||
|
||||
|
||||
@@ -389,6 +389,46 @@ function App(): JSX.Element {
|
||||
|
||||
const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null
|
||||
const activeWorkspaceItem = workspaceNavItems.find((item) => item.key === activeWorkspace) ?? workspaceNavItems[0]
|
||||
const mapLayerSourceLabel = useMemo(() => {
|
||||
if (changeDetectionResult?.geojson) {
|
||||
return 'Change detection'
|
||||
}
|
||||
if (segmentationGeoJson) {
|
||||
return 'Segmentation run'
|
||||
}
|
||||
if (detectionGeoJson) {
|
||||
return 'Detection run'
|
||||
}
|
||||
if (datasetContent && selectedDataset) {
|
||||
return `${selectedDataset.dataset_type} dataset`
|
||||
}
|
||||
return 'No active vector or result layer'
|
||||
}, [changeDetectionResult?.geojson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
|
||||
const mapLayerProvenance = useMemo(() => {
|
||||
if (changeDetectionResult?.geojson) {
|
||||
return `source ${changeSourceDatasetId || 'n/a'} -> target ${changeTargetDatasetId || 'n/a'}`
|
||||
}
|
||||
if (segmentationGeoJson) {
|
||||
return selectedSegmentationRunId ? `analysis run ${selectedSegmentationRunId}` : 'segmentation results loaded'
|
||||
}
|
||||
if (detectionGeoJson) {
|
||||
return selectedDetectionRunId ? `analysis run ${selectedDetectionRunId}` : 'detection results loaded'
|
||||
}
|
||||
if (datasetContent && selectedDataset) {
|
||||
return `${selectedDataset.dataset_role ?? 'source'} / ${selectedDataset.source_name ?? selectedDataset.source}`
|
||||
}
|
||||
return 'Open a dataset, detection run, segmentation run or change result to draw it here.'
|
||||
}, [
|
||||
changeDetectionResult?.geojson,
|
||||
changeSourceDatasetId,
|
||||
changeTargetDatasetId,
|
||||
datasetContent,
|
||||
detectionGeoJson,
|
||||
segmentationGeoJson,
|
||||
selectedDataset,
|
||||
selectedDetectionRunId,
|
||||
selectedSegmentationRunId,
|
||||
])
|
||||
const openDatasetInMap = (dataset: DatasetCreateResponse) => {
|
||||
if (selectedProjectId) {
|
||||
loadDatasetDetails(selectedProjectId, dataset)
|
||||
@@ -565,6 +605,8 @@ function App(): JSX.Element {
|
||||
areaFeatureCollection={areaFeatureCollection}
|
||||
mapFeatureCollection={mapFeatureCollection}
|
||||
mapLayerLabel={mapLayerLabel}
|
||||
mapLayerSourceLabel={mapLayerSourceLabel}
|
||||
mapLayerProvenance={mapLayerProvenance}
|
||||
mapLayerVisible={mapLayerVisible}
|
||||
mapLayerOpacity={mapLayerOpacity}
|
||||
areaLayerVisible={areaLayerVisible}
|
||||
|
||||
@@ -7,6 +7,8 @@ interface MapWorkspaceProps {
|
||||
areaFeatureCollection: GeoJSON.FeatureCollection | null
|
||||
mapFeatureCollection: GeoJSON.FeatureCollection | null
|
||||
mapLayerLabel: string
|
||||
mapLayerSourceLabel: string
|
||||
mapLayerProvenance: string
|
||||
mapLayerVisible: boolean
|
||||
mapLayerOpacity: number
|
||||
areaLayerVisible: boolean
|
||||
@@ -28,6 +30,8 @@ export function MapWorkspace({
|
||||
areaFeatureCollection,
|
||||
mapFeatureCollection,
|
||||
mapLayerLabel,
|
||||
mapLayerSourceLabel,
|
||||
mapLayerProvenance,
|
||||
mapLayerVisible,
|
||||
mapLayerOpacity,
|
||||
areaLayerVisible,
|
||||
@@ -42,6 +46,13 @@ export function MapWorkspace({
|
||||
onSetMapLayerOpacity,
|
||||
onSelectMapFeature,
|
||||
}: MapWorkspaceProps): JSX.Element {
|
||||
const featureProperties = selectedMapFeature?.properties ?? null
|
||||
const featureSummaryEntries = featureProperties
|
||||
? Object.entries(featureProperties)
|
||||
.filter(([, value]) => value !== null && value !== undefined && typeof value !== 'object')
|
||||
.slice(0, 6)
|
||||
: []
|
||||
|
||||
return (
|
||||
<section data-testid="map-workspace">
|
||||
<div className="panel-title-row">
|
||||
@@ -121,6 +132,26 @@ export function MapWorkspace({
|
||||
<span>{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector/result layer loaded'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="layer-provenance-rail" aria-label="Active map layer provenance">
|
||||
<div>
|
||||
<span>Layer source</span>
|
||||
<strong>{mapLayerSourceLabel}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Provenance</span>
|
||||
<strong>{mapLayerProvenance}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Draw state</span>
|
||||
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active vector or result layer'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{!mapFeatureCollection ? (
|
||||
<div className="empty-state map-empty-state">
|
||||
<strong>No active vector or result layer</strong>
|
||||
<p>Open a dataset, detection run, segmentation run or change result to draw it here.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<GeoMap
|
||||
data={mapFeatureCollection}
|
||||
areaData={areaFeatureCollection}
|
||||
@@ -137,6 +168,16 @@ export function MapWorkspace({
|
||||
</div>
|
||||
{selectedMapFeature ? (
|
||||
<>
|
||||
{featureSummaryEntries.length > 0 ? (
|
||||
<div className="feature-summary-grid" aria-label="Selected feature summary">
|
||||
{featureSummaryEntries.map(([key, value]) => (
|
||||
<div className="feature-property-chip" key={key}>
|
||||
<span>{key}</span>
|
||||
<strong>{String(value)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -797,6 +797,14 @@ section > div:not(.map-controls):not(.feature-inspector):not(.quick-action-grid)
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.panel-title-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
section > .panel-title-row {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
section button + button {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
@@ -1178,6 +1186,10 @@ button.entity-card {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.map-empty-state {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.export-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1287,6 +1299,58 @@ button.entity-card {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.layer-provenance-rail {
|
||||
display: grid;
|
||||
grid-template-columns: 0.85fr minmax(0, 1.5fr) 0.85fr;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.layer-provenance-rail > div,
|
||||
.feature-property-chip {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0.58rem 0.68rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.layer-provenance-rail span,
|
||||
.feature-property-chip span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.layer-provenance-rail strong,
|
||||
.feature-property-chip strong {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
overflow: hidden;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.layer-provenance-rail strong {
|
||||
display: -webkit-box;
|
||||
min-height: 2.1rem;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.feature-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.lab-block + .lab-block {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
@@ -1508,6 +1572,7 @@ button.entity-card {
|
||||
|
||||
.dataset-upload-form,
|
||||
.map-toolbar,
|
||||
.layer-provenance-rail,
|
||||
.lab-form-grid,
|
||||
.quality-summary-grid,
|
||||
.quality-score-row,
|
||||
|
||||
Reference in New Issue
Block a user