const GEOMETRY_TYPES = new Set(["Point", "LineString", "Polygon", "MultiPolygon"]); const MAX_GEOMETRY_BYTES = 192 * 1024; const MAX_GEOMETRY_VERTICES = 10_000; /** * Validate the bounded GeoJSON geometry subset accepted by provider-neutral * Data Products. GeometryCollections and arbitrary feature properties are * deliberately excluded: a fact owns exactly one geometry and its attributes * stay under the fact field policy. */ export function validateGeoJsonGeometry(value, path, errors, { allowedTypes = GEOMETRY_TYPES } = {}) { if (!isPlainObject(value) || !allowedTypes.has(value.type) || !Array.isArray(value.coordinates)) { errors.push(`${path}_must_be_bounded_geojson_geometry`); return; } rejectUnknownKeys(value, new Set(["type", "coordinates"]), path, errors); if (serializedByteLength(value) > MAX_GEOMETRY_BYTES) { errors.push(`${path}_size_exceeded`); return; } const vertices = []; if (value.type === "Point") { validatePosition(value.coordinates, path, errors, vertices); } else if (value.type === "LineString") { validateLineString(value.coordinates, path, errors, vertices); } else if (value.type === "Polygon") { validatePolygon(value.coordinates, path, errors, vertices); } else if (value.type === "MultiPolygon") { if (!value.coordinates.length) errors.push(`${path}.coordinates_must_be_nonempty`); value.coordinates.forEach((polygon, index) => validatePolygon(polygon, `${path}.coordinates[${index}]`, errors, vertices)); } if (vertices.length > MAX_GEOMETRY_VERTICES) errors.push(`${path}_vertex_limit_exceeded`); } export function isBoundedGeoJsonGeometry(value, allowedTypes = GEOMETRY_TYPES) { const errors = []; validateGeoJsonGeometry(value, "geometry", errors, { allowedTypes }); return errors.length === 0; } function validatePolygon(value, path, errors, vertices) { if (!Array.isArray(value) || !value.length) { errors.push(`${path}_polygon_rings_required`); return; } value.forEach((ring, index) => { const ringPath = `${path}[${index}]`; if (!Array.isArray(ring) || ring.length < 4) { errors.push(`${ringPath}_linear_ring_requires_four_positions`); return; } ring.forEach((position, positionIndex) => validatePosition(position, `${ringPath}[${positionIndex}]`, errors, vertices)); if (!samePosition(ring[0], ring.at(-1))) errors.push(`${ringPath}_linear_ring_must_be_closed`); }); } function validateLineString(value, path, errors, vertices) { if (!Array.isArray(value) || value.length < 2) { errors.push(`${path}_line_string_requires_two_positions`); return; } value.forEach((position, index) => validatePosition(position, `${path}[${index}]`, errors, vertices)); } function validatePosition(value, path, errors, vertices) { if (!Array.isArray(value) || value.length !== 2 || !value.every(Number.isFinite)) { errors.push(`${path}_position_invalid`); return; } const [longitude, latitude] = value; if (longitude < -180 || longitude > 180) errors.push(`${path}.longitude_out_of_range`); if (latitude < -90 || latitude > 90) errors.push(`${path}.latitude_out_of_range`); vertices.push(value); } function samePosition(left, right) { return Array.isArray(left) && Array.isArray(right) && left.length === 2 && right.length === 2 && Object.is(left[0], right[0]) && Object.is(left[1], right[1]); } function rejectUnknownKeys(value, allowed, path, errors) { for (const key of Object.keys(value)) { if (!allowed.has(key)) errors.push(`${path}.${key}_not_allowed`); } } function serializedByteLength(value) { try { return Buffer.byteLength(JSON.stringify(value)); } catch { return Number.POSITIVE_INFINITY; } } function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } export const DATA_PRODUCT_GEOMETRY_TYPES = Object.freeze([...GEOMETRY_TYPES]); export const DATA_PRODUCT_MAX_GEOMETRY_BYTES = MAX_GEOMETRY_BYTES; export const DATA_PRODUCT_MAX_GEOMETRY_VERTICES = MAX_GEOMETRY_VERTICES;