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 | 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, 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 | 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() 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): 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) }