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/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/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]);
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);
+}