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
+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>