Skip to content

Commit 5060acc

Browse files
committed
fix: viewport bounds search
1 parent ec6670f commit 5060acc

File tree

3 files changed

+176
-102
lines changed

3 files changed

+176
-102
lines changed

src/components/collection.tsx

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { Box, DataList, HStack, Icon, Stack, Text } from "@chakra-ui/react";
1+
import {
2+
Badge,
3+
Box,
4+
DataList,
5+
HStack,
6+
Icon,
7+
Stack,
8+
Text,
9+
} from "@chakra-ui/react";
210
import { LuFileSearch } from "react-icons/lu";
3-
import type {
4-
StacCollection,
5-
SpatialExtent as StacSpatialExtent,
6-
TemporalExtent as StacTemporalExtent,
7-
} from "stac-ts";
11+
import type { StacCollection } from "stac-ts";
812
import useStacMap from "../hooks/stac-map";
913
import { ChildCard, Children } from "./children";
14+
import { SpatialExtent, TemporalExtent } from "./extents";
1015
import ItemSearch from "./search/item";
1116
import Section from "./section";
1217
import Value from "./value";
@@ -30,6 +35,7 @@ export function Collection({ collection }: { collection: StacCollection }) {
3035
<LuFileSearch></LuFileSearch>
3136
</Icon>{" "}
3237
Item search
38+
<Badge colorPalette={"orange"}>Under development</Badge>
3339
</HStack>
3440
}
3541
>
@@ -74,28 +80,7 @@ export function CollectionCard({
7480
);
7581
}
7682

