feat: stream municipality vectors by viewport
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -292,6 +292,11 @@ export interface VectorSelectionBBox {
|
||||
crs?: 'EPSG:4326'
|
||||
}
|
||||
|
||||
export interface MapViewportState {
|
||||
bbox: VectorSelectionBBox
|
||||
zoom: number
|
||||
}
|
||||
|
||||
export interface VectorSelectionRequest {
|
||||
bbox: VectorSelectionBBox
|
||||
limit?: number
|
||||
|
||||
@@ -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")"
|
||||
|
||||
Reference in New Issue
Block a user