Skip to content

Commit 4357e83

Browse files
alukachclaude
andcommitted
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<north, and non-zero area on both axes (west<east is NOT required so antimeridian-crossing bboxes stay valid). The collection EditForm now folds per-corner bbox errors into the Formik validation (keyed at spatial.<i>.<corner> 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) <noreply@anthropic.com>
1 parent 4dfb61c commit 4357e83

4 files changed

Lines changed: 319 additions & 2 deletions

File tree

packages/client/src/pages/CollectionDetail/index.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '@chakra-ui/react';
2424
import { useCollection, useStacSearch } from '@developmentseed/stac-react';
2525
import {
26+
CollecticonCircleExclamation,
2627
CollecticonEllipsisVertical,
2728
CollecticonEye,
2829
CollecticonPencil,
@@ -36,6 +37,7 @@ import { InnerPageHeader } from '$components/InnerPageHeader';
3637
import { StacBrowserMenuItem } from '$components/StacBrowserMenuItem';
3738
import { ItemCard, ItemCardLoading } from '$components/ItemCard';
3839
import { zeroPad } from '$utils/format';
40+
import { summarizeBboxIssues } from '$utils/bbox';
3941
import { ButtonWithAuth } from '$components/auth/ButtonWithAuth';
4042
import { DeleteMenuItem } from '$components/DeleteMenuItem';
4143
import SmartLink from '$components/SmartLink';
@@ -105,6 +107,14 @@ function CollectionDetail() {
105107
submit();
106108
}, [collections, submit]);
107109

