From 4dfb61c5699a3c09aa13e6f175d9884859b85f3f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 8 Jun 2026 14:16:23 -0700 Subject: [PATCH 1/2] fix: tolerate invalid/degenerate bboxes when fitting the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A collection or item whose bbox sits at the Web Mercator singularity (latitude ±90°, e.g. [-90, 90, -90, 90]) made fitBounds produce NaN center coordinates and an out-of-range tile request (.../22/1048576/0.png), crashing the map. Add a shared sanitizeBbox helper that drops non-finite corners and clamps lon/lat to ±180 / ±85.0511°, and call it from both CollectionMap and ItemMap before fitBounds. Also cap fitBounds at maxZoom and guard it in try/catch so a zero-area extent can't zoom absurdly or take down the app. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/client/src/components/Map/bounds.ts | 27 ++++++++++++++ packages/client/src/components/Map/index.ts | 1 + .../pages/CollectionDetail/CollectionMap.tsx | 36 +++++++++++-------- .../client/src/pages/ItemDetail/ItemMap.tsx | 17 ++++++--- 4 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 packages/client/src/components/Map/bounds.ts diff --git a/packages/client/src/components/Map/bounds.ts b/packages/client/src/components/Map/bounds.ts new file mode 100644 index 0000000..23dd102 --- /dev/null +++ b/packages/client/src/components/Map/bounds.ts @@ -0,0 +1,27 @@ +// Web Mercator can't represent the poles — latitude projects to infinity at +// ±90°, which yields NaN map coordinates and out-of-range tile requests (e.g. +// .../22/1048576/0.png) that crash the map. Clamp to the standard Mercator +// limit and keep lon/lat finite and in range before handing corners to maplibre. +export const MERCATOR_MAX_LAT = 85.0511287798066; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +/** + * Returns a [west, south, east, north] tuple safe for LngLatBounds/fitBounds, + * or null when the corners aren't a usable set of finite numbers. Longitudes + * are clamped to ±180 and latitudes to the Web Mercator limit so a degenerate + * or pole-adjacent extent can't produce an invalid map view. + */ +export function sanitizeBbox( + bbox: number[] +): [number, number, number, number] | null { + const [x1, y1, x2, y2] = bbox; + if (![x1, y1, x2, y2].every((n) => Number.isFinite(n))) return null; + return [ + clamp(x1, -180, 180), + clamp(y1, -MERCATOR_MAX_LAT, MERCATOR_MAX_LAT), + clamp(x2, -180, 180), + clamp(y2, -MERCATOR_MAX_LAT, MERCATOR_MAX_LAT) + ]; +} diff --git a/packages/client/src/components/Map/index.ts b/packages/client/src/components/Map/index.ts index d2e4cb2..c97c926 100644 --- a/packages/client/src/components/Map/index.ts +++ b/packages/client/src/components/Map/index.ts @@ -1,3 +1,4 @@ import BackgroundTiles from './BackgroundTiles'; export { BackgroundTiles }; +export { sanitizeBbox, MERCATOR_MAX_LAT } from './bounds'; diff --git a/packages/client/src/pages/CollectionDetail/CollectionMap.tsx b/packages/client/src/pages/CollectionDetail/CollectionMap.tsx index 8f9e6ec..7058103 100644 --- a/packages/client/src/pages/CollectionDetail/CollectionMap.tsx +++ b/packages/client/src/pages/CollectionDetail/CollectionMap.tsx @@ -3,7 +3,7 @@ import Map, { Layer, Source, MapRef } from 'react-map-gl/maplibre'; import { LngLatBounds } from 'maplibre-gl'; import bboxPolygon from '@turf/bbox-polygon'; -import { BackgroundTiles } from '../../components/Map'; +import { BackgroundTiles, sanitizeBbox } from '../../components/Map'; import { StacCollection } from 'stac-ts'; const extentOutline = { @@ -47,19 +47,27 @@ function CollectionMap({ collection }: CollectionMapProps) { // Fit the map view around the current collection extent useEffect(() => { - if (collection && map) { - let [x1, y1, x2, y2] = collection.extent.spatial.bbox[0]; - const bounds = new LngLatBounds([x1, y1, x2, y2]); - for ( - let i = 1, len = collection.extent.spatial.bbox.length; - i < len; - i++ - ) { - [x1, y1, x2, y2] = collection.extent.spatial.bbox[i]; - bounds.extend([x1, y1, x2, y2]); - } - [x1, y1, x2, y2] = bounds.toArray().flat(); - map.fitBounds([x1, y1, x2, y2], { padding: 30, duration: 0 }); + if (!collection || !map) return; + + const sanitized = collection.extent.spatial.bbox + .map(sanitizeBbox) + .filter((b): b is [number, number, number, number] => b !== null); + if (sanitized.length === 0) return; + + const bounds = new LngLatBounds(sanitized[0]); + for (let i = 1; i < sanitized.length; i++) { + const [x1, y1, x2, y2] = sanitized[i]; + bounds.extend([x1, y1, x2, y2]); + } + + // maxZoom keeps a zero-area extent (a single point, e.g. a bbox like + // [-90, 90, -90, 90]) from fitting to an absurd zoom level. Guard the + // call so a still-degenerate extent can't take the whole app down. + try { + map.fitBounds(bounds, { padding: 30, duration: 0, maxZoom: 12 }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Unable to fit map to collection extent', error); } }, [collection, map]); diff --git a/packages/client/src/pages/ItemDetail/ItemMap.tsx b/packages/client/src/pages/ItemDetail/ItemMap.tsx index 088200b..3105e2f 100644 --- a/packages/client/src/pages/ItemDetail/ItemMap.tsx +++ b/packages/client/src/pages/ItemDetail/ItemMap.tsx @@ -3,7 +3,7 @@ import Map, { Source, Layer, MapRef, ErrorEvent } from 'react-map-gl/maplibre'; import { StacItem } from 'stac-ts'; import getBbox from '@turf/bbox'; -import { BackgroundTiles } from '$components/Map'; +import { BackgroundTiles, sanitizeBbox } from '$components/Map'; const resultsOutline = { 'line-color': '#C53030', @@ -30,11 +30,18 @@ export function ItemMap( // Fit the map view around the current results bbox useEffect(() => { - const bounds = item && getBbox(item as GeoJSON.Feature); + if (!map || !item) return; - if (map && bounds) { - const [x1, y1, x2, y2] = bounds; - map.fitBounds([x1, y1, x2, y2], { padding: 30, duration: 0 }); + const bounds = sanitizeBbox(getBbox(item as GeoJSON.Feature)); + if (!bounds) return; + + // maxZoom keeps a zero-area geometry (a single point) from fitting to an + // absurd zoom; the guard stops a still-degenerate extent from crashing. + try { + map.fitBounds(bounds, { padding: 30, duration: 0, maxZoom: 12 }); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Unable to fit map to item extent', error); } }, [item, map]); From 4357e83a4c1f3024e99591fa34f34c4646abdf9f Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 8 Jun 2026 15:06:47 -0700 Subject: [PATCH 2/2] feat: validate collection bboxes on create/edit and warn on view The schema-driven form validation only knew each bbox corner was a number, so geographically invalid or degenerate extents (out-of-range coords, south>north, zero-area boxes like [-90, 90, -90, 90]) could be saved and later crash the map. Add a shared inspectBbox/summarizeBboxIssues util ($utils/bbox) encoding the geographic rules: lon in [-180,180], lat in [-90,90], south. so they render inline and block submit), and the collection detail page shows a non-blocking warning listing the problems when a stored extent is invalid. Unit-tested in bbox.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/CollectionDetail/index.tsx | 38 ++++++ .../src/pages/CollectionForm/EditForm.tsx | 53 +++++++- packages/client/src/utils/bbox.test.ts | 105 +++++++++++++++ packages/client/src/utils/bbox.ts | 125 ++++++++++++++++++ 4 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 packages/client/src/utils/bbox.test.ts create mode 100644 packages/client/src/utils/bbox.ts diff --git a/packages/client/src/pages/CollectionDetail/index.tsx b/packages/client/src/pages/CollectionDetail/index.tsx index bb217c0..137c251 100644 --- a/packages/client/src/pages/CollectionDetail/index.tsx +++ b/packages/client/src/pages/CollectionDetail/index.tsx @@ -23,6 +23,7 @@ import { } from '@chakra-ui/react'; import { useCollection, useStacSearch } from '@developmentseed/stac-react'; import { + CollecticonCircleExclamation, CollecticonEllipsisVertical, CollecticonEye, CollecticonPencil, @@ -36,6 +37,7 @@ import { InnerPageHeader } from '$components/InnerPageHeader'; import { StacBrowserMenuItem } from '$components/StacBrowserMenuItem'; import { ItemCard, ItemCardLoading } from '$components/ItemCard'; import { zeroPad } from '$utils/format'; +import { summarizeBboxIssues } from '$utils/bbox'; import { ButtonWithAuth } from '$components/auth/ButtonWithAuth'; import { DeleteMenuItem } from '$components/DeleteMenuItem'; import SmartLink from '$components/SmartLink'; @@ -105,6 +107,14 @@ function CollectionDetail() { submit(); }, [collections, submit]); + // Surface a non-blocking heads-up when the stored spatial extent is invalid + // or degenerate. The map clamps such bboxes so it won't crash, but the view + // would otherwise silently misrepresent the data. + const bboxIssues = useMemo( + () => summarizeBboxIssues(collection?.extent?.spatial?.bbox), + [collection] + ); + const dateLabel = useMemo(() => { if (!collection) { return; @@ -203,6 +213,34 @@ function CollectionDetail() { + {bboxIssues.length > 0 && ( + + + + + This collection's spatial extent looks invalid + + {bboxIssues.map((message) => ( + + {message} + + ))} + + The map may not display the extent correctly. + + + + )} + . +// so they render inline on the offending coordinate input. +function buildSpatialErrors(values: any): { spatial: any[] } | undefined { + const spatial = values?.spatial; + if (!Array.isArray(spatial)) return undefined; + + const spatialErrors: any[] = []; + let hasError = false; + spatial.forEach((bbox: unknown, i: number) => { + const issues = inspectBbox(bbox); + if (!issues.length) return; + const row: (string | undefined)[] = []; + // First issue per corner wins — enough to point the user at the field. + issues.forEach(({ index, message }) => { + if (row[index] === undefined) row[index] = message; + }); + spatialErrors[i] = row; + hasError = true; + }); + + return hasError ? { spatial: spatialErrors } : undefined; +} + +// Deep-merge two Formik error trees. `base` (schema errors) wins on a leaf +// conflict so a "must be a number" isn't clobbered by a range message; `extra` +// (bbox errors) fills the gaps. Returns undefined when both are empty. +function mergeErrors(base: any, extra: any): any { + if (extra === undefined || extra === null) return base ?? undefined; + if (base === undefined || base === null) return extra; + if (typeof base === 'string' || typeof extra === 'string') return base; + + const out: any = Array.isArray(base) || Array.isArray(extra) ? [] : {}; + const keys = new Set([...Object.keys(base), ...Object.keys(extra)]); + keys.forEach((key) => { + out[key] = mergeErrors(base[key], extra[key]); + }); + return out; +} + export function EditForm(props: { initialData?: any; onSubmit: (data: any, formikHelpers: FormikHelpers) => void; @@ -56,8 +98,15 @@ export function EditForm(props: { validate={(values) => { if (view === 'json') return; - const [, error] = validatePluginsFieldsData(plugins, values); - if (error) return error; + const [, schemaError] = validatePluginsFieldsData( + plugins, + values + ); + const merged = mergeErrors( + schemaError, + buildSpatialErrors(values) + ); + if (merged) return merged; }} > {({ handleSubmit, values, isSubmitting }) => ( diff --git a/packages/client/src/utils/bbox.test.ts b/packages/client/src/utils/bbox.test.ts new file mode 100644 index 0000000..9f7d724 --- /dev/null +++ b/packages/client/src/utils/bbox.test.ts @@ -0,0 +1,105 @@ +/** + * @jest-environment node + */ +import { + inspectBbox, + summarizeBboxIssues, + COORD_REQUIRED_MSG, + LON_RANGE_MSG, + LAT_RANGE_MSG, + LAT_ORDER_MSG, + LAT_ZERO_MSG, + LON_ZERO_MSG, + MIN_LON, + MIN_LAT, + MAX_LON, + MAX_LAT +} from './bbox'; + +const messages = (bbox: unknown) => inspectBbox(bbox).map((i) => i.message); + +describe('inspectBbox', () => { + it('accepts a normal bbox', () => { + expect(inspectBbox([-10, -5, 20, 15])).toEqual([]); + }); + + it('accepts a global bbox touching the poles', () => { + expect(inspectBbox([-180, -90, 180, 90])).toEqual([]); + }); + + it('accepts an antimeridian-crossing bbox (minLon > maxLon)', () => { + expect(inspectBbox([170, -10, -170, 10])).toEqual([]); + }); + + it('flags the degenerate pole point [-90, 90, -90, 90]', () => { + // Zero width (lon min === max) and zero height (lat min === max). + expect(messages([-90, 90, -90, 90])).toEqual( + expect.arrayContaining([LON_ZERO_MSG, LAT_ZERO_MSG]) + ); + }); + + it('flags out-of-range longitude and latitude on the right corner', () => { + expect(inspectBbox([-200, -5, 20, 95])).toEqual( + expect.arrayContaining([ + { index: MIN_LON, message: LON_RANGE_MSG }, + { index: MAX_LAT, message: LAT_RANGE_MSG } + ]) + ); + }); + + it('flags reversed latitude (south > north)', () => { + expect(inspectBbox([-10, 20, 10, -20])).toEqual([ + { index: MAX_LAT, message: LAT_ORDER_MSG } + ]); + }); + + it('flags non-numeric / missing corners', () => { + expect(inspectBbox([-10, null, 'x', 15])).toEqual( + expect.arrayContaining([ + { index: MIN_LAT, message: COORD_REQUIRED_MSG }, + { index: MAX_LON, message: COORD_REQUIRED_MSG } + ]) + ); + }); + + it('does not add ordering/zero errors when a corner is out of range', () => { + // maxLat out of range — we should not also claim zero-height/ordering. + const msgs = messages([0, 0, 10, 200]); + expect(msgs).toContain(LAT_RANGE_MSG); + expect(msgs).not.toContain(LAT_ZERO_MSG); + expect(msgs).not.toContain(LAT_ORDER_MSG); + }); + + it('reads horizontal corners out of a 3D (6-value) bbox', () => { + // [minLon, minLat, minElev, maxLon, maxLat, maxElev] + expect(inspectBbox([-10, -5, 0, 20, 15, 100])).toEqual([]); + expect(messages([-10, 20, 0, 10, -20, 100])).toEqual([LAT_ORDER_MSG]); + }); + + it('ignores arrays that are not a 2D or 3D bbox', () => { + expect(inspectBbox([1, 2, 3])).toEqual([]); + expect(inspectBbox('nope')).toEqual([]); + }); + + it('coerces numeric strings (form inputs arrive as strings)', () => { + expect(inspectBbox(['-10', '-5', '20', '15'])).toEqual([]); + expect(messages(['-10', '5', '20', '5'])).toEqual([LAT_ZERO_MSG]); + }); +}); + +describe('summarizeBboxIssues', () => { + it('returns distinct messages across all bboxes', () => { + const issues = summarizeBboxIssues([ + [-10, -5, 20, 15], // fine + [-90, 90, -90, 90], // zero width + zero height + [0, 0, 10, 200] // lat range + ]); + expect(issues.sort()).toEqual( + [LON_ZERO_MSG, LAT_ZERO_MSG, LAT_RANGE_MSG].sort() + ); + }); + + it('returns [] for a missing/!array extent', () => { + expect(summarizeBboxIssues(undefined)).toEqual([]); + }); +}); diff --git a/packages/client/src/utils/bbox.ts b/packages/client/src/utils/bbox.ts new file mode 100644 index 0000000..c57ed1b --- /dev/null +++ b/packages/client/src/utils/bbox.ts @@ -0,0 +1,125 @@ +// Shared validation for STAC spatial-extent bboxes, used both when creating a +// collection (to block submit) and when viewing one (to warn). A STAC 2D bbox +// is [minLon, minLat, maxLon, maxLat]; the 3D form inserts elevation as +// [minLon, minLat, minElev, maxLon, maxLat, maxElev], so we read the +// horizontal corners out of either shape before checking them. + +export const COORD_REQUIRED_MSG = 'Must be a valid number'; +export const LON_RANGE_MSG = 'Longitude must be between -180 and 180'; +export const LAT_RANGE_MSG = 'Latitude must be between -90 and 90'; +export const LAT_ORDER_MSG = 'Max latitude must be greater than min latitude'; +export const LAT_ZERO_MSG = + 'Min and max latitude are equal — the extent has zero height'; +export const LON_ZERO_MSG = + 'Min and max longitude are equal — the extent has zero width'; + +// Index of a coordinate within the 4-value horizontal bbox. +export const MIN_LON = 0; +export const MIN_LAT = 1; +export const MAX_LON = 2; +export const MAX_LAT = 3; + +export interface BboxIssue { + // Position in the horizontal [minLon, minLat, maxLon, maxLat] tuple the issue + // attaches to — used to key inline form errors. + index: number; + message: string; +} + +const toNumber = (v: unknown): number => { + if (typeof v === 'number') return v; + if (typeof v === 'string' && v.trim() !== '') return Number(v); + return NaN; +}; + +const isFiniteNum = (n: number): boolean => Number.isFinite(n); + +// Pull the four horizontal corners out of a 2D ([4]) or 3D ([6]) bbox. Returns +// null when the array isn't one of those shapes. +function horizontalCorners( + bbox: unknown +): [unknown, unknown, unknown, unknown] | null { + if (!Array.isArray(bbox)) return null; + if (bbox.length === 4) return [bbox[0], bbox[1], bbox[2], bbox[3]]; + if (bbox.length === 6) return [bbox[0], bbox[1], bbox[3], bbox[4]]; + return null; +} + +/** + * Inspects a single bbox and returns the problems found, each tagged with the + * coordinate index it relates to. Rules: + * - every horizontal corner must be a finite number; + * - longitudes in [-180, 180], latitudes in [-90, 90]; + * - min latitude must be below max latitude (south < north); + * - the extent must have area — identical min/max on either axis is rejected + * (this catches degenerate boxes like [-90, 90, -90, 90]). + * West < East is intentionally NOT required: a bbox may cross the antimeridian + * with minLon > maxLon. Only an exact min === max longitude is degenerate. + */ +export function inspectBbox(bbox: unknown): BboxIssue[] { + const corners = horizontalCorners(bbox); + if (!corners) return []; + + const [minLon, minLat, maxLon, maxLat] = corners.map(toNumber); + const issues: BboxIssue[] = []; + + const checkFinite = (value: number, index: number) => { + if (!isFiniteNum(value)) { + issues.push({ index, message: COORD_REQUIRED_MSG }); + return false; + } + return true; + }; + + const minLonOk = checkFinite(minLon, MIN_LON); + const minLatOk = checkFinite(minLat, MIN_LAT); + const maxLonOk = checkFinite(maxLon, MAX_LON); + const maxLatOk = checkFinite(maxLat, MAX_LAT); + + let lonRangeOk = minLonOk && maxLonOk; + let latRangeOk = minLatOk && maxLatOk; + + if (minLonOk && (minLon < -180 || minLon > 180)) { + issues.push({ index: MIN_LON, message: LON_RANGE_MSG }); + lonRangeOk = false; + } + if (maxLonOk && (maxLon < -180 || maxLon > 180)) { + issues.push({ index: MAX_LON, message: LON_RANGE_MSG }); + lonRangeOk = false; + } + if (minLatOk && (minLat < -90 || minLat > 90)) { + issues.push({ index: MIN_LAT, message: LAT_RANGE_MSG }); + latRangeOk = false; + } + if (maxLatOk && (maxLat < -90 || maxLat > 90)) { + issues.push({ index: MAX_LAT, message: LAT_RANGE_MSG }); + latRangeOk = false; + } + + // Ordering / zero-area only make sense once the corners are valid numbers in + // range; otherwise the range errors above already point at the real problem. + if (latRangeOk) { + if (minLat > maxLat) + issues.push({ index: MAX_LAT, message: LAT_ORDER_MSG }); + else if (minLat === maxLat) + issues.push({ index: MAX_LAT, message: LAT_ZERO_MSG }); + } + if (lonRangeOk && minLon === maxLon) { + issues.push({ index: MAX_LON, message: LON_ZERO_MSG }); + } + + return issues; +} + +/** + * Collects the distinct human-readable problems across every bbox in a spatial + * extent. Handy for a single summary warning when viewing a collection. + */ +export function summarizeBboxIssues(bboxes: unknown): string[] { + if (!Array.isArray(bboxes)) return []; + const messages = new Set(); + bboxes.forEach((bbox) => + inspectBbox(bbox).forEach((issue) => messages.add(issue.message)) + ); + return Array.from(messages); +}