feat: stream municipality vectors by viewport
This commit is contained in:
+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