110+
// Surface a non-blocking heads-up when the stored spatial extent is invalid
111+
// or degenerate. The map clamps such bboxes so it won't crash, but the view
112+
// would otherwise silently misrepresent the data.
113+
const bboxIssues = useMemo(
114+
() => summarizeBboxIssues(collection?.extent?.spatial?.bbox),
115+
[collection]
116+
);
117+
108118
const dateLabel = useMemo(() => {
109119
if (!collection) {
110120
return;
@@ -203,6 +213,34 @@ function CollectionDetail() {
203213
</Box>
204214
</Flex>
205215

216+
{bboxIssues.length > 0 && (
217+
<Flex
218+
mx='8'
219+
gap='3'
220+
p='4'
221+
borderRadius='md'
222+
bg='danger.50'
223+
borderWidth='1px'
224+
borderColor='danger.200'
225+
alignItems='flex-start'
226+
>
227+
<CollecticonCircleExclamation color='danger.500' />
228+
<Box>
229+
<Text fontWeight='bold' color='danger.600'>
230+
This collection&apos;s spatial extent looks invalid
231+
</Text>
232+
{bboxIssues.map((message) => (
233+
<Text key={message} fontSize='sm'>
234+
{message}
235+
</Text>
236+
))}
237+
<Text fontSize='sm' color='base.400'>
238+
The map may not display the extent correctly.
239+
</Text>
240+
</Box>
241+
</Flex>
242+
)}
243+
206244
<Grid templateColumns='repeat(12, 1fr)' gap={8}>
207245
<GridItem colSpan={8}>
208246
<Flex

packages/client/src/pages/CollectionForm/EditForm.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,51 @@ import {
1717
import { InnerPageHeaderSticky } from '$components/InnerPageHeader';
1818
import { CollecticonForm } from '$components/icons/form';
1919
import { AppNotification, NotificationButton } from '$components/Notifications';
20+
import { inspectBbox } from '$utils/bbox';
2021

2122
type FormView = 'fields' | 'json';
2223

24+
// The generic schema → Yup validation only knows each bbox corner is a number;
25+
// it can't express the geographic rules (ranges, ordering, zero-area). Build
26+
// Formik errors for `values.spatial` (number[][]) keyed at spatial.<i>.<corner>
27+
// so they render inline on the offending coordinate input.
28+
function buildSpatialErrors(values: any): { spatial: any[] } | undefined {
29+
const spatial = values?.spatial;
30+
if (!Array.isArray(spatial)) return undefined;
31+
32+
const spatialErrors: any[] = [];
33+
let hasError = false;
34+
spatial.forEach((bbox: unknown, i: number) => {
35+
const issues = inspectBbox(bbox);
36+
if (!issues.length) return;
37+
const row: (string | undefined)[] = [];
38+
// First issue per corner wins — enough to point the user at the field.
39+
issues.forEach(({ index, message }) => {
40+
if (row[index] === undefined) row[index] = message;
41+
});
42+
spatialErrors[i] = row;
43+
hasError = true;
44+
});
45+
46+
return hasError ? { spatial: spatialErrors } : undefined;
47+
}
48+
49+
// Deep-merge two Formik error trees. `base` (schema errors) wins on a leaf
50+
// conflict so a "must be a number" isn't clobbered by a range message; `extra`
51+
// (bbox errors) fills the gaps. Returns undefined when both are empty.
52+
function mergeErrors(base: any, extra: any): any {
53+
if (extra === undefined || extra === null) return base ?? undefined;
54+
if (base === undefined || base === null) return extra;
55+
if (typeof base === 'string' || typeof extra === 'string') return base;
56+
57+
const out: any = Array.isArray(base) || Array.isArray(extra) ? [] : {};
58+
const keys = new Set([...Object.keys(base), ...Object.keys(extra)]);
59+
keys.forEach((key) => {
60+
out[key] = mergeErrors(base[key], extra[key]);
61+
});
62+
return out;
63+
}
64+
2365
export function EditForm(props: {
2466
initialData?: any;
2567
onSubmit: (data: any, formikHelpers: FormikHelpers<any>) => void;
@@ -56,8 +98,15 @@ export function EditForm(props: {
5698
validate={(values) => {
5799
if (view === 'json') return;
58100

59-
const [, error] = validatePluginsFieldsData(plugins, values);
60-
if (error) return error;
101+
const [, schemaError] = validatePluginsFieldsData(
102+
plugins,
103+
values
104+
);
105+
const merged = mergeErrors(
106+
schemaError,
107+
buildSpatialErrors(values)
108+
);
109+
if (merged) return merged;
61110
}}
62111
>
63112
{({ handleSubmit, values, isSubmitting }) => (
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import {
5+
inspectBbox,
6+
summarizeBboxIssues,
7+
COORD_REQUIRED_MSG,
8+
LON_RANGE_MSG,
9+
LAT_RANGE_MSG,
10+
LAT_ORDER_MSG,
11+
LAT_ZERO_MSG,
12+
LON_ZERO_MSG,
13+
MIN_LON,
14+
MIN_LAT,
15+
MAX_LON,
16+
MAX_LAT
17+
} from './bbox';
18+
19+
const messages = (bbox: unknown) => inspectBbox(bbox).map((i) => i.message);
20+
21+
describe('inspectBbox', () => {
22+
it('accepts a normal bbox', () => {
23+
expect(inspectBbox([-10, -5, 20, 15])).toEqual([]);
24+
});
25+
26+
it('accepts a global bbox touching the poles', () => {
27+
expect(inspectBbox([-180, -90, 180, 90])).toEqual([]);
28+
});
29+
30+
it('accepts an antimeridian-crossing bbox (minLon > maxLon)', () => {
31+
expect(inspectBbox([170, -10, -170, 10])).toEqual([]);
32+
});
33+
34+
it('flags the degenerate pole point [-90, 90, -90, 90]', () => {
35+
// Zero width (lon min === max) and zero height (lat min === max).
36+
expect(messages([-90, 90, -90, 90])).toEqual(
37+
expect.arrayContaining([LON_ZERO_MSG, LAT_ZERO_MSG])
38+
);
39+
});
40+
41+
it('flags out-of-range longitude and latitude on the right corner', () => {
42+
expect(inspectBbox([-200, -5, 20, 95])).toEqual(
43+
expect.arrayContaining([
44+
{ index: MIN_LON, message: LON_RANGE_MSG },
45+
{ index: MAX_LAT, message: LAT_RANGE_MSG }
46+
])
47+
);
48+
});
49+
50+
it('flags reversed latitude (south > north)', () => {
51+
expect(inspectBbox([-10, 20, 10, -20])).toEqual([
52+
{ index: MAX_LAT, message: LAT_ORDER_MSG }
53+
]);
54+
});
55+
56+
it('flags non-numeric / missing corners', () => {
57+
expect(inspectBbox([-10, null, 'x', 15])).toEqual(
58+
expect.arrayContaining([
59+
{ index: MIN_LAT, message: COORD_REQUIRED_MSG },
60+
{ index: MAX_LON, message: COORD_REQUIRED_MSG }
61+
])
62+
);
63+
});
64+
65+
it('does not add ordering/zero errors when a corner is out of range', () => {
66+
// maxLat out of range — we should not also claim zero-height/ordering.
67+
const msgs = messages([0, 0, 10, 200]);
68+
expect(msgs).toContain(LAT_RANGE_MSG);
69+
expect(msgs).not.toContain(LAT_ZERO_MSG);
70+
expect(msgs).not.toContain(LAT_ORDER_MSG);
71+
});
72+
73+
it('reads horizontal corners out of a 3D (6-value) bbox', () => {
74+
// [minLon, minLat, minElev, maxLon, maxLat, maxElev]
75+
expect(inspectBbox([-10, -5, 0, 20, 15, 100])).toEqual([]);
76+
expect(messages([-10, 20, 0, 10, -20, 100])).toEqual([LAT_ORDER_MSG]);
77+
});
78+
79+
it('ignores arrays that are not a 2D or 3D bbox', () => {
80+
expect(inspectBbox([1, 2, 3])).toEqual([]);
81+
expect(inspectBbox('nope')).toEqual([]);
82+
});
83+
84+
it('coerces numeric strings (form inputs arrive as strings)', () => {
85+
expect(inspectBbox(['-10', '-5', '20', '15'])).toEqual([]);
86+
expect(messages(['-10', '5', '20', '5'])).toEqual([LAT_ZERO_MSG]);
87+
});
88+
});
89+
90+
describe('summarizeBboxIssues', () => {
91+
it('returns distinct messages across all bboxes', () => {
92+
const issues = summarizeBboxIssues([
93+
[-10, -5, 20, 15], // fine
94+
[-90, 90, -90, 90], // zero width + zero height
95+
[0, 0, 10, 200] // lat range
96+
]);
97+
expect(issues.sort()).toEqual(
98+
[LON_ZERO_MSG, LAT_ZERO_MSG, LAT_RANGE_MSG].sort()
99+
);
100+
});
101+
102+
it('returns [] for a missing/!array extent', () => {
103+
expect(summarizeBboxIssues(undefined)).toEqual([]);
104+
});
105+
});

packages/client/src/utils/bbox.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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

Comments
 (0)