439 lines
15 KiB
TypeScript
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)
|
|
}
|