Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/client/src/components/Map/bounds.ts
Original file line number Diff line number Diff line change
@@ -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)
];
}
1 change: 1 addition & 0 deletions packages/client/src/components/Map/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BackgroundTiles from './BackgroundTiles';

export { BackgroundTiles };
export { sanitizeBbox, MERCATOR_MAX_LAT } from './bounds';
36 changes: 22 additions & 14 deletions packages/client/src/pages/CollectionDetail/CollectionMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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]);

Expand Down
38 changes: 38 additions & 0 deletions packages/client/src/pages/CollectionDetail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '@chakra-ui/react';
import { useCollection, useStacSearch } from '@developmentseed/stac-react';
import {
CollecticonCircleExclamation,
CollecticonEllipsisVertical,
CollecticonEye,
CollecticonPencil,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -203,6 +213,34 @@ function CollectionDetail() {
</Box>
</Flex>

{bboxIssues.length > 0 && (
<Flex
mx='8'
gap='3'
p='4'
borderRadius='md'
bg='danger.50'
borderWidth='1px'
borderColor='danger.200'
alignItems='flex-start'
>
<CollecticonCircleExclamation color='danger.500' />
<Box>
<Text fontWeight='bold' color='danger.600'>
This collection&apos;s spatial extent looks invalid
</Text>
{bboxIssues.map((message) => (
<Text key={message} fontSize='sm'>
{message}
</Text>
))}
<Text fontSize='sm' color='base.400'>
The map may not display the extent correctly.
</Text>
</Box>
</Flex>
)}

<Grid templateColumns='repeat(12, 1fr)' gap={8}>
<GridItem colSpan={8}>
<Flex
Expand Down
53 changes: 51 additions & 2 deletions packages/client/src/pages/CollectionForm/EditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,51 @@ import {
import { InnerPageHeaderSticky } from '$components/InnerPageHeader';
import { CollecticonForm } from '$components/icons/form';
import { AppNotification, NotificationButton } from '$components/Notifications';
import { inspectBbox } from '$utils/bbox';

type FormView = 'fields' | 'json';

// The generic schema → Yup validation only knows each bbox corner is a number;
// it can't express the geographic rules (ranges, ordering, zero-area). Build
// Formik errors for `values.spatial` (number[][]) keyed at spatial.<i>.<corner>
// 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<any>) => void;
Expand Down Expand Up @@ -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 }) => (
Expand Down
17 changes: 12 additions & 5 deletions packages/client/src/pages/ItemDetail/ItemMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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]);

Expand Down
105 changes: 105 additions & 0 deletions packages/client/src/utils/bbox.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading