feat: stream municipality vectors by viewport
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 10:47:23 +02:00
parent 4933284890
commit 1ba479b50b
19 changed files with 465 additions and 38 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 182 Municipality viewport delivery and bounded AI handoff (2026-07-14)
- Replaced full-file loading for vector datasets above 5,000 features with debounced, zoom-aware requests to the existing persisted PostGIS bbox-selection endpoint.
- Kept each viewport response bounded to the canonical 1,000-feature maximum and made zoom-required, loading, visible/total, truncation and error states explicit in the Map workspace.
- Prevented viewport responses from repeatedly fitting the map back to their own bounds while preserving AOI framing and existing behavior for small vectors, detection/segmentation results, selections and QA evidence.
- Hardened the real raster/detection/QA operator smoke so it can reuse a validated existing project, attach both uploads to a persisted analysis Area and apply safe distinct upload filenames.
- Added focused contract and frontend-wiring regression coverage. No migration, model dependency, provider fetch behavior or persistence schema changed.
## Sprint 181 Complete Mol municipality workspace (2026-07-14)
- Added an explicit operator provisioner for the official Digitaal Vlaanderen Mol municipality boundary (NIS `13025`) and the complete GRB GBG building population clipped to that boundary.
+5
View File
@@ -907,6 +907,11 @@ returns a canonical-envelope GeoJSON FeatureCollection. It is intended for the
Map workspace area-extract flow and does not create derived datasets or export
records by itself.
The same bounded endpoint is the canonical large-layer map delivery path. The
frontend requests at most 1,000 features for the current viewport and surfaces
the response `truncated` flag; the backend does not provide or imply an
unbounded municipality-wide map response.
`POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive`
uses the same persisted `vector_features` selection but writes the result as a
new derived vector dataset. The created dataset uses
@@ -14,10 +14,13 @@ def test_real_data_detection_qa_smoke_requires_operator_inputs_and_checks_full_c
assert "bash -n scripts/verify_real_data_detection_qa_workflow.sh" in readiness
assert "REAL_RASTER_PATH" in script
assert "REAL_REFERENCE_VECTOR_PATH" in script
assert "REAL_PROJECT_ID" in script
assert "REAL_DATASET_NAME_PREFIX" in script
assert "usage()" in script
assert "/api/v1/projects" in script
assert "/datasets/upload" in script
assert "dataset_role=reference" in script
assert 'area_upload_args=(-F "area_id=${area_id}")' in script
assert "reference_layer_name=buildings" in script
assert "/raster/inspect" in script
assert "/raster/tile" in script
@@ -0,0 +1,48 @@
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.schemas.operations import VectorSelectionBBox, VectorSelectionRequest
ROOT = Path(__file__).resolve().parents[2]
def test_vector_selection_contract_keeps_an_explicit_bounded_limit() -> None:
bbox = VectorSelectionBBox(min_x=5.03, min_y=51.15, max_x=5.25, max_y=51.33)
request = VectorSelectionRequest(bbox=bbox, limit=1000)
assert request.limit == 1000
with pytest.raises(ValidationError):
VectorSelectionRequest(bbox=bbox, limit=1001)
def test_large_vector_delivery_uses_existing_postgis_bbox_contract() -> None:
config = (ROOT / "frontend" / "src" / "config" / "vectorDelivery.ts").read_text(encoding="utf-8")
viewport_hook = (ROOT / "frontend" / "src" / "hooks" / "useViewportVectorLayer.ts").read_text(encoding="utf-8")
dataset_hook = (ROOT / "frontend" / "src" / "hooks" / "useDatasetWorkflow.ts").read_text(encoding="utf-8")
assert "VECTOR_VIEWPORT_FEATURE_THRESHOLD = 5_000" in config
assert "VECTOR_VIEWPORT_MIN_ZOOM = 14" in config
assert "VECTOR_VIEWPORT_FEATURE_LIMIT = 1_000" in config
assert "datasetsApi.selectVectorFeatures" in viewport_hook
assert "response.truncated" in viewport_hook
assert "requestSequence" in viewport_hook
assert "summary.feature_count ?? dataset.feature_count" in dataset_hook
assert "datasetsApi.getContent" in dataset_hook
def test_map_reports_viewport_and_does_not_refit_each_slice() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
geo_map = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8")
workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
assert "useViewportVectorLayer" in app
assert "fitMapDataOnChange={!viewportVectorLayerActive}" in app
assert "map.on('moveend', emitViewport)" in geo_map
assert "fitDataOnChange" in geo_map
assert "onViewportChange" in geo_map
assert "Zoom in to level" in (ROOT / "frontend" / "src" / "hooks" / "useViewportVectorLayer.ts").read_text(encoding="utf-8")
assert "viewport-vector-status" in workspace
+2
View File
@@ -405,6 +405,8 @@ Rules:
- Coordinates are EPSG:4326 longitude/latitude.
- Results are generated from persisted PostGIS `vector_features`, not from client-side map data.
- The response is capped by `limit` and returns `truncated=true` when more matching rows exist.
- `limit` is bounded to `1..1000`. Municipality-scale clients must page spatially by viewport instead of requesting an unbounded municipality FeatureCollection.
- The Map workspace uses this existing endpoint for vector datasets above 5,000 features. It starts delivery at zoom level 14, debounces `moveend` requests and explicitly reports `truncated=true` as a request to zoom further in. This is a client delivery policy, not a second API or persistence path.
### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select/derive`
+36
View File
@@ -7474,3 +7474,39 @@ Open:
layering additional municipality-wide reference classes. Then acquire and
tile georeferenced imagery only for an explicitly selected Mol analysis zone
and run configured-YOLO plus QA/QC against the persisted GRB reference.
# Sprint 182 - Municipality viewport delivery and bounded AI handoff
## Implementation
- Added `useViewportVectorLayer` with centralized feature threshold, minimum
zoom, response limit and debounce policy. Vector datasets above 5,000
features now use the existing canonical PostGIS bbox-selection endpoint from
zoom level 14 instead of downloading the full stored GeoJSON file.
- Added MapLibre `moveend` viewport reporting, stale-request protection and
disabled automatic fit for viewport slices. AOI changes still frame the map;
small vectors and persisted analysis/evidence layers retain their existing
complete-layer fit behavior.
- Added explicit low-zoom, loading, visible/total, truncation and error states.
A selected large database layer is no longer presented as an empty or missing
layer before the first detail request.
- Extended the real-data detection/QA workflow with validated existing-project
reuse, safe distinct upload filenames and actual Area linkage for the raster
and matching reference vector. The workflow still consumes operator-provided
files and an existing local model asset; it performs no provider fetch or
model download.
## Local validation
- `python -m compileall backend/app` passed.
- Full backend suite passed: `500` tests.
- `npm run typecheck` and `npm run build` passed; the production build contains
85 transformed modules and preserves the dedicated MapLibre chunk.
- `bash scripts/run_readiness_check.sh` passed with the 81-route API contract
audit, one Alembic head `202606120900`, shell syntax checks and full frontend
build.
- Internal browser validation against the local frontend and live Tower API
selected `mol_grb_gbg_buildings.geojson`, showed the zoom-14 PostGIS delivery
guard, suppressed the incorrect empty-layer state and loaded an actual
viewport slice with `2 visible of 36,941 total features`. No browser warnings
or errors were recorded.
+10
View File
@@ -525,6 +525,16 @@ This file now starts with the current implementation status. Older preparation/b
- [ ] Acquire and tile a georeferenced raster only for an explicitly selected
Mol analysis zone before running the next configured-YOLO validation.
# Sprint 182 - Municipality viewport delivery and bounded AI handoff
- [x] Stop downloading municipality-scale vector datasets as one frontend GeoJSON payload.
- [x] Deliver large persisted layers through the existing PostGIS bbox endpoint from zoom level 14.
- [x] Report visible/total counts and explicit truncation instead of silently hiding omitted features.
- [x] Preserve complete loading and auto-fit behavior for small vectors and persisted AI/QA overlays.
- [x] Allow the real raster/detection/QA operator smoke to reuse the definitive Mol project.
- [x] Link operator raster/reference uploads to the persisted analysis Area and keep their names distinct from municipality-wide layers.
- [ ] Execute the bounded Mol-Centrum configured-YOLO/QA workflow in the deployed runtime and retain its persisted ids as release evidence.
# Sprint 171 - Positive AOI expansion and small-building recovery
- [x] Reject cross-model false-negative comparisons when reference populations differ.
+2
View File
@@ -20,6 +20,8 @@ When the public OpenStreetMap fallback is active, the Map workspace shows a base
Operational GIS testing is now available directly in the Map workspace. Users can choose a persisted vector database layer, load it on the map, reuse the selected AOI or active layer extent, run the existing persisted `vector_features` bbox query, save the result as a derived dataset, export the selection GeoJSON, choose a reference dataset and launch QA/QC without creating fake data or a parallel backend path. The guided workflow also includes a one-click full run action that executes query, derived dataset save, GeoJSON export and optional QA/QC in sequence with visible status. For repeated review, switch the run mode from `Create new dataset/export` to `Reuse latest saved dataset for QA`; this reruns QA/QC against the latest saved derived dataset without creating another dataset/export pair.
Large vector layers use viewport delivery instead of downloading one unbounded GeoJSON file. Datasets above 5,000 features load their persisted PostGIS geometries from the existing bbox-selection endpoint at zoom level 14 or closer, with a 1,000-feature cap per view. The Map workspace shows visible versus total feature counts and asks the operator to zoom further when the response is truncated. Small layers, AI result overlays, selections and QA evidence keep their existing complete-FeatureCollection behavior.
QA/QC and Exports follow the same calmer density model. QA/QC keeps metric evidence, feature ids and raw findings available but compresses provenance and history surfaces so review starts from the selected check and map evidence actions. Exports uses denser handoff cards, latest-artifact cards and history filters so artifact creation and download paths are easier to scan.
When project data loads and no dataset is selected yet, the workbench auto-opens the first ready vector dataset. This gives Data, Map and Exports an immediately usable default context while preserving explicit user selection once the user picks another dataset.
+27 -6
View File
@@ -29,6 +29,7 @@ import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap'
import { useViewportVectorLayer } from './hooks/useViewportVectorLayer'
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
@@ -344,6 +345,15 @@ function App(): JSX.Element {
loadProjectData,
loadQualityChecks,
})
const viewportVectorLayer = useViewportVectorLayer({
selectedProjectId,
selectedDataset,
featureCount: selectedDatasetSummary?.feature_count ?? selectedDataset?.feature_count ?? null,
isVectorDatasetType,
})
const analysisMapLayerActive = Boolean(changeDetectionResult?.geojson || segmentationGeoJson || detectionGeoJson)
const viewportVectorLayerActive = viewportVectorLayer.enabled && !analysisMapLayerActive
const datasetMapContent = viewportVectorLayer.enabled ? viewportVectorLayer.data : datasetContent
const useRasterTileManifestForSegmentation = () => {
const manifestPath = latestRasterTileManifestPath.trim()
if (!manifestPath || !selectedDataset || selectedDataset.dataset_type !== 'raster') {
@@ -403,8 +413,9 @@ function App(): JSX.Element {
changeDetectionGeoJson: changeDetectionResult?.geojson ?? null,
segmentationGeoJson,
detectionGeoJson,
datasetContent,
datasetContent: datasetMapContent,
selectedDataset,
datasetLayerActive: Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type)),
})
const {
mapSelectionBbox,
@@ -519,11 +530,11 @@ function App(): JSX.Element {
if (detectionGeoJson) {
return 'Detection run'
}
if (datasetContent && selectedDataset) {
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
return `${selectedDataset.dataset_type} dataset`
}
return 'No active vector or result layer'
}, [changeDetectionResult?.geojson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
}, [changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled])
const mapLayerProvenance = useMemo(() => {
if (changeDetectionResult?.geojson) {
return `source ${changeSourceDatasetId || 'n/a'} -> target ${changeTargetDatasetId || 'n/a'}`
@@ -534,7 +545,7 @@ function App(): JSX.Element {
if (detectionGeoJson) {
return selectedDetectionRunId ? `analysis run ${selectedDetectionRunId}` : 'detection results loaded'
}
if (datasetContent && selectedDataset) {
if ((datasetMapContent || viewportVectorLayer.enabled) && 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.'
@@ -542,12 +553,13 @@ function App(): JSX.Element {
changeDetectionResult?.geojson,
changeSourceDatasetId,
changeTargetDatasetId,
datasetContent,
datasetMapContent,
detectionGeoJson,
segmentationGeoJson,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
viewportVectorLayer.enabled,
])
const openDatasetInMap = (dataset: DatasetCreateResponse) => {
if (selectedProjectId) {
@@ -643,7 +655,11 @@ function App(): JSX.Element {
const projectContextLabel = selectedProject?.name ?? 'No project'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Select area' : 'No AOI')
const datasetContextLabel = selectedDataset?.name ?? (datasets.length > 0 ? 'Select dataset' : 'No dataset')
const layerContextLabel = mapFeatureCollection ? `${mapFeatureCount} features` : 'No active layer'
const layerContextLabel = mapFeatureCollection
? `${mapFeatureCount} features`
: viewportVectorLayerActive
? 'Viewport layer selected'
: 'No active layer'
return (
<div className="app-shell workbench-shell">
@@ -905,6 +921,10 @@ function App(): JSX.Element {
areaLayerOpacity={areaLayerOpacity}
mapFeatureCount={mapFeatureCount}
areaFeatureCount={areaFeatureCount}
viewportVectorEnabled={viewportVectorLayerActive}
viewportVectorStatus={viewportVectorLayerActive ? viewportVectorLayer.statusMessage : null}
viewportVectorTone={viewportVectorLayer.error ? 'error' : viewportVectorLayer.truncated ? 'warning' : viewportVectorLayer.loading || viewportVectorLayer.zoomRequired ? 'pending' : 'ready'}
fitMapDataOnChange={!viewportVectorLayerActive}
selectedMapFeature={selectedMapFeature}
mapSelectionBbox={mapSelectionBbox}
mapSelectionResult={mapSelectionResult}
@@ -933,6 +953,7 @@ function App(): JSX.Element {
onSetMapLayerVisible={setMapLayerVisible}
onSetMapLayerOpacity={setMapLayerOpacity}
onSelectMapFeature={setSelectedMapFeature}
onMapViewportChange={viewportVectorLayer.setViewport}
onSetMapSelectionBbox={setMapSelectionBbox}
onRunMapSelectionExtract={runMapSelectionExtract}
onClearMapSelectionExtract={resetMapSelectionExtract}
+33 -5
View File
@@ -3,6 +3,7 @@ import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapViewportState } from '../types'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
@@ -16,8 +17,10 @@ interface GeoMapProps {
opacity?: number
areaVisible?: boolean
areaOpacity?: number
fitDataOnChange?: boolean
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
onMapCoordinateSelect?: (coordinate: [number, number]) => void
onViewportChange?: (viewport: MapViewportState) => void
}
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
@@ -108,14 +111,18 @@ function GeoMap({
opacity = 0.4,
areaVisible = true,
areaOpacity = 0.18,
fitDataOnChange = true,
onFeatureSelect,
onMapCoordinateSelect,
onViewportChange,
}: GeoMapProps): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
const onViewportChangeRef = useRef<GeoMapProps['onViewportChange']>(onViewportChange)
const bboxSelectionModeRef = useRef(bboxSelectionMode)
const lastFittedAreaRef = useRef<GeoJSON.FeatureCollection | null>(null)
const [mapStyleReady, setMapStyleReady] = useState(false)
useEffect(() => {
@@ -126,6 +133,10 @@ function GeoMap({
onMapCoordinateSelectRef.current = onMapCoordinateSelect
}, [onMapCoordinateSelect])
useEffect(() => {
onViewportChangeRef.current = onViewportChange
}, [onViewportChange])
useEffect(() => {
bboxSelectionModeRef.current = bboxSelectionMode
if (mapRef.current) {
@@ -152,9 +163,24 @@ function GeoMap({
}),
'bottom-right',
)
const emitViewport = () => {
const bounds = map.getBounds()
onViewportChangeRef.current?.({
bbox: {
min_x: bounds.getWest(),
min_y: bounds.getSouth(),
max_x: bounds.getEast(),
max_y: bounds.getNorth(),
crs: 'EPSG:4326',
},
zoom: map.getZoom(),
})
}
map.on('load', () => {
setMapStyleReady(true)
emitViewport()
})
map.on('moveend', emitViewport)
map.on('click', (event) => {
if (bboxSelectionModeRef.current) {
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
@@ -270,7 +296,7 @@ function GeoMap({
})
}
if (data) {
if (data && fitDataOnChange) {
const collection = data
if (collection.type === 'FeatureCollection' && collection.features.length > 0) {
const bounds = collectCoordinates(collection)
@@ -279,7 +305,7 @@ function GeoMap({
}
}
}
}, [data, mapStyleReady])
}, [data, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
@@ -329,14 +355,16 @@ function GeoMap({
)
}
const activeCollection = mergeFeatureCollections([areaData, data])
if (activeCollection) {
const activeCollection = fitDataOnChange ? mergeFeatureCollections([areaData, data]) : areaData
const shouldFitArea = fitDataOnChange || lastFittedAreaRef.current !== areaData
if (activeCollection && shouldFitArea) {
const bounds = collectCoordinates(activeCollection)
if (bounds) {
map.fitBounds(bounds, { padding: 40 })
}
}
}, [areaData, data, mapStyleReady])
lastFittedAreaRef.current = areaData
}, [areaData, data, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
+40 -9
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
@@ -189,6 +189,10 @@ interface MapWorkspaceProps {
areaLayerOpacity: number
mapFeatureCount: number
areaFeatureCount: number
viewportVectorEnabled: boolean
viewportVectorStatus: string | null
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
fitMapDataOnChange: boolean
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
@@ -217,6 +221,7 @@ interface MapWorkspaceProps {
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => Promise<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
@@ -247,6 +252,10 @@ export function MapWorkspace({
areaLayerOpacity,
mapFeatureCount,
areaFeatureCount,
viewportVectorEnabled,
viewportVectorStatus,
viewportVectorTone,
fitMapDataOnChange,
selectedMapFeature,
selectedFeature = selectedMapFeature,
mapSelectionBbox,
@@ -275,6 +284,7 @@ export function MapWorkspace({
onSetMapLayerVisible,
onSetMapLayerOpacity,
onSelectMapFeature,
onMapViewportChange,
onSetMapSelectionBbox,
onRunMapSelectionExtract,
onClearMapSelectionExtract,
@@ -485,7 +495,9 @@ export function MapWorkspace({
<p className="eyebrow">Spatial review</p>
<h2>Map workspace</h2>
</div>
<span className="count-pill">{mapFeatureCollection ? `${mapFeatureCount} features` : 'no layer'}</span>
<span className="count-pill">
{mapFeatureCollection ? `${mapFeatureCount} features` : viewportVectorEnabled ? 'zoom to load' : 'no layer'}
</span>
</div>
<div className="map-control-surface" aria-label="Map workspace controls">
@@ -555,7 +567,7 @@ export function MapWorkspace({
<label className="checkbox-row">
<input
checked={mapLayerVisible}
disabled={!mapFeatureCollection}
disabled={!mapFeatureCollection && !viewportVectorEnabled}
type="checkbox"
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
data-testid="map-layer-visible"
@@ -564,7 +576,7 @@ export function MapWorkspace({
</label>
<input
aria-label="Layer opacity"
disabled={!mapFeatureCollection}
disabled={!mapFeatureCollection && !viewportVectorEnabled}
max="1"
min="0.05"
step="0.05"
@@ -578,7 +590,18 @@ export function MapWorkspace({
<strong>{mapLayerLabel}</strong>
<span>{selectedMapDataset ? `DB layer: ${selectedMapDataset.name}` : 'No database layer selected'}</span>
<span>{areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'}</span>
<span>{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector/result layer loaded'}</span>
<span>
{mapFeatureCollection
? `${mapFeatureCount} features loaded`
: viewportVectorEnabled
? 'Database layer selected; visible features load by viewport'
: 'No vector/result layer loaded'}
</span>
{viewportVectorEnabled && viewportVectorStatus ? (
<span className={`viewport-vector-status viewport-vector-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
{viewportVectorStatus}
</span>
) : null}
</div>
</div>
</div>
@@ -596,15 +619,19 @@ export function MapWorkspace({
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
fitDataOnChange={fitMapDataOnChange}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
onViewportChange={onMapViewportChange}
/>
</div>
<details className="map-layer-details">
<summary>
<span>Layer details</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active layer'}</strong>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport layer selected' : 'No active layer'}
</strong>
</summary>
<div className="map-context-summary" aria-label="Map layer status">
<div>
@@ -619,7 +646,9 @@ export function MapWorkspace({
</div>
<div>
<span>Feature state</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No layer rendered'}</strong>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Awaiting viewport detail' : 'No layer rendered'}
</strong>
<small>{mapLayerProvenance}</small>
</div>
<div>
@@ -639,7 +668,9 @@ export function MapWorkspace({
</div>
<div>
<span>Draw state</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active vector or result layer'}</strong>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport delivery active' : 'No active vector or result layer'}
</strong>
</div>
<div>
<span>QA evidence overlay</span>
@@ -672,7 +703,7 @@ export function MapWorkspace({
</div>
) : null}
{!mapFeatureCollection ? (
{!mapFeatureCollection && !viewportVectorEnabled ? (
<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>
+4
View File
@@ -0,0 +1,4 @@
export const VECTOR_VIEWPORT_FEATURE_THRESHOLD = 5_000
export const VECTOR_VIEWPORT_MIN_ZOOM = 14
export const VECTOR_VIEWPORT_FEATURE_LIMIT = 1_000
export const VECTOR_VIEWPORT_DEBOUNCE_MS = 250
+7 -5
View File
@@ -12,6 +12,7 @@ import type {
} from '../types'
import { formatError } from '../lib/formatError'
import { isPrimaryFocusMunicipalityBoundaryDataset } from '../config/primaryFocus'
import { VECTOR_VIEWPORT_FEATURE_THRESHOLD } from '../config/vectorDelivery'
interface DatasetWorkflowOptions {
selectedProjectId: string | null
@@ -165,12 +166,13 @@ export function useDatasetWorkflow({
setJobs([])
try {
if (isVectorDatasetType(dataset.dataset_type)) {
const [content, summary] = await Promise.all([
datasetsApi.getContent(projectId, dataset.id),
datasetsApi.vectorSummary(projectId, dataset.id),
])
setDatasetContent(content)
const summary = await datasetsApi.vectorSummary(projectId, dataset.id)
setSelectedDatasetSummary(summary)
const featureCount = summary.feature_count ?? dataset.feature_count
if (featureCount == null || featureCount <= VECTOR_VIEWPORT_FEATURE_THRESHOLD) {
const content = await datasetsApi.getContent(projectId, dataset.id)
setDatasetContent(content)
}
} else if (dataset.dataset_type === 'raster') {
try {
const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id)
+4 -2
View File
@@ -8,6 +8,7 @@ interface MapWorkspaceStateOptions {
detectionGeoJson: GeoJSON.FeatureCollection | null
datasetContent: GeoJSON.FeatureCollection | null
selectedDataset: DatasetCreateResponse | null
datasetLayerActive?: boolean
}
export function useMapWorkspaceState({
@@ -17,6 +18,7 @@ export function useMapWorkspaceState({
detectionGeoJson,
datasetContent,
selectedDataset,
datasetLayerActive = Boolean(datasetContent),
}: MapWorkspaceStateOptions) {
const [mapLayerVisible, setMapLayerVisible] = useState(true)
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
@@ -72,11 +74,11 @@ export function useMapWorkspaceState({
if (detectionGeoJson) {
return 'Detection result'
}
if (datasetContent && selectedDataset) {
if (datasetLayerActive && selectedDataset) {
return selectedDataset.name
}
return 'No active vector layer'
}, [changeDetectionGeoJson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
}, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
VECTOR_VIEWPORT_DEBOUNCE_MS,
VECTOR_VIEWPORT_FEATURE_LIMIT,
VECTOR_VIEWPORT_FEATURE_THRESHOLD,
VECTOR_VIEWPORT_MIN_ZOOM,
} from '../config/vectorDelivery'
import { formatError } from '../lib/formatError'
import { datasetsApi } from '../services/api/datasets'
import type { DatasetCreateResponse, MapViewportState } from '../types'
interface ViewportVectorLayerOptions {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
featureCount: number | null
isVectorDatasetType: (datasetType: string) => boolean
}
export function useViewportVectorLayer({
selectedProjectId,
selectedDataset,
featureCount,
isVectorDatasetType,
}: ViewportVectorLayerOptions) {
const [viewport, setViewport] = useState<MapViewportState | null>(null)
const [data, setData] = useState<GeoJSON.FeatureCollection | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [truncated, setTruncated] = useState(false)
const [loadedFeatureCount, setLoadedFeatureCount] = useState(0)
const requestSequence = useRef(0)
const enabled = Boolean(
selectedProjectId &&
selectedDataset &&
isVectorDatasetType(selectedDataset.dataset_type) &&
(featureCount ?? 0) > VECTOR_VIEWPORT_FEATURE_THRESHOLD,
)
const zoom = viewport?.zoom ?? null
const zoomRequired = enabled && (zoom === null || zoom < VECTOR_VIEWPORT_MIN_ZOOM)
useEffect(() => {
requestSequence.current += 1
setData(null)
setLoading(false)
setError(null)
setTruncated(false)
setLoadedFeatureCount(0)
}, [enabled, selectedDataset?.id, selectedProjectId])
useEffect(() => {
if (!enabled || !selectedProjectId || !selectedDataset || !viewport) {
return
}
if (viewport.zoom < VECTOR_VIEWPORT_MIN_ZOOM) {
requestSequence.current += 1
setData(null)
setLoading(false)
setError(null)
setTruncated(false)
setLoadedFeatureCount(0)
return
}
const sequence = requestSequence.current + 1
requestSequence.current = sequence
const timer = window.setTimeout(async () => {
setLoading(true)
setError(null)
try {
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
bbox: viewport.bbox,
limit: VECTOR_VIEWPORT_FEATURE_LIMIT,
})
if (requestSequence.current !== sequence) {
return
}
setData(response.geojson)
setLoadedFeatureCount(response.feature_count)
setTruncated(response.truncated)
} catch (requestError) {
if (requestSequence.current !== sequence) {
return
}
setData(null)
setLoadedFeatureCount(0)
setTruncated(false)
setError(formatError(requestError, 'Unable to load visible vector features'))
} finally {
if (requestSequence.current === sequence) {
setLoading(false)
}
}
}, VECTOR_VIEWPORT_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
}
}, [enabled, selectedDataset, selectedProjectId, viewport])
const statusMessage = useMemo(() => {
if (!enabled) {
return null
}
if (zoomRequired) {
return `Zoom in to level ${VECTOR_VIEWPORT_MIN_ZOOM} to load buildings from PostGIS.`
}
if (loading) {
return 'Loading visible features from PostGIS...'
}
if (error) {
return error
}
if (truncated) {
return `${loadedFeatureCount.toLocaleString()} visible features loaded; zoom in further because this view exceeds the ${VECTOR_VIEWPORT_FEATURE_LIMIT.toLocaleString()} feature limit.`
}
return `${loadedFeatureCount.toLocaleString()} visible of ${(featureCount ?? 0).toLocaleString()} total features loaded from PostGIS.`
}, [enabled, error, featureCount, loadedFeatureCount, loading, truncated, zoomRequired])
return {
enabled,
data,
loading,
error,
truncated,
loadedFeatureCount,
featureLimit: VECTOR_VIEWPORT_FEATURE_LIMIT,
minZoom: VECTOR_VIEWPORT_MIN_ZOOM,
zoom,
zoomRequired,
statusMessage,
setViewport,
}
}
+27
View File
@@ -2611,6 +2611,33 @@ button.entity-card {
font-size: 0.84rem;
}
.map-status .viewport-vector-status {
grid-column: 1 / -1;
padding: 0.5rem 0.65rem;
border: 1px solid #bae6fd;
background: #f0f9ff;
color: #075985;
font-weight: 700;
}
.map-status .viewport-vector-status-ready {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
.map-status .viewport-vector-status-warning {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
.map-status .viewport-vector-status-error {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.layer-provenance-rail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+5
View File
@@ -292,6 +292,11 @@ export interface VectorSelectionBBox {
crs?: 'EPSG:4326'
}
export interface MapViewportState {
bbox: VectorSelectionBBox
zoom: number
}
export interface VectorSelectionRequest {
bbox: VectorSelectionBBox
limit?: number
+22
View File
@@ -167,6 +167,28 @@ REAL_REFERENCE_VECTOR_PATH=/mnt/user/appdata/geointel/storage/operator-data/geel
bash scripts/verify_real_data_detection_qa_workflow.sh http://192.168.10.150:1202
```
Reuse the definitive Mol municipality project for a bounded Mol-Centrum
analysis zone while keeping the municipality-wide reference layer distinct:
```bash
REAL_PROJECT_ID=d74c1f87-29c0-4c67-adfc-560764f2b80e \
REAL_PROJECT_NAME='Mol Municipality Workbench' \
REAL_PROJECT_REGION='Mol, Kempen' \
REAL_AREA_NAME='Mol Centrum - AI analysezone 500m' \
REAL_AREA_BBOX='5.113116,51.189653,5.120284,51.194147' \
REAL_DATASET_NAME_PREFIX='mol_center_ai_500m' \
REAL_RASTER_PATH=/mnt/user/appdata/geointel/storage/operator-data/mol_orthophoto_wms_512.tif \
REAL_REFERENCE_VECTOR_PATH=/mnt/user/appdata/geointel/storage/operator-data/mol_grb_gbg_buildings.geojson \
bash scripts/verify_real_data_detection_qa_workflow.sh http://192.168.10.150:1202
```
`REAL_PROJECT_ID` is validated through the canonical project endpoint; it does
not create a shadow validation project. When `REAL_AREA_BBOX` is present, both
the raster and matching reference upload persist that new Area id. The optional
safe filename prefix prevents the bounded sample from sharing a display name
with the complete Mol GRB dataset. The workflow still performs no data fetch or
model download: the files and configured local model asset must already exist.
Those files are runtime artifacts generated from Digitaal Vlaanderen's
OMWRGBMRVL WMS `Ortho` layer and GRB OGC API Features `GBG` building collection
for a small Geel AOI. They are intentionally not repository fixtures.
@@ -18,8 +18,10 @@ Required inputs:
Optional environment:
REAL_PROJECT_NAME Project name for the validation run.
REAL_PROJECT_REGION Persisted project region, default: Kempen.
REAL_PROJECT_ID Existing project id to reuse instead of creating a validation project.
REAL_AREA_NAME Persisted AOI name when REAL_AREA_BBOX is set.
REAL_AREA_BBOX Optional EPSG:4326 minx,miny,maxx,maxy AOI bounds.
REAL_DATASET_NAME_PREFIX Safe filename prefix for raster/reference uploads in a reused project.
REAL_MODEL_ASSET_ID Specific /api/v1/detection/model-assets id to use.
REAL_TILE_SIZE Raster tile size, default 640.
REAL_TILE_OVERLAP Raster tile overlap, default 64.
@@ -33,8 +35,10 @@ REAL_RASTER_PATH="${2:-${REAL_RASTER_PATH:-}}"
REAL_REFERENCE_VECTOR_PATH="${3:-${REAL_REFERENCE_VECTOR_PATH:-}}"
REAL_PROJECT_NAME="${REAL_PROJECT_NAME:-GeoIntel Real Data Validation}"
REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}"
REAL_PROJECT_ID="${REAL_PROJECT_ID:-}"
REAL_AREA_NAME="${REAL_AREA_NAME:-${REAL_PROJECT_NAME} AOI}"
REAL_AREA_BBOX="${REAL_AREA_BBOX:-}"
REAL_DATASET_NAME_PREFIX="${REAL_DATASET_NAME_PREFIX:-}"
REAL_TILE_SIZE="${REAL_TILE_SIZE:-640}"
REAL_TILE_OVERLAP="${REAL_TILE_OVERLAP:-64}"
REAL_CONFIDENCE_THRESHOLD="${REAL_CONFIDENCE_THRESHOLD:-0.5}"
@@ -62,6 +66,13 @@ if [ ! -f "${REAL_REFERENCE_VECTOR_PATH}" ]; then
exit 2
fi
case "${REAL_DATASET_NAME_PREFIX}" in
*[!a-zA-Z0-9._-]*)
echo "REAL_DATASET_NAME_PREFIX may contain letters, numbers, dot, underscore and dash only" >&2
exit 2
;;
esac
case "${REAL_RASTER_PATH,,}" in
*.tif|*.tiff|*.geotiff) ;;
*)
@@ -136,7 +147,16 @@ echo "Base URL: ${BASE_URL}"
echo "Raster: ${REAL_RASTER_PATH}"
echo "Reference vector: ${REAL_REFERENCE_VECTOR_PATH}"
"${PYTHON_BIN}" - "${TMP_DIR}/project_request.json" "${REAL_PROJECT_NAME}" "${REAL_PROJECT_REGION}" <<'PY'
if [ -n "${REAL_PROJECT_ID}" ]; then
curl -fsS "${BASE_URL%/}/api/v1/projects/${REAL_PROJECT_ID}" > "${TMP_DIR}/project.json"
require_json_data "${TMP_DIR}/project.json"
project_id="$(json_field "${TMP_DIR}/project.json" "data.id")"
if [ "${project_id}" != "${REAL_PROJECT_ID}" ]; then
echo "Existing project lookup returned the wrong project id" >&2
exit 1
fi
else
"${PYTHON_BIN}" - "${TMP_DIR}/project_request.json" "${REAL_PROJECT_NAME}" "${REAL_PROJECT_REGION}" <<'PY'
import json
import sys
from datetime import datetime, timezone
@@ -152,14 +172,15 @@ with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
PY
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects" \
-H "Content-Type: application/json" \
--data-binary "@${TMP_DIR}/project_request.json" > "${TMP_DIR}/project.json"
require_json_data "${TMP_DIR}/project.json"
project_id="$(json_field "${TMP_DIR}/project.json" "data.id")"
if [ -z "${project_id}" ] || [ "${project_id}" = "None" ] || [ "${project_id}" = "null" ]; then
echo "Project creation did not return a project id" >&2
exit 1
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects" \
-H "Content-Type: application/json" \
--data-binary "@${TMP_DIR}/project_request.json" > "${TMP_DIR}/project.json"
require_json_data "${TMP_DIR}/project.json"
project_id="$(json_field "${TMP_DIR}/project.json" "data.id")"
if [ -z "${project_id}" ] || [ "${project_id}" = "None" ] || [ "${project_id}" = "null" ]; then
echo "Project creation did not return a project id" >&2
exit 1
fi
fi
area_id=""
@@ -197,14 +218,29 @@ PY
fi
fi
area_upload_args=()
if [ -n "${area_id}" ]; then
area_upload_args=(-F "area_id=${area_id}")
fi
raster_form_file="file=@${REAL_RASTER_PATH}"
reference_form_file="file=@${REAL_REFERENCE_VECTOR_PATH}"
if [ -n "${REAL_DATASET_NAME_PREFIX}" ]; then
raster_extension="${REAL_RASTER_PATH##*.}"
reference_extension="${REAL_REFERENCE_VECTOR_PATH##*.}"
raster_form_file="${raster_form_file};filename=${REAL_DATASET_NAME_PREFIX}_orthophoto.${raster_extension}"
reference_form_file="${reference_form_file};filename=${REAL_DATASET_NAME_PREFIX}_grb_buildings.${reference_extension}"
fi
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \
-F "file=@${REAL_RASTER_PATH}" \
-F "${raster_form_file}" \
-F "dataset_type=raster" \
-F "source=user_upload" \
-F "dataset_role=source" \
-F "source_name=manual" \
-F 'source_metadata_json={"validation_workflow":"real_data_detection_qa","input_kind":"orthophoto"}' \
-F 'provenance_metadata_json={"operator_supplied":true,"no_external_fetch":true}' \
"${area_upload_args[@]}" \
> "${TMP_DIR}/raster_upload.json"
require_json_data "${TMP_DIR}/raster_upload.json"
raster_dataset_id="$(json_field "${TMP_DIR}/raster_upload.json" "data.id")"
@@ -226,7 +262,7 @@ if not data.get("bounds_json"):
PY
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \
-F "file=@${REAL_REFERENCE_VECTOR_PATH}" \
-F "${reference_form_file}" \
-F "dataset_type=vector" \
-F "source=user_upload" \
-F "dataset_role=reference" \
@@ -234,6 +270,7 @@ curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload"
-F "reference_layer_name=buildings" \
-F 'source_metadata_json={"validation_workflow":"real_data_detection_qa","input_kind":"reference_buildings"}' \
-F 'provenance_metadata_json={"operator_supplied":true,"no_external_fetch":true}' \
"${area_upload_args[@]}" \
> "${TMP_DIR}/reference_upload.json"
require_json_data "${TMP_DIR}/reference_upload.json"
reference_dataset_id="$(json_field "${TMP_DIR}/reference_upload.json" "data.id")"