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

This commit is contained in:
Codex
2026-07-14 10:47:23 +02:00
parent 4933284890
commit 1ba479b50b
19 changed files with 465 additions and 38 deletions
+2
View File
@@ -20,6 +20,8 @@ When the public OpenStreetMap fallback is active, the Map workspace shows a base
Operational GIS testing is now available directly in the Map workspace. Users can choose a persisted vector database layer, load it on the map, reuse the selected AOI or active layer extent, run the existing persisted `vector_features` bbox query, save the result as a derived dataset, export the selection GeoJSON, choose a reference dataset and launch QA/QC without creating fake data or a parallel backend path. The guided workflow also includes a one-click full run action that executes query, derived dataset save, GeoJSON export and optional QA/QC in sequence with visible status. For repeated review, switch the run mode from `Create new dataset/export` to `Reuse latest saved dataset for QA`; this reruns QA/QC against the latest saved derived dataset without creating another dataset/export pair.
Large vector layers use viewport delivery instead of downloading one unbounded GeoJSON file. Datasets above 5,000 features load their persisted PostGIS geometries from the existing bbox-selection endpoint at zoom level 14 or closer, with a 1,000-feature cap per view. The Map workspace shows visible versus total feature counts and asks the operator to zoom further when the response is truncated. Small layers, AI result overlays, selections and QA evidence keep their existing complete-FeatureCollection behavior.
QA/QC and Exports follow the same calmer density model. QA/QC keeps metric evidence, feature ids and raw findings available but compresses provenance and history surfaces so review starts from the selected check and map evidence actions. Exports uses denser handoff cards, latest-artifact cards and history filters so artifact creation and download paths are easier to scan.
When project data loads and no dataset is selected yet, the workbench auto-opens the first ready vector dataset. This gives Data, Map and Exports an immediately usable default context while preserving explicit user selection once the user picks another dataset.
+27 -6
View File
@@ -29,6 +29,7 @@ import { useProjectWorkspace } from './hooks/useProjectWorkspace'
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
import { useSegmentationWorkflow } from './hooks/useSegmentationWorkflow'
import { useWorkbenchBootstrap } from './hooks/useWorkbenchBootstrap'
import { useViewportVectorLayer } from './hooks/useViewportVectorLayer'
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
@@ -344,6 +345,15 @@ function App(): JSX.Element {
loadProjectData,
loadQualityChecks,
})
const viewportVectorLayer = useViewportVectorLayer({
selectedProjectId,
selectedDataset,
featureCount: selectedDatasetSummary?.feature_count ?? selectedDataset?.feature_count ?? null,
isVectorDatasetType,
})
const analysisMapLayerActive = Boolean(changeDetectionResult?.geojson || segmentationGeoJson || detectionGeoJson)
const viewportVectorLayerActive = viewportVectorLayer.enabled && !analysisMapLayerActive
const datasetMapContent = viewportVectorLayer.enabled ? viewportVectorLayer.data : datasetContent
const useRasterTileManifestForSegmentation = () => {
const manifestPath = latestRasterTileManifestPath.trim()
if (!manifestPath || !selectedDataset || selectedDataset.dataset_type !== 'raster') {
@@ -403,8 +413,9 @@ function App(): JSX.Element {
changeDetectionGeoJson: changeDetectionResult?.geojson ?? null,
segmentationGeoJson,
detectionGeoJson,
datasetContent,
datasetContent: datasetMapContent,
selectedDataset,
datasetLayerActive: Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type)),
})
const {
mapSelectionBbox,
@@ -519,11 +530,11 @@ function App(): JSX.Element {
if (detectionGeoJson) {
return 'Detection run'
}
if (datasetContent && selectedDataset) {
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
return `${selectedDataset.dataset_type} dataset`
}
return 'No active vector or result layer'
}, [changeDetectionResult?.geojson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
}, [changeDetectionResult?.geojson, datasetMapContent, detectionGeoJson, segmentationGeoJson, selectedDataset, viewportVectorLayer.enabled])
const mapLayerProvenance = useMemo(() => {
if (changeDetectionResult?.geojson) {
return `source ${changeSourceDatasetId || 'n/a'} -> target ${changeTargetDatasetId || 'n/a'}`
@@ -534,7 +545,7 @@ function App(): JSX.Element {
if (detectionGeoJson) {
return selectedDetectionRunId ? `analysis run ${selectedDetectionRunId}` : 'detection results loaded'
}
if (datasetContent && selectedDataset) {
if ((datasetMapContent || viewportVectorLayer.enabled) && selectedDataset) {
return `${selectedDataset.dataset_role ?? 'source'} / ${selectedDataset.source_name ?? selectedDataset.source}`
}
return 'Open a dataset, detection run, segmentation run or change result to draw it here.'
@@ -542,12 +553,13 @@ function App(): JSX.Element {
changeDetectionResult?.geojson,
changeSourceDatasetId,
changeTargetDatasetId,
datasetContent,
datasetMapContent,
detectionGeoJson,
segmentationGeoJson,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
viewportVectorLayer.enabled,
])
const openDatasetInMap = (dataset: DatasetCreateResponse) => {
if (selectedProjectId) {
@@ -643,7 +655,11 @@ function App(): JSX.Element {
const projectContextLabel = selectedProject?.name ?? 'No project'
const areaContextLabel = selectedArea?.name ?? (areas.length > 0 ? 'Select area' : 'No AOI')
const datasetContextLabel = selectedDataset?.name ?? (datasets.length > 0 ? 'Select dataset' : 'No dataset')
const layerContextLabel = mapFeatureCollection ? `${mapFeatureCount} features` : 'No active layer'
const layerContextLabel = mapFeatureCollection
? `${mapFeatureCount} features`
: viewportVectorLayerActive
? 'Viewport layer selected'
: 'No active layer'
return (
<div className="app-shell workbench-shell">
@@ -905,6 +921,10 @@ function App(): JSX.Element {
areaLayerOpacity={areaLayerOpacity}
mapFeatureCount={mapFeatureCount}
areaFeatureCount={areaFeatureCount}
viewportVectorEnabled={viewportVectorLayerActive}
viewportVectorStatus={viewportVectorLayerActive ? viewportVectorLayer.statusMessage : null}
viewportVectorTone={viewportVectorLayer.error ? 'error' : viewportVectorLayer.truncated ? 'warning' : viewportVectorLayer.loading || viewportVectorLayer.zoomRequired ? 'pending' : 'ready'}
fitMapDataOnChange={!viewportVectorLayerActive}
selectedMapFeature={selectedMapFeature}
mapSelectionBbox={mapSelectionBbox}
mapSelectionResult={mapSelectionResult}
@@ -933,6 +953,7 @@ function App(): JSX.Element {
onSetMapLayerVisible={setMapLayerVisible}
onSetMapLayerOpacity={setMapLayerOpacity}
onSelectMapFeature={setSelectedMapFeature}
onMapViewportChange={viewportVectorLayer.setViewport}
onSetMapSelectionBbox={setMapSelectionBbox}
onRunMapSelectionExtract={runMapSelectionExtract}
onClearMapSelectionExtract={resetMapSelectionExtract}
+33 -5
View File
@@ -3,6 +3,7 @@ import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { PRIMARY_FOCUS_CENTER } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapViewportState } from '../types'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
@@ -16,8 +17,10 @@ interface GeoMapProps {
opacity?: number
areaVisible?: boolean
areaOpacity?: number
fitDataOnChange?: boolean
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
onMapCoordinateSelect?: (coordinate: [number, number]) => void
onViewportChange?: (viewport: MapViewportState) => void
}
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
@@ -108,14 +111,18 @@ function GeoMap({
opacity = 0.4,
areaVisible = true,
areaOpacity = 0.18,
fitDataOnChange = true,
onFeatureSelect,
onMapCoordinateSelect,
onViewportChange,
}: GeoMapProps): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
const onViewportChangeRef = useRef<GeoMapProps['onViewportChange']>(onViewportChange)
const bboxSelectionModeRef = useRef(bboxSelectionMode)
const lastFittedAreaRef = useRef<GeoJSON.FeatureCollection | null>(null)
const [mapStyleReady, setMapStyleReady] = useState(false)
useEffect(() => {
@@ -126,6 +133,10 @@ function GeoMap({
onMapCoordinateSelectRef.current = onMapCoordinateSelect
}, [onMapCoordinateSelect])
useEffect(() => {
onViewportChangeRef.current = onViewportChange
}, [onViewportChange])
useEffect(() => {
bboxSelectionModeRef.current = bboxSelectionMode
if (mapRef.current) {
@@ -152,9 +163,24 @@ function GeoMap({
}),
'bottom-right',
)
const emitViewport = () => {
const bounds = map.getBounds()
onViewportChangeRef.current?.({
bbox: {
min_x: bounds.getWest(),
min_y: bounds.getSouth(),
max_x: bounds.getEast(),
max_y: bounds.getNorth(),
crs: 'EPSG:4326',
},
zoom: map.getZoom(),
})
}
map.on('load', () => {
setMapStyleReady(true)
emitViewport()
})
map.on('moveend', emitViewport)
map.on('click', (event) => {
if (bboxSelectionModeRef.current) {
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
@@ -270,7 +296,7 @@ function GeoMap({
})
}
if (data) {
if (data && fitDataOnChange) {
const collection = data
if (collection.type === 'FeatureCollection' && collection.features.length > 0) {
const bounds = collectCoordinates(collection)
@@ -279,7 +305,7 @@ function GeoMap({
}
}
}
}, [data, mapStyleReady])
}, [data, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
@@ -329,14 +355,16 @@ function GeoMap({
)
}
const activeCollection = mergeFeatureCollections([areaData, data])
if (activeCollection) {
const activeCollection = fitDataOnChange ? mergeFeatureCollections([areaData, data]) : areaData
const shouldFitArea = fitDataOnChange || lastFittedAreaRef.current !== areaData
if (activeCollection && shouldFitArea) {
const bounds = collectCoordinates(activeCollection)
if (bounds) {
map.fitBounds(bounds, { padding: 40 })
}
}
}, [areaData, data, mapStyleReady])
lastFittedAreaRef.current = areaData
}, [areaData, data, fitDataOnChange, mapStyleReady])
useEffect(() => {
const map = mapRef.current
+40 -9
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import GeoMap from '../GeoMap'
import type { AreaRead, DatasetCreateResponse, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import type { AreaRead, DatasetCreateResponse, MapViewportState, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
import { featureCollectionBounds } from '../../lib/geojsonBounds'
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
@@ -189,6 +189,10 @@ interface MapWorkspaceProps {
areaLayerOpacity: number
mapFeatureCount: number
areaFeatureCount: number
viewportVectorEnabled: boolean
viewportVectorStatus: string | null
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
fitMapDataOnChange: boolean
selectedMapFeature: GeoJSON.Feature | null
selectedFeature?: GeoJSON.Feature | null
mapSelectionBbox: VectorSelectionBBox | null
@@ -217,6 +221,7 @@ interface MapWorkspaceProps {
onSetMapLayerVisible: (visible: boolean) => void
onSetMapLayerOpacity: (opacity: number) => void
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
onMapViewportChange: (viewport: MapViewportState) => void
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => Promise<VectorSelectionResponse | null>
onClearMapSelectionExtract: () => void
@@ -247,6 +252,10 @@ export function MapWorkspace({
areaLayerOpacity,
mapFeatureCount,
areaFeatureCount,
viewportVectorEnabled,
viewportVectorStatus,
viewportVectorTone,
fitMapDataOnChange,
selectedMapFeature,
selectedFeature = selectedMapFeature,
mapSelectionBbox,
@@ -275,6 +284,7 @@ export function MapWorkspace({
onSetMapLayerVisible,
onSetMapLayerOpacity,
onSelectMapFeature,
onMapViewportChange,
onSetMapSelectionBbox,
onRunMapSelectionExtract,
onClearMapSelectionExtract,
@@ -485,7 +495,9 @@ export function MapWorkspace({
<p className="eyebrow">Spatial review</p>
<h2>Map workspace</h2>
</div>
<span className="count-pill">{mapFeatureCollection ? `${mapFeatureCount} features` : 'no layer'}</span>
<span className="count-pill">
{mapFeatureCollection ? `${mapFeatureCount} features` : viewportVectorEnabled ? 'zoom to load' : 'no layer'}
</span>
</div>
<div className="map-control-surface" aria-label="Map workspace controls">
@@ -555,7 +567,7 @@ export function MapWorkspace({
<label className="checkbox-row">
<input
checked={mapLayerVisible}
disabled={!mapFeatureCollection}
disabled={!mapFeatureCollection && !viewportVectorEnabled}
type="checkbox"
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
data-testid="map-layer-visible"
@@ -564,7 +576,7 @@ export function MapWorkspace({
</label>
<input
aria-label="Layer opacity"
disabled={!mapFeatureCollection}
disabled={!mapFeatureCollection && !viewportVectorEnabled}
max="1"
min="0.05"
step="0.05"
@@ -578,7 +590,18 @@ export function MapWorkspace({
<strong>{mapLayerLabel}</strong>
<span>{selectedMapDataset ? `DB layer: ${selectedMapDataset.name}` : 'No database layer selected'}</span>
<span>{areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'}</span>
<span>{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector/result layer loaded'}</span>
<span>
{mapFeatureCollection
? `${mapFeatureCount} features loaded`
: viewportVectorEnabled
? 'Database layer selected; visible features load by viewport'
: 'No vector/result layer loaded'}
</span>
{viewportVectorEnabled && viewportVectorStatus ? (
<span className={`viewport-vector-status viewport-vector-status-${viewportVectorTone}`} role={viewportVectorTone === 'error' ? 'alert' : 'status'}>
{viewportVectorStatus}
</span>
) : null}
</div>
</div>
</div>
@@ -596,15 +619,19 @@ export function MapWorkspace({
opacity={mapLayerOpacity}
areaVisible={areaLayerVisible}
areaOpacity={areaLayerOpacity}
fitDataOnChange={fitMapDataOnChange}
onFeatureSelect={onSelectMapFeature}
onMapCoordinateSelect={handleMapCoordinateSelect}
onViewportChange={onMapViewportChange}
/>
</div>
<details className="map-layer-details">
<summary>
<span>Layer details</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active layer'}</strong>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport layer selected' : 'No active layer'}
</strong>
</summary>
<div className="map-context-summary" aria-label="Map layer status">
<div>
@@ -619,7 +646,9 @@ export function MapWorkspace({
</div>
<div>
<span>Feature state</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No layer rendered'}</strong>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Awaiting viewport detail' : 'No layer rendered'}
</strong>
<small>{mapLayerProvenance}</small>
</div>
<div>
@@ -639,7 +668,9 @@ export function MapWorkspace({
</div>
<div>
<span>Draw state</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} rendered features` : 'No active vector or result layer'}</strong>
<strong>
{mapFeatureCollection ? `${mapFeatureCount} rendered features` : viewportVectorEnabled ? 'Viewport delivery active' : 'No active vector or result layer'}
</strong>
</div>
<div>
<span>QA evidence overlay</span>
@@ -672,7 +703,7 @@ export function MapWorkspace({
</div>
) : null}
{!mapFeatureCollection ? (
{!mapFeatureCollection && !viewportVectorEnabled ? (
<div className="empty-state map-empty-state">
<strong>No active vector or result layer</strong>
<p>Open a dataset, detection run, segmentation run or change result to draw it here.</p>
+4
View File
@@ -0,0 +1,4 @@
export const VECTOR_VIEWPORT_FEATURE_THRESHOLD = 5_000
export const VECTOR_VIEWPORT_MIN_ZOOM = 14
export const VECTOR_VIEWPORT_FEATURE_LIMIT = 1_000
export const VECTOR_VIEWPORT_DEBOUNCE_MS = 250
+7 -5
View File
@@ -12,6 +12,7 @@ import type {
} from '../types'
import { formatError } from '../lib/formatError'
import { isPrimaryFocusMunicipalityBoundaryDataset } from '../config/primaryFocus'
import { VECTOR_VIEWPORT_FEATURE_THRESHOLD } from '../config/vectorDelivery'
interface DatasetWorkflowOptions {
selectedProjectId: string | null
@@ -165,12 +166,13 @@ export function useDatasetWorkflow({
setJobs([])
try {
if (isVectorDatasetType(dataset.dataset_type)) {
const [content, summary] = await Promise.all([
datasetsApi.getContent(projectId, dataset.id),
datasetsApi.vectorSummary(projectId, dataset.id),
])
setDatasetContent(content)
const summary = await datasetsApi.vectorSummary(projectId, dataset.id)
setSelectedDatasetSummary(summary)
const featureCount = summary.feature_count ?? dataset.feature_count
if (featureCount == null || featureCount <= VECTOR_VIEWPORT_FEATURE_THRESHOLD) {
const content = await datasetsApi.getContent(projectId, dataset.id)
setDatasetContent(content)
}
} else if (dataset.dataset_type === 'raster') {
try {
const rasterInspection = await datasetsApi.rasterInspect(projectId, dataset.id)
+4 -2
View File
@@ -8,6 +8,7 @@ interface MapWorkspaceStateOptions {
detectionGeoJson: GeoJSON.FeatureCollection | null
datasetContent: GeoJSON.FeatureCollection | null
selectedDataset: DatasetCreateResponse | null
datasetLayerActive?: boolean
}
export function useMapWorkspaceState({
@@ -17,6 +18,7 @@ export function useMapWorkspaceState({
detectionGeoJson,
datasetContent,
selectedDataset,
datasetLayerActive = Boolean(datasetContent),
}: MapWorkspaceStateOptions) {
const [mapLayerVisible, setMapLayerVisible] = useState(true)
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
@@ -72,11 +74,11 @@ export function useMapWorkspaceState({
if (detectionGeoJson) {
return 'Detection result'
}
if (datasetContent && selectedDataset) {
if (datasetLayerActive && selectedDataset) {
return selectedDataset.name
}
return 'No active vector layer'
}, [changeDetectionGeoJson, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
}, [changeDetectionGeoJson, datasetLayerActive, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
@@ -0,0 +1,134 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
VECTOR_VIEWPORT_DEBOUNCE_MS,
VECTOR_VIEWPORT_FEATURE_LIMIT,
VECTOR_VIEWPORT_FEATURE_THRESHOLD,
VECTOR_VIEWPORT_MIN_ZOOM,
} from '../config/vectorDelivery'
import { formatError } from '../lib/formatError'
import { datasetsApi } from '../services/api/datasets'
import type { DatasetCreateResponse, MapViewportState } from '../types'
interface ViewportVectorLayerOptions {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
featureCount: number | null
isVectorDatasetType: (datasetType: string) => boolean
}
export function useViewportVectorLayer({
selectedProjectId,
selectedDataset,
featureCount,
isVectorDatasetType,
}: ViewportVectorLayerOptions) {
const [viewport, setViewport] = useState<MapViewportState | null>(null)
const [data, setData] = useState<GeoJSON.FeatureCollection | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [truncated, setTruncated] = useState(false)
const [loadedFeatureCount, setLoadedFeatureCount] = useState(0)
const requestSequence = useRef(0)
const enabled = Boolean(
selectedProjectId &&
selectedDataset &&
isVectorDatasetType(selectedDataset.dataset_type) &&
(featureCount ?? 0) > VECTOR_VIEWPORT_FEATURE_THRESHOLD,
)
const zoom = viewport?.zoom ?? null
const zoomRequired = enabled && (zoom === null || zoom < VECTOR_VIEWPORT_MIN_ZOOM)
useEffect(() => {
requestSequence.current += 1
setData(null)
setLoading(false)
setError(null)
setTruncated(false)
setLoadedFeatureCount(0)
}, [enabled, selectedDataset?.id, selectedProjectId])
useEffect(() => {
if (!enabled || !selectedProjectId || !selectedDataset || !viewport) {
return
}
if (viewport.zoom < VECTOR_VIEWPORT_MIN_ZOOM) {
requestSequence.current += 1
setData(null)
setLoading(false)
setError(null)
setTruncated(false)
setLoadedFeatureCount(0)
return
}
const sequence = requestSequence.current + 1
requestSequence.current = sequence
const timer = window.setTimeout(async () => {
setLoading(true)
setError(null)
try {
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
bbox: viewport.bbox,
limit: VECTOR_VIEWPORT_FEATURE_LIMIT,
})
if (requestSequence.current !== sequence) {
return
}
setData(response.geojson)
setLoadedFeatureCount(response.feature_count)
setTruncated(response.truncated)
} catch (requestError) {
if (requestSequence.current !== sequence) {
return
}
setData(null)
setLoadedFeatureCount(0)
setTruncated(false)
setError(formatError(requestError, 'Unable to load visible vector features'))
} finally {
if (requestSequence.current === sequence) {
setLoading(false)
}
}
}, VECTOR_VIEWPORT_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
}
}, [enabled, selectedDataset, selectedProjectId, viewport])
const statusMessage = useMemo(() => {
if (!enabled) {
return null
}
if (zoomRequired) {
return `Zoom in to level ${VECTOR_VIEWPORT_MIN_ZOOM} to load buildings from PostGIS.`
}
if (loading) {
return 'Loading visible features from PostGIS...'
}
if (error) {
return error
}
if (truncated) {
return `${loadedFeatureCount.toLocaleString()} visible features loaded; zoom in further because this view exceeds the ${VECTOR_VIEWPORT_FEATURE_LIMIT.toLocaleString()} feature limit.`
}
return `${loadedFeatureCount.toLocaleString()} visible of ${(featureCount ?? 0).toLocaleString()} total features loaded from PostGIS.`
}, [enabled, error, featureCount, loadedFeatureCount, loading, truncated, zoomRequired])
return {
enabled,
data,
loading,
error,
truncated,
loadedFeatureCount,
featureLimit: VECTOR_VIEWPORT_FEATURE_LIMIT,
minZoom: VECTOR_VIEWPORT_MIN_ZOOM,
zoom,
zoomRequired,
statusMessage,
setViewport,
}
}
+27
View File
@@ -2611,6 +2611,33 @@ button.entity-card {
font-size: 0.84rem;
}
.map-status .viewport-vector-status {
grid-column: 1 / -1;
padding: 0.5rem 0.65rem;
border: 1px solid #bae6fd;
background: #f0f9ff;
color: #075985;
font-weight: 700;
}
.map-status .viewport-vector-status-ready {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
.map-status .viewport-vector-status-warning {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
.map-status .viewport-vector-status-error {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.layer-provenance-rail {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
+5
View File
@@ -292,6 +292,11 @@ export interface VectorSelectionBBox {
crs?: 'EPSG:4326'
}
export interface MapViewportState {
bbox: VectorSelectionBBox
zoom: number
}
export interface VectorSelectionRequest {
bbox: VectorSelectionBBox
limit?: number