Add V1 map workbench controls
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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)
|
## 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.
|
- 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)
|
## Sprint 18 vector change detection foundation (2026-06-16)
|
||||||
|
|
||||||
Changed:
|
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] Backend FastAPI foundation, health endpoint and service structure.
|
||||||
- [x] React/TypeScript frontend foundation and MapLibre workbench.
|
- [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] SQLAlchemy/PostGIS ORM models and Alembic migration chain through Sprint 9.
|
||||||
- [x] Dataset upload, storage metadata and vector feature persistence.
|
- [x] Dataset upload, storage metadata and vector feature persistence.
|
||||||
- [x] Raster metadata and raster operation service boundaries.
|
- [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] API client
|
||||||
- [x] Project pages
|
- [x] Project pages
|
||||||
- [x] Map Workbench basis
|
- [x] Map Workbench basis
|
||||||
|
- [x] Map layer controls
|
||||||
|
- [x] Feature property inspector
|
||||||
|
|
||||||
## 4. Project & Area API
|
## 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 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.
|
- 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
|
## Release hardening updates
|
||||||
|
|
||||||
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
- 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 [selectedRasterMetadata, setSelectedRasterMetadata] = useState<RasterMetadataResponse | null>(null)
|
||||||
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
|
const [selectedRasterStats, setSelectedRasterStats] = useState<RasterStatsResponse | null>(null)
|
||||||
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | 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 [jobs, setJobs] = useState<JobRead[]>([])
|
||||||
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
||||||
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
|
const [loadingCapabilities, setLoadingCapabilities] = useState(false)
|
||||||
@@ -244,6 +247,22 @@ function App(): JSX.Element {
|
|||||||
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
|
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
|
||||||
[changeDetectionResult, 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(
|
const isRasterTileInputValid = useMemo(
|
||||||
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
||||||
[rasterTileSize, rasterTileOverlap],
|
[rasterTileSize, rasterTileOverlap],
|
||||||
@@ -624,6 +643,10 @@ function App(): JSX.Element {
|
|||||||
loadSegmentationModels().catch(() => null)
|
loadSegmentationModels().catch(() => null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedMapFeature(null)
|
||||||
|
}, [mapFeatureCollection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
setAreas([])
|
setAreas([])
|
||||||
@@ -1300,8 +1323,8 @@ function App(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header>
|
<header>
|
||||||
<h1>GeoIntel Kempen Sprint 9</h1>
|
<h1>GeoIntel Kempen V1 Workbench</h1>
|
||||||
<p>Sprint 9: raster/vector workbench with detection and segmentation foundations.</p>
|
<p>Raster/vector workbench with QA/QC, exports, change detection and AI foundation layers.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{errorMessage ? <p className="error">{errorMessage}</p> : null}
|
{errorMessage ? <p className="error">{errorMessage}</p> : null}
|
||||||
@@ -1826,8 +1849,50 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Map workspace</h2>
|
<h2>Map workspace</h2>
|
||||||
<p>{selectedDatasetId ? `Showing dataset ${selectedDatasetId}` : 'No vector dataset selected'}</p>
|
<p>{mapLayerLabel}</p>
|
||||||
<GeoMap data={mapFeatureCollection} />
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
|||||||
|
|
||||||
interface GeoMapProps {
|
interface GeoMapProps {
|
||||||
data: GeoJSON.FeatureCollection | null
|
data: GeoJSON.FeatureCollection | null
|
||||||
|
visible?: boolean
|
||||||
|
opacity?: number
|
||||||
|
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
|
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 containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
|
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFeatureSelectRef.current = onFeatureSelect
|
||||||
|
}, [onFeatureSelect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || mapRef.current) {
|
if (!containerRef.current || mapRef.current) {
|
||||||
@@ -56,6 +64,21 @@ function GeoMap({ data }: GeoMapProps): JSX.Element {
|
|||||||
zoom: 9,
|
zoom: 9,
|
||||||
})
|
})
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
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
|
mapRef.current = map
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -136,6 +159,22 @@ function GeoMap({ data }: GeoMapProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [data])
|
}, [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} />
|
return <div className="map-container" ref={containerRef} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,3 +152,22 @@ ul {
|
|||||||
border: 1px solid #94a3b8;
|
border: 1px solid #94a3b8;
|
||||||
border-radius: 8px;
|
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