Files
geointel/frontend/src/hooks/useViewportVectorLayer.ts
T
Codex 1ba479b50b
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
feat: stream municipality vectors by viewport
2026-07-14 10:47:23 +02:00

135 lines
4.2 KiB
TypeScript

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,
}
}