import { BadRequestException } from '@nestjs/common'; import { AssetGeometryType } from '../database/entities'; export interface GeoJsonGeometry { type: AssetGeometryType; coordinates: unknown[]; } type Position = [number, number]; function invalid(message: string): never { throw new BadRequestException({ code: 'INVALID_ASSET_GEOMETRY', message, }); } function position(value: unknown, label: string): Position { if (!Array.isArray(value) || value.length < 2) { return invalid(`${label} debe contener longitud y latitud`); } const longitude = Number(value[0]); const latitude = Number(value[1]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { return invalid(`${label} contiene coordenadas no numéricas`); } if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) { return invalid(`${label} está fuera del rango geográfico válido`); } return [longitude, latitude]; } function positions(value: unknown, minimum: number, label: string): Position[] { if (!Array.isArray(value) || value.length < minimum) { return invalid(`${label} necesita al menos ${minimum} vértices`); } if (value.length > 10_000) return invalid(`${label} supera el máximo de 10000 vértices`); return value.map((item, index) => position(item, `${label} · vértice ${index + 1}`)); } function samePosition(first: Position, last: Position): boolean { return first[0] === last[0] && first[1] === last[1]; } export function validateGeoJsonGeometry(input: GeoJsonGeometry): GeoJsonGeometry { if (!Object.values(AssetGeometryType).includes(input.type)) { return invalid('El tipo de geometría no está permitido'); } if (input.type === AssetGeometryType.POINT) { return { type: input.type, coordinates: position(input.coordinates, 'El punto') }; } if (input.type === AssetGeometryType.LINESTRING) { const line = positions(input.coordinates, 2, 'La línea'); if (new Set(line.map((item) => item.join(','))).size < 2) { return invalid('La línea necesita al menos dos posiciones diferentes'); } return { type: input.type, coordinates: line }; } if (!Array.isArray(input.coordinates) || input.coordinates.length < 1) { return invalid('El polígono necesita al menos un anillo'); } if (input.coordinates.length > 20) return invalid('El polígono supera el máximo de 20 anillos'); const rings = input.coordinates.map((ring, ringIndex) => { const normalized = positions(ring, 4, `Anillo ${ringIndex + 1}`); if (!samePosition(normalized[0]!, normalized[normalized.length - 1]!)) { return invalid(`El anillo ${ringIndex + 1} debe estar cerrado`); } if (new Set(normalized.slice(0, -1).map((item) => item.join(','))).size < 3) { return invalid(`El anillo ${ringIndex + 1} necesita tres posiciones diferentes`); } return normalized; }); return { type: input.type, coordinates: rings }; } export function parseBoundingBox(value?: string): [number, number, number, number] | null { if (!value) return null; const numbers = value.split(',').map(Number); if (numbers.length !== 4 || numbers.some((item) => !Number.isFinite(item))) { return invalid('El área visible del mapa no es válida'); } const [west, south, east, north] = numbers as [number, number, number, number]; if ( west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north ) { return invalid('El área visible del mapa está fuera de rango'); } return [west, south, east, north]; }