77-
function SpatialExtent({ bbox }: { bbox: StacSpatialExtent }) {
78-
return <Text>[{bbox.map((n) => Number(n.toFixed(4))).join(", ")}]</Text>;
79-
}
80-
81-
function TemporalExtent({ interval }: { interval: StacTemporalExtent }) {
82-
return (
83-
<Text>
84-
<DateString datetime={interval[0]}></DateString>{" "}
85-
<DateString datetime={interval[1]}></DateString>
86-
</Text>
87-
);
88-
}
89-
90-
function DateString({ datetime }: { datetime: string | null }) {
91-
if (datetime) {
92-
return new Date(datetime).toLocaleDateString();
93-
} else {
94-
return "unbounded";
95-
}
96-
}
97-
98-
function Extents({ collection }: { collection: StacCollection }) {
83+
export function Extents({ collection }: { collection: StacCollection }) {
9984
return (
10085
<DataList.Root orientation={"horizontal"}>
10186
{collection.extent?.spatial?.bbox?.[0] && (

src/components/extents.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {
2+
SpatialExtent as StacSpatialExtent,
3+
TemporalExtent as StacTemporalExtent,
4+
} from "stac-ts";
5+
6+
export function SpatialExtent({ bbox }: { bbox: StacSpatialExtent }) {
7+
return <>[{bbox.map((n) => Number(n.toFixed(4))).join(", ")}]</>;
8+
}
9+
10+
export function TemporalExtent({ interval }: { interval: StacTemporalExtent }) {
11+
return (
12+
<>
13+
<DateString datetime={interval[0]}></DateString>{" "}
14+
<DateString datetime={interval[1]}></DateString>
15+
</>
16+
);
17+
}
18+
19+
function DateString({ datetime }: { datetime: string | null }) {
20+
if (datetime) {
21+
return new Date(datetime).toLocaleDateString();
22+
} else {
23+
return "unbounded";
24+
}
25+
}

src/components/search/item.tsx

Lines changed: 138 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import {
2+
Accordion,
23
Alert,
3-
Box,
44
Button,
55
ButtonGroup,
66
createListCollection,
77
Field,
88
Group,
9+
Heading,
910
HStack,
1011
IconButton,
1112
Input,
13+
Link,
1214
Portal,
1315
Progress,
1416
Select,
1517
Stack,
1618
Switch,
19+
Text,
1720
} from "@chakra-ui/react";
21+
import type { BBox } from "geojson";
1822
import { useEffect, useState } from "react";
1923
import { LuPause, LuPlay, LuSearch, LuX } from "react-icons/lu";
2024
import { useMap } from "react-map-gl/maplibre";
@@ -23,8 +27,14 @@ import useStacMap from "../../hooks/stac-map";
2327
import useStacSearch from "../../hooks/stac-search";
2428
import type { StacSearch } from "../../types/stac";
2529
import DownloadButtons from "../download";
30+
import { SpatialExtent } from "../extents";
2631
import { toaster } from "../ui/toaster";
2732

33+
interface NormalizedBbox {
34+
bbox: BBox;
35+
isCrossingAntimeridian: boolean;
36+
}
37+
2838
export default function ItemSearch({
2939
collection,
3040
links,
@@ -35,6 +45,7 @@ export default function ItemSearch({
3545
const { setItems } = useStacMap();
3646
const [search, setSearch] = useState<StacSearch>();
3747
const [link, setLink] = useState<StacLink | undefined>(links[0]);
48+
const [normalizedBbox, setNormalizedBbox] = useState<NormalizedBbox>();
3849
const [datetime, setDatetime] = useState<string>();
3950
const [useViewportBounds, setUseViewportBounds] = useState(true);
4051
const { map } = useMap();
@@ -48,36 +59,111 @@ export default function ItemSearch({
4859
}),
4960
});
5061

51-
return (
52-
<Stack gap={4}>
53-
<Alert.Root status={"warning"} size={"sm"}>
54-
<Alert.Indicator></Alert.Indicator>
55-
<Alert.Content>
56-
<Alert.Title>Under construction</Alert.Title>
57-
<Alert.Description>
58-
Item search is under active development and is relatively
59-
under-powered.
60-
</Alert.Description>
61-
</Alert.Content>
62-
</Alert.Root>
62+
useEffect(() => {
63+
function getNormalizedMapBounds() {
64+
return normalizeBbox(map?.getBounds().toArray().flat() as BBox);
65+
}
66+
67+
const listener = () => {
68+
setNormalizedBbox(getNormalizedMapBounds());
69+
};
6370

64-
<Switch.Root
65-
disabled={!map}
66-
checked={!!map && useViewportBounds}
67-
onCheckedChange={(e) => setUseViewportBounds(e.checked)}
71+
if (useViewportBounds && map) {
72+
map.on("moveend", listener);
73+
setNormalizedBbox(getNormalizedMapBounds());
74+
} else {
75+
map?.off("moveend", listener);
76+
setNormalizedBbox(undefined);
77+
}
78+
}, [map, useViewportBounds]);
79+
80+
return (
81+
<Stack>
82+
<Accordion.Root
83+
defaultValue={["spatial", "temporal"]}
84+
multiple
85+
variant={"enclosed"}
6886
>
69-
<Switch.HiddenInput></Switch.HiddenInput>
70-
<Switch.Label>Use viewport bounds</Switch.Label>
71-
<Switch.Control></Switch.Control>
72-
</Switch.Root>
87+
<Accordion.Item value="spatial">
88+
<Accordion.ItemTrigger>
89+
<Heading flex="1" size={"lg"}>
90+
Spatial
91+
</Heading>
92+
<Accordion.ItemIndicator />
93+
</Accordion.ItemTrigger>
94+
<Accordion.ItemContent pb={4}>
95+
<Stack>
96+
<Switch.Root
97+
disabled={!map}
98+
checked={!!map && useViewportBounds}
99+
onCheckedChange={(e) => setUseViewportBounds(e.checked)}
100+
size={"sm"}
101+
>
102+
<Switch.HiddenInput></Switch.HiddenInput>
103+
<Switch.Label>Use viewport bounds</Switch.Label>
104+
<Switch.Control></Switch.Control>
105+
</Switch.Root>
106+
{normalizedBbox && (
107+
<Text fontSize={"sm"} fontWeight={"lighter"}>
108+
<SpatialExtent bbox={normalizedBbox?.bbox}></SpatialExtent>
109+
</Text>
110+
)}
111+
{normalizedBbox?.isCrossingAntimeridian && (
112+
<Alert.Root status={"warning"} size={"sm"}>
113+
<Alert.Indicator></Alert.Indicator>
114+
<Alert.Content>
115+
<Alert.Title>Antimeridian-crossing viewport</Alert.Title>
116+
<Alert.Description>
117+
The viewport bounds cross the{" "}
118+
<Link
119+
href="https://en.wikipedia.org/wiki/180th_meridian"
120+
target="_blank"
121+
>
122+
antimeridian
123+
</Link>
124+
, and may servers do not support antimeridian-crossing
125+
bounding boxes. The search bounding box has been reduced
126+
to only one side of the antimeridian.
127+
</Alert.Description>
128+
</Alert.Content>
129+
</Alert.Root>
130+
)}
131+
</Stack>
132+
</Accordion.ItemContent>
133+
</Accordion.Item>
73134

74-
<Datetime
75-
interval={collection.extent?.temporal?.interval[0]}
76-
setDatetime={setDatetime}
77-
></Datetime>
135+
<Accordion.Item value="temporal">
136+
<Accordion.ItemTrigger>
137+
<Heading flex="1" size={"lg"}>
138+
Temporal
139+
</Heading>
140+
<Accordion.ItemIndicator />
141+
</Accordion.ItemTrigger>
142+
<Accordion.ItemContent pb={4}>
143+
<Datetime
144+
interval={collection.extent?.temporal?.interval[0]}
145+
setDatetime={setDatetime}
146+
></Datetime>
147+
</Accordion.ItemContent>
148+
</Accordion.Item>
149+
</Accordion.Root>
78150

79151
<HStack>
80-
<Box flex={1}></Box>
152+
<Button
153+
variant={"solid"}
154+
size={"md"}
155+
onClick={() =>
156+
setSearch({
157+
collections: [collection.id],
158+
datetime,
159+
bbox: normalizedBbox?.bbox,
160+
})
161+
}
162+
disabled={!!search}
163+
>
164+
<LuSearch></LuSearch>
165+
Search
166+
</Button>
81167

82168
<Select.Root
83169
collection={methods}
@@ -110,31 +196,6 @@ export default function ItemSearch({
110196
</Select.Positioner>
111197
</Portal>
112198
</Select.Root>
113-
114-
<Button
115-
variant={"surface"}
116-
onClick={() =>
117-
setSearch({
118-
collections: [collection.id],
119-
datetime,
120-
bbox:
121-
useViewportBounds && map
122-
? normalizeBbox(
123-
map.getBounds().toArray().flat() as [
124-
number,
125-
number,
126-
number,
127-
number,
128-
],
129-
)
130-
: undefined,
131-
})
132-
}
133-
disabled={!!search}
134-
>
135-
<LuSearch></LuSearch>
136-
Search
137-
</Button>
138199
</HStack>
139200

140201
{search && link && (
@@ -257,7 +318,7 @@ function Datetime({
257318
}, [startDatetime, endDatetime, setDatetime]);
258319

259320
return (
260-
<Stack>
321+
<Stack gap={4}>
261322
<DatetimeInput
262323
label="Start datetime"
263324
datetime={startDatetime}
@@ -268,17 +329,16 @@ function Datetime({
268329
datetime={endDatetime}
269330
setDatetime={setEndDatetime}
270331
></DatetimeInput>
271-
<HStack>
272-
<Button
273-
variant={"outline"}
274-
onClick={() => {
275-
setStartDatetime(interval?.[0] ? new Date(interval[0]) : undefined);
276-
setEndDatetime(interval?.[1] ? new Date(interval[1]) : undefined);
277-
}}
278-
>
279-
Set to collection extents
280-
</Button>
281-
</HStack>
332+
<Button
333+
size={"sm"}
334+
variant={"outline"}
335+
onClick={() => {
336+
setStartDatetime(interval?.[0] ? new Date(interval[0]) : undefined);
337+
setEndDatetime(interval?.[1] ? new Date(interval[1]) : undefined);
338+
}}
339+
>
340+
Set to collection extents
341+
</Button>
282342
</Stack>
283343
);
284344
}
@@ -354,25 +414,29 @@ function DatetimeInput({
354414
);
355415
}
356416

357-
function normalizeBbox(bbox: [number, number, number, number]) {
417+
function normalizeBbox(bbox: BBox): NormalizedBbox {
358418
if (bbox[2] - bbox[0] >= 360) {
359-
return [-180, bbox[1], 180, bbox[3]];
419+
return {
420+
bbox: [-180, bbox[1], 180, bbox[3]],
421+
isCrossingAntimeridian: false,
422+
};
360423
} else if (bbox[0] < -180) {
361424
return normalizeBbox([bbox[0] + 360, bbox[1], bbox[2] + 360, bbox[3]]);
362425
} else if (bbox[0] > 180) {
363426
return normalizeBbox([bbox[0] - 360, bbox[1], bbox[2] - 360, bbox[3]]);
364427
} else if (bbox[2] > 180) {
365-
// Antimeridian-crossing
366-
toaster.create({
367-
type: "info",
368-
title: "Viewport crosses the antimeridian",
369-
description:
370-
"The viewport crosses the antimeridian, and many STAC API servers do not support bounding boxes that cross +/- 180° longitude. We're narrowing the viewport to only search to only one side.",
371-
});
372428
if ((bbox[0] + bbox[2]) / 2 > 180) {
373-
return [-180, bbox[1], bbox[2] - 360, bbox[3]];
429+
return {
430+
bbox: [-180, bbox[1], bbox[2] - 360, bbox[3]],
431+
isCrossingAntimeridian: true,
432+
};
374433
} else {
375-
return [bbox[0], bbox[1], 180, bbox[3]];
434+
return {
435+
bbox: [bbox[0], bbox[1], 180, bbox[3]],
436+
isCrossingAntimeridian: true,
437+
};
376438
}
439+
} else {
440+
return { bbox: bbox, isCrossingAntimeridian: false };
377441
}
378442
}

0 commit comments

Comments
 (0)