Add V1 map workbench controls
This commit is contained in:
@@ -7,6 +7,14 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 19 V1 map workbench controls (2026-06-17)
|
||||
|
||||
- Added MapLibre layer visibility and opacity controls for the active GeoJSON workbench layer.
|
||||
- Added click-to-inspect feature properties for the active map layer.
|
||||
- Updated the visible app identity from the stale Sprint 9 label to GeoIntel Kempen V1 Workbench.
|
||||
- Added regression tests that lock the map control and feature inspection wiring.
|
||||
- No API contracts, migrations, backend behavior, provider fetching, AI inference or new dependencies were introduced.
|
||||
|
||||
## Sprint 18 vector change detection foundation (2026-06-16)
|
||||
|
||||
- Added `POST /api/v1/analysis/change-detection` for synchronous comparison of two vector datasets in the same project.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_geomap_exposes_v1_layer_controls_and_feature_inspection_contract() -> None:
|
||||
geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "visible?: boolean" in geomap
|
||||
assert "opacity?: number" in geomap
|
||||
assert "onFeatureSelect?: (feature: GeoJSON.Feature | null) => void" in geomap
|
||||
assert "queryRenderedFeatures" in geomap
|
||||
assert "setLayoutProperty('dataset-fill', 'visibility'" in geomap
|
||||
assert "setPaintProperty('dataset-fill', 'fill-opacity', opacity)" in geomap
|
||||
|
||||
|
||||
def test_app_wires_map_workbench_controls_and_property_inspector() -> None:
|
||||
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "mapLayerVisible" in app
|
||||
assert "mapLayerOpacity" in app
|
||||
assert "selectedMapFeature" in app
|
||||
assert "Layer visible" in app
|
||||
assert "Layer opacity" in app
|
||||
assert "Feature inspector" in app
|
||||
assert "onFeatureSelect={setSelectedMapFeature}" in app
|
||||
@@ -1,3 +1,24 @@
|
||||
## Sprint 19 V1 map workbench controls (2026-06-17)
|
||||
|
||||
Changed:
|
||||
- Added active MapLibre layer visibility and opacity controls.
|
||||
- Added click-to-inspect feature property display for the active GeoJSON workbench layer.
|
||||
- Added active layer label and feature count to the Map workspace panel.
|
||||
- Updated the app header from the stale Sprint 9 label to the GeoIntel Kempen V1 Workbench identity.
|
||||
- Added regression tests for the frontend map control and feature inspection wiring.
|
||||
|
||||
Limitations:
|
||||
- The current workbench still shows one active GeoJSON overlay at a time; multi-layer stack ordering remains a later UI enhancement.
|
||||
- Raster preview display still remains metadata/path-oriented unless the backend exposes a browser-safe raster image/tile URL.
|
||||
- No API contracts, migrations, backend behavior, provider fetching, AI inference or new dependencies were introduced.
|
||||
|
||||
Validation planned:
|
||||
- `python -m compileall backend/app`
|
||||
- `cd backend && python -m pytest -W error::DeprecationWarning`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- `cd frontend && npm run build`
|
||||
- `bash scripts/run_readiness_check.sh` via Git Bash on Windows
|
||||
- Tower redeploy through `scripts/deploy_tower.ps1`
|
||||
## Sprint 18 vector change detection foundation (2026-06-16)
|
||||
|
||||
Changed:
|
||||
|
||||
@@ -15,6 +15,7 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
|
||||
- [x] Backend FastAPI foundation, health endpoint and service structure.
|
||||
- [x] React/TypeScript frontend foundation and MapLibre workbench.
|
||||
- [x] Map layer visibility, opacity and feature property inspection.
|
||||
- [x] SQLAlchemy/PostGIS ORM models and Alembic migration chain through Sprint 9.
|
||||
- [x] Dataset upload, storage metadata and vector feature persistence.
|
||||
- [x] Raster metadata and raster operation service boundaries.
|
||||
@@ -86,6 +87,8 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] API client
|
||||
- [x] Project pages
|
||||
- [x] Map Workbench basis
|
||||
- [x] Map layer controls
|
||||
- [x] Feature property inspector
|
||||
|
||||
## 4. Project & Area API
|
||||
|
||||
|
||||
@@ -127,6 +127,23 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
||||
- The UI does not introduce live provider downloads, a report designer or new AI behavior.
|
||||
- The HTML report is a lightweight artifact built from persisted project, dataset, QA/QC summary and export history state; it is not a PDF/report designer.
|
||||
|
||||
## Sprint 18 additions
|
||||
|
||||
- Added a Change Detection panel for comparing two vector datasets in the same project.
|
||||
- Change Detection uses the backend job envelope and renders added/removed/unchanged GeoJSON on the existing MapLibre workbench map.
|
||||
- The UI exposes IoU threshold and unchanged-feature inclusion controls.
|
||||
- The frontend does not infer fake object lifecycle states; it displays only the backend-provided added/removed/unchanged result.
|
||||
|
||||
## Sprint 19 additions
|
||||
|
||||
- Added V1 Map Workbench controls for the active GeoJSON layer:
|
||||
- visibility toggle
|
||||
- opacity slider
|
||||
- active layer label
|
||||
- loaded feature count
|
||||
- Added click-to-inspect feature properties from the active MapLibre layer.
|
||||
- Updated the app header to the V1 workbench identity instead of an old sprint label.
|
||||
|
||||
## Release hardening updates
|
||||
|
||||
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
||||
|
||||
+69
-4
@@ -94,6 +94,9 @@ function App(): JSX.Element {
|
||||
const [selectedRasterMetadata, setSelectedRasterMetadata] = useState<RasterMetadataResponse | null>(null)
|
||||
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
|
||||
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
|
||||
const [mapLayerVisible, setMapLayerVisible] = useState(true)
|
||||
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
|
||||
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
||||
const [jobs, setJobs] = useState<JobRead[]>([])
|
||||
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
||||
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
|
||||
@@ -244,6 +247,22 @@ function App(): JSX.Element {
|
||||
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
|
||||
[changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent],
|
||||
)
|
||||
const mapLayerLabel = useMemo(() => {
|
||||
if (changeDetectionResult) {
|
||||
return 'Change detection result'
|
||||
}
|
||||
if (segmentationGeoJson) {
|
||||
return 'Segmentation result'
|
||||
}
|
||||
if (detectionGeoJson) {
|
||||
return 'Detection result'
|
||||
}
|
||||
if (datasetContent && selectedDataset) {
|
||||
return selectedDataset.name
|
||||
}
|
||||
return 'No active vector layer'
|
||||
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
|
||||
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
|
||||
const isRasterTileInputValid = useMemo(
|
||||
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
||||
[rasterTileSize, rasterTileOverlap],
|
||||
@@ -624,6 +643,10 @@ function App(): JSX.Element {
|
||||
loadSegmentationModels().catch(() => null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMapFeature(null)
|
||||
}, [mapFeatureCollection])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
setAreas([])
|
||||
@@ -1300,8 +1323,8 @@ function App(): JSX.Element {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header>
|
||||
<h1>GeoIntel Kempen Sprint 9</h1>
|
||||
<p>Sprint 9: raster/vector workbench with detection and segmentation foundations.</p>
|
||||
<h1>GeoIntel Kempen V1 Workbench</h1>
|
||||
<p>Raster/vector workbench with QA/QC, exports, change detection and AI foundation layers.</p>
|
||||
</header>
|
||||
|
||||
{errorMessage ? <p className="error">{errorMessage}</p> : null}
|
||||
@@ -1826,8 +1849,50 @@ function App(): JSX.Element {
|
||||
|
||||
<section>
|
||||
<h2>Map workspace</h2>
|
||||
<p>{selectedDatasetId ? `Showing dataset ${selectedDatasetId}` : 'No vector dataset selected'}</p>
|
||||
<GeoMap data={mapFeatureCollection} />
|
||||
<p>{mapLayerLabel}</p>
|
||||
<div className="map-controls">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
checked={mapLayerVisible}
|
||||
disabled={!mapFeatureCollection}
|
||||
type="checkbox"
|
||||
onChange={(event) => setMapLayerVisible(event.target.checked)}
|
||||
/>
|
||||
Layer visible
|
||||
</label>
|
||||
<label>
|
||||
Layer opacity
|
||||
<input
|
||||
disabled={!mapFeatureCollection}
|
||||
max="1"
|
||||
min="0.05"
|
||||
step="0.05"
|
||||
type="range"
|
||||
value={mapLayerOpacity}
|
||||
onChange={(event) => setMapLayerOpacity(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div className="map-status">
|
||||
{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'}
|
||||
</div>
|
||||
</div>
|
||||
<GeoMap
|
||||
data={mapFeatureCollection}
|
||||
visible={mapLayerVisible}
|
||||
opacity={mapLayerOpacity}
|
||||
onFeatureSelect={setSelectedMapFeature}
|
||||
/>
|
||||
<div className="feature-inspector">
|
||||
<h3>Feature inspector</h3>
|
||||
{selectedMapFeature ? (
|
||||
<>
|
||||
<p>Geometry: {selectedMapFeature.geometry?.type ?? 'n/a'}</p>
|
||||
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Click a visible map feature to inspect its properties.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
interface GeoMapProps {
|
||||
data: GeoJSON.FeatureCollection | null
|
||||
visible?: boolean
|
||||
opacity?: number
|
||||
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||
}
|
||||
|
||||
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
|
||||
@@ -40,9 +43,14 @@ function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): mapli
|
||||
]
|
||||
}
|
||||
|
||||
function GeoMap({ data }: GeoMapProps): JSX.Element {
|
||||
function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMapProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
|
||||
|
||||
useEffect(() => {
|
||||
onFeatureSelectRef.current = onFeatureSelect
|
||||
}, [onFeatureSelect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) {
|
||||
@@ -56,6 +64,21 @@ function GeoMap({ data }: GeoMapProps): JSX.Element {
|
||||
zoom: 9,
|
||||
})
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
map.on('click', (event) => {
|
||||
if (!map.getLayer('dataset-fill') || !map.getLayer('dataset-line')) {
|
||||
onFeatureSelectRef.current?.(null)
|
||||
return
|
||||
}
|
||||
const features = map.queryRenderedFeatures(event.point, {
|
||||
layers: ['dataset-fill', 'dataset-line'],
|
||||
})
|
||||
if (features.length === 0) {
|
||||
onFeatureSelectRef.current?.(null)
|
||||
return
|
||||
}
|
||||
const feature = features[0] as unknown as GeoJSON.Feature
|
||||
onFeatureSelectRef.current?.(feature)
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
return () => {
|
||||
@@ -136,6 +159,22 @@ function GeoMap({ data }: GeoMapProps): JSX.Element {
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) {
|
||||
return
|
||||
}
|
||||
const visibility = visible ? 'visible' : 'none'
|
||||
if (map.getLayer('dataset-fill')) {
|
||||
map.setLayoutProperty('dataset-fill', 'visibility', visibility)
|
||||
map.setPaintProperty('dataset-fill', 'fill-opacity', opacity)
|
||||
}
|
||||
if (map.getLayer('dataset-line')) {
|
||||
map.setLayoutProperty('dataset-line', 'visibility', visibility)
|
||||
map.setPaintProperty('dataset-line', 'line-opacity', visible ? 1 : 0)
|
||||
}
|
||||
}, [visible, opacity, data])
|
||||
|
||||
return <div className="map-container" ref={containerRef} />
|
||||
}
|
||||
|
||||
|
||||
@@ -152,3 +152,22 @@ ul {
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.map-status {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.feature-inspector {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user