|
| 1 | +// Shared validation for STAC spatial-extent bboxes, used both when creating a |
| 2 | +// collection (to block submit) and when viewing one (to warn). A STAC 2D bbox |
| 3 | +// is [minLon, minLat, maxLon, maxLat]; the 3D form inserts elevation as |
| 4 | +// [minLon, minLat, minElev, maxLon, maxLat, maxElev], so we read the |
| 5 | +// horizontal corners out of either shape before checking them. |
| 6 | + |
| 7 | +export const COORD_REQUIRED_MSG = 'Must be a valid number'; |
| 8 | +export const LON_RANGE_MSG = 'Longitude must be between -180 and 180'; |
| 9 | +export const LAT_RANGE_MSG = 'Latitude must be between -90 and 90'; |
| 10 | +export const LAT_ORDER_MSG = 'Max latitude must be greater than min latitude'; |
| 11 | +export const LAT_ZERO_MSG = |
| 12 | + 'Min and max latitude are equal — the extent has zero height'; |
| 13 | +export const LON_ZERO_MSG = |
| 14 | + 'Min and max longitude are equal — the extent has zero width'; |
| 15 | + |
| 16 | +// Index of a coordinate within the 4-value horizontal bbox. |
| 17 | +export const MIN_LON = 0; |
| 18 | +export const MIN_LAT = 1; |
| 19 | +export const MAX_LON = 2; |
| 20 | +export const MAX_LAT = 3; |
| 21 | + |
| 22 | +export interface BboxIssue { |
| 23 | + // Position in the horizontal [minLon, minLat, maxLon, maxLat] tuple the issue |
| 24 | + // attaches to — used to key inline form errors. |
| 25 | + index: number; |
| 26 | + message: string; |
| 27 | +} |
| 28 | + |
| 29 | +const toNumber = (v: unknown): number => { |
| 30 | + if (typeof v === 'number') return v; |
| 31 | + if (typeof v === 'string' && v.trim() !== '') return Number(v); |
| 32 | + return NaN; |
| 33 | +}; |
| 34 | + |
| 35 | +const isFiniteNum = (n: number): boolean => Number.isFinite(n); |
| 36 | + |
| 37 | +// Pull the four horizontal corners out of a 2D ([4]) or 3D ([6]) bbox. Returns |
| 38 | +// null when the array isn't one of those shapes. |
| 39 | +function horizontalCorners( |
| 40 | + bbox: unknown |
| 41 | +): [unknown, unknown, unknown, unknown] | null { |
| 42 | + if (!Array.isArray(bbox)) return null; |
| 43 | + if (bbox.length === 4) return [bbox[0], bbox[1], bbox[2], bbox[3]]; |
| 44 | + if (bbox.length === 6) return [bbox[0], bbox[1], bbox[3], bbox[4]]; |
| 45 | + return null; |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Inspects a single bbox and returns the problems found, each tagged with the |
| 50 | + * coordinate index it relates to. Rules: |
| 51 | + * - every horizontal corner must be a finite number; |
| 52 | + * - longitudes in [-180, 180], latitudes in [-90, 90]; |
| 53 | + * - min latitude must be below max latitude (south < north); |
| 54 | + * - the extent must have area — identical min/max on either axis is rejected |
| 55 | + * (this catches degenerate boxes like [-90, 90, -90, 90]). |
| 56 | + * West < East is intentionally NOT required: a bbox may cross the antimeridian |
| 57 | + * with minLon > maxLon. Only an exact min === max longitude is degenerate. |
| 58 | + */ |
| 59 | +export function inspectBbox(bbox: unknown): BboxIssue[] { |
| 60 | + const corners = horizontalCorners(bbox); |
| 61 | + if (!corners) return []; |
| 62 | + |
| 63 | + const [minLon, minLat, maxLon, maxLat] = corners.map(toNumber); |
| 64 | + const issues: BboxIssue[] = []; |
| 65 | + |
| 66 | + const checkFinite = (value: number, index: number) => { |
| 67 | + if (!isFiniteNum(value)) { |
| 68 | + issues.push({ index, message: COORD_REQUIRED_MSG }); |
| 69 | + return false; |
| 70 | + } |
| 71 | + return true; |
| 72 | + }; |
| 73 | + |
| 74 | + const minLonOk = checkFinite(minLon, MIN_LON); |
| 75 | + const minLatOk = checkFinite(minLat, MIN_LAT); |
| 76 | + const maxLonOk = checkFinite(maxLon, MAX_LON); |
| 77 | + const maxLatOk = checkFinite(maxLat, MAX_LAT); |
| 78 | + |
| 79 | + let lonRangeOk = minLonOk && maxLonOk; |
| 80 | + let latRangeOk = minLatOk && maxLatOk; |
| 81 | + |
| 82 | + if (minLonOk && (minLon < -180 || minLon > 180)) { |
| 83 | + issues.push({ index: MIN_LON, message: LON_RANGE_MSG }); |
| 84 | + lonRangeOk = false; |
| 85 | + } |
| 86 | + if (maxLonOk && (maxLon < -180 || maxLon > 180)) { |
| 87 | + issues.push({ index: MAX_LON, message: LON_RANGE_MSG }); |
| 88 | + lonRangeOk = false; |
| 89 | + } |
| 90 | + if (minLatOk && (minLat < -90 || minLat > 90)) { |
| 91 | + issues.push({ index: MIN_LAT, message: LAT_RANGE_MSG }); |
| 92 | + latRangeOk = false; |
| 93 | + } |
| 94 | + if (maxLatOk && (maxLat < -90 || maxLat > 90)) { |
| 95 | + issues.push({ index: MAX_LAT, message: LAT_RANGE_MSG }); |
| 96 | + latRangeOk = false; |
| 97 | + } |
| 98 | + |
| 99 | + // Ordering / zero-area only make sense once the corners are valid numbers in |
| 100 | + // range; otherwise the range errors above already point at the real problem. |
| 101 | + if (latRangeOk) { |
| 102 | + if (minLat > maxLat) |
| 103 | + issues.push({ index: MAX_LAT, message: LAT_ORDER_MSG }); |
| 104 | + else if (minLat === maxLat) |
| 105 | + issues.push({ index: MAX_LAT, message: LAT_ZERO_MSG }); |
| 106 | + } |
| 107 | + if (lonRangeOk && minLon === maxLon) { |
| 108 | + issues.push({ index: MAX_LON, message: LON_ZERO_MSG }); |
| 109 | + } |
| 110 | + |
| 111 | + return issues; |
| 112 | +} |
| 113 | + |
| 114 | +/** |
| 115 | + * Collects the distinct human-readable problems across every bbox in a spatial |
| 116 | + * extent. Handy for a single summary warning when viewing a collection. |
| 117 | + */ |
| 118 | +export function summarizeBboxIssues(bboxes: unknown): string[] { |
| 119 | + if (!Array.isArray(bboxes)) return []; |
| 120 | + const messages = new Set<string>(); |
| 121 | + bboxes.forEach((bbox) => |
| 122 | + inspectBbox(bbox).forEach((issue) => messages.add(issue.message)) |
| 123 | + ); |
| 124 | + return Array.from(messages); |
| 125 | +} |
0 commit comments