Files
geointel/frontend/src/components/map/mapWorkspaceUtils.ts
T
Codex 65749b1694
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
fix: scope evolution series to work area
2026-07-22 06:55:49 +02:00

439 lines
15 KiB
TypeScript

import { featureCollectionBounds } from '../../lib/geojsonBounds'
import type {
DatasetCreateResponse,
ProjectRead,
VectorSelectionBBox,
VectorSelectionMetric,
VectorSelectionResponse,
} from '../../types'
import {
FLANDERS_WORKSPACE_LABEL,
FLANDERS_WORKSPACE_PROJECT_NAME,
} from '../../config/primaryFocus'
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
export function selectedAreaCoverageZones(areaName: string | null | undefined): string[] | null {
const normalized = String(areaName ?? '').toLowerCase()
if (!normalized) return null
if (normalized.includes('coast land-sea')) return ['flanders', 'belgian_north_sea']
if (normalized.includes('language boundary')) return ['flanders', 'wallonia']
if (normalized.includes('north sea')) {
return ['belgian_north_sea', 'territorial_sea', 'exclusive_economic_zone', 'continental_shelf']
}
if (normalized.includes('territorial sea')) return ['territorial_sea']
if (normalized.includes('exclusive economic zone')) return ['exclusive_economic_zone']
if (normalized.includes('continental shelf')) return ['continental_shelf']
if (normalized.includes('brussels')) return ['brussels']
if (normalized.includes('wallonia') || normalized.includes('ardennes')) return ['wallonia']
if (normalized.includes('flanders') || normalized.includes('mol') || normalized.includes('kempen')) return ['flanders']
if (normalized.includes('belgium land')) return ['belgium', 'flanders', 'wallonia', 'brussels']
return null
}
export function isMunicipalityAreaName(areaName: string | null | undefined): boolean {
const normalized = String(areaName ?? '').trim()
return /^Gemeente\s/i.test(normalized) || /\bmunicipality$/i.test(normalized)
}
export function productCoversZones(productZones: string[], selectedZones: string[] | null): boolean {
return selectedZones === null || selectedZones.some((zone) => productZones.includes(zone))
}
export function isSelectionBoundedDataset(
sourceMetadata: Record<string, unknown> | null | undefined,
): boolean {
const boundedScope = sourceMetadata?.['geometry_clipped_to_selection'] === true
|| sourceMetadata?.['coverage_scope'] === 'bounded_selection'
return boundedScope
&& Array.isArray(sourceMetadata?.['bbox_epsg4326'])
&& sourceMetadata['bbox_epsg4326'].length === 4
}
export type TemporalAreaMatch = 'exact' | 'unscoped' | 'other'
export function temporalDatasetAreaMatch(
dataset: Pick<DatasetCreateResponse, 'area_id'>,
selectedAreaId: string | null,
): TemporalAreaMatch {
if (!selectedAreaId || !dataset.area_id) return 'unscoped'
return dataset.area_id === selectedAreaId ? 'exact' : 'other'
}
export function operationalScopeProjectLabel(project: ProjectRead): string {
if (project.name === MOL_PROJECT_NAME) {
return 'Mol'
}
if (project.name === KEMPEN_PROJECT_NAME) {
return 'Kempen (28 gemeenten)'
}
if (project.name === FLANDERS_WORKSPACE_PROJECT_NAME) {
return FLANDERS_WORKSPACE_LABEL
}
return project.name
}
export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
if (!bbox) {
return null
}
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
return Math.max(0, widthMetres * heightMetres)
}
export interface SelectionDimensions {
widthMetres: number
heightMetres: number
areaSquareMetres: number
}
export type SelectionAnalysisScale = 'detail' | 'regional' | 'overview'
export function selectionDimensions(bbox: VectorSelectionBBox): SelectionDimensions {
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
const widthMetres = Math.max(0, (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians))
const heightMetres = Math.max(0, (bbox.max_y - bbox.min_y) * 110_574)
return {
widthMetres,
heightMetres,
areaSquareMetres: widthMetres * heightMetres,
}
}
export function selectionAnalysisScale(bbox: VectorSelectionBBox): SelectionAnalysisScale {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const longestSide = Math.max(widthMetres, heightMetres)
if (longestSide <= 20_000) return 'detail'
if (longestSide <= 50_000) return 'regional'
return 'overview'
}
export function persistedDatasetSupportsSelection(
dataset: { dataset_type: string; source_name?: string | null },
bbox: VectorSelectionBBox,
): boolean {
if (dataset.dataset_type !== 'raster') return true
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return false
const dimensions = selectionDimensions(bbox)
if (dataset.source_name === 'digitaal_vlaanderen_dhmv' || dataset.source_name === 'spw_terrain' || dataset.source_name === 'vmm_flood_hazard') {
return dimensions.areaSquareMetres <= 280_000_000
}
if (dataset.source_name === 'department_omgeving_thematic_raster') {
return dimensions.areaSquareMetres <= 2_800_000_000
}
return scale === 'detail'
}
export function datasetIntersectsSelection(
dataset: { source_metadata?: Record<string, unknown> | null },
bbox: VectorSelectionBBox,
): boolean {
const bounds = dataset.source_metadata?.['bbox_epsg4326']
if (!Array.isArray(bounds) || bounds.length !== 4) {
return true
}
const [minX, minY, maxX, maxY] = bounds.map(Number)
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return true
}
return !(
bbox.max_x < minX
|| bbox.min_x > maxX
|| bbox.max_y < minY
|| bbox.min_y > maxY
)
}
export function deduplicateTemporalSnapshots<
T extends {
id: string
observed_at?: string | null
imported_at?: string | null
created_at?: string | null
},
>(datasets: T[]): T[] {
const byObservation = new Map<string, T>()
for (const dataset of datasets) {
if (!dataset.observed_at) continue
const current = byObservation.get(dataset.observed_at)
const recency = new Date(dataset.imported_at ?? dataset.created_at ?? 0).getTime()
const currentRecency = new Date(current?.imported_at ?? current?.created_at ?? 0).getTime()
if (!current || recency > currentRecency || (recency === currentRecency && dataset.id > current.id)) {
byObservation.set(dataset.observed_at, dataset)
}
}
return Array.from(byObservation.values()).sort(
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
)
}
export function selectionFeatureLimit(bbox: VectorSelectionBBox): number {
const scale = selectionAnalysisScale(bbox)
if (scale === 'overview') return 25
if (scale === 'regional') return 250
return 1000
}
export function splitSelectionBbox(
bbox: VectorSelectionBBox,
maxTileSideMetres = 18_000,
maxTiles = 16,
): VectorSelectionBBox[] {
const { widthMetres, heightMetres } = selectionDimensions(bbox)
const columns = Math.max(1, Math.ceil(widthMetres / maxTileSideMetres))
const rows = Math.max(1, Math.ceil(heightMetres / maxTileSideMetres))
if (columns * rows > maxTiles) {
throw new Error(
`De selectie vereist ${columns * rows} detailpartities; maximaal ${maxTiles} zijn toegestaan.`,
)
}
const longitudeStep = (bbox.max_x - bbox.min_x) / columns
const latitudeStep = (bbox.max_y - bbox.min_y) / rows
const tiles: VectorSelectionBBox[] = []
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
tiles.push({
min_x: bbox.min_x + longitudeStep * column,
min_y: bbox.min_y + latitudeStep * row,
max_x: column === columns - 1 ? bbox.max_x : bbox.min_x + longitudeStep * (column + 1),
max_y: row === rows - 1 ? bbox.max_y : bbox.min_y + latitudeStep * (row + 1),
crs: 'EPSG:4326',
})
}
}
return tiles
}
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
if (!left || !right) {
return false
}
const tolerance = 1e-9
return (
Math.abs(left.min_x - right.min_x) < tolerance
&& Math.abs(left.min_y - right.min_y) < tolerance
&& Math.abs(left.max_x - right.max_x) < tolerance
&& Math.abs(left.max_y - right.max_y) < tolerance
)
}
export function formatArea(areaSquareMetres: number | null): string {
if (areaSquareMetres === null) {
return 'Nog niet geselecteerd'
}
if (areaSquareMetres >= 1_000_000) {
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
}
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
}
export function resultCountLabel(result: VectorSelectionResponse): string {
const total = result.total_feature_count ?? result.feature_count
return result.truncated && result.total_feature_count == null
? `${result.feature_count.toLocaleString('nl-BE')}+`
: total.toLocaleString('nl-BE')
}
export function resultMetricLabel(result: VectorSelectionResponse): string {
if (!result.summary) {
return resultCountLabel(result)
}
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
}
export function selectionMetricLabel(metric: VectorSelectionMetric): string {
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
}
export function formatTemporalMetric(value: number, unit: string): string {
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
}
export function readablePropertyName(value: string): string {
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
}
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
const points: Array<[number, number]> = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
points.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
if ('coordinates' in (geometry ?? {})) {
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
}
return points
}
function formatCoordinate(value: number): string {
return Number.isFinite(value) ? value.toFixed(6) : 'n.v.t.'
}
export function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
const points = collectGeometryPoints(feature?.geometry)
if (!feature?.geometry || points.length === 0) {
return {
bboxLabel: 'n.v.t.',
coordinateCount: 0,
geometryType: feature?.geometry?.type ?? 'geen',
}
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
Math.max(...xs),
)}, ${formatCoordinate(Math.max(...ys))}`
return {
bboxLabel,
coordinateCount: points.length,
geometryType: feature.geometry.type,
}
}
export function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
const bounds = featureCollectionBounds(collection)
if (!bounds) {
return null
}
return {
min_x: bounds.minX,
min_y: bounds.minY,
max_x: bounds.maxX,
max_y: bounds.maxY,
crs: 'EPSG:4326',
}
}
export function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
const points = collectGeometryPoints(feature?.geometry)
if (points.length === 0) {
return null
}
const xs = points.map((point) => point[0])
const ys = points.map((point) => point[1])
return {
min_x: Math.min(...xs),
min_y: Math.min(...ys),
max_x: Math.max(...xs),
max_y: Math.max(...ys),
crs: 'EPSG:4326',
}
}
export function normalizeBboxFromCorners(
first: [number, number],
second: [number, number],
): VectorSelectionBBox {
return {
min_x: Math.min(first[0], second[0]),
min_y: Math.min(first[1], second[1]),
max_x: Math.max(first[0], second[0]),
max_y: Math.max(first[1], second[1]),
crs: 'EPSG:4326',
}
}
export function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
if (!bbox) {
return 'n.v.t.'
}
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
}
export function formatPercentage(value: number | null | undefined): string {
return typeof value === 'number' && Number.isFinite(value)
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
: 'n.v.t.'
}
export function bboxToInputState(bbox: VectorSelectionBBox | null) {
return {
min_x: bbox ? String(bbox.min_x) : '',
min_y: bbox ? String(bbox.min_y) : '',
max_x: bbox ? String(bbox.max_x) : '',
max_y: bbox ? String(bbox.max_y) : '',
}
}
export function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
const min_x = Number(input.min_x)
const min_y = Number(input.min_y)
const max_x = Number(input.max_x)
const max_y = Number(input.max_y)
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
return null
}
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
}
export function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: [feature],
}
}
export function safeFileStem(value: unknown): string {
const stem = String(value ?? 'selected-feature')
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
return stem || 'selected-feature'
}
function fallbackCopyText(text: string): void {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
export function copyText(text: string): void {
if (navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
return
}
fallbackCopyText(text)
}
export function downloadJsonFile(
filename: string,
payload: unknown,
contentType = 'application/json',
): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}