feat: stream municipality vectors by viewport
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user