diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 806afd5..3c34094 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version-file: .node-version - cache: "yarn" + cache: 'yarn' - name: Install run: | yarn install @@ -23,7 +23,7 @@ jobs: - name: Lint run: yarn lint - name: Check formatting - run: yarn run prettier . --check + run: yarn format:check - name: Test run: yarn test - name: Build @@ -44,7 +44,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version-file: .node-version - cache: "yarn" + cache: 'yarn' - name: Install run: yarn install - id: setup_pages @@ -92,7 +92,7 @@ jobs: - uses: actions/setup-node@v3 with: node-version-file: .node-version - cache: "yarn" + cache: 'yarn' - name: Install run: yarn install - name: Release diff --git a/.gitignore b/.gitignore index 2c27527..7781638 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? tests/__screenshots__/ +codebook.toml diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..23c7fcc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +yarn.lock +package-lock.json + +# Build outputs +dist/ +build/ +.next/ +out/ + +# Generated files +coverage/ +.nyc_output/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Cache directories +.cache/ +.parcel-cache/ + +# IDE files +.vscode/ +.idea/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp + +# Documentation that shouldn't be auto-formatted +CHANGELOG.md +LICENSE +*.min.js +*.min.css + +# Config files that might have specific formatting +.github/ diff --git a/.prettierrc.toml b/.prettierrc.toml index 3d99dbf..782756f 100644 --- a/.prettierrc.toml +++ b/.prettierrc.toml @@ -1 +1,34 @@ tabWidth = 2 +semi = true +singleQuote = false +trailingComma = "es5" +printWidth = 80 +plugins = ["@trivago/prettier-plugin-sort-imports"] + +# Import sorting configuration +importOrder = [ + "^react$", + "^react-dom$", + "^react/", + "^react-", + "^@chakra-ui/", + "^@deck\\.gl/", + "^@geoarrow/", + "^@duckdb/", + "^@tanstack/", + "^@turf/", + "^@types/", + "^maplibre-gl", + "^deck\\.gl", + "^apache-arrow", + "^stac-ts", + "^stac-wasm", + "^next-themes", + "^@", + "^[a-z]", + "^\\./", + "^\\.\\./" +] +importOrderSeparation = false +importOrderSortSpecifiers = true +importOrderCaseInsensitive = true diff --git a/eslint.config.js b/eslint.config.js index 61b6280..9cf8056 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,5 +24,5 @@ export default tseslint.config( { allowConstantExport: true }, ], }, - }, + } ); diff --git a/package.json b/package.json index 5e07bd6..67e5c38 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "format": "prettier . --write", + "format:check": "prettier . --check", "preview": "vite preview", "test": "vitest run" }, @@ -61,6 +62,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/geojson": "^7946.0.16", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", diff --git a/src/app.tsx b/src/app.tsx index 075023f..e317c18 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,36 +1,85 @@ -import { - Alert, - Box, - Container, - FileUpload, - Flex, - GridItem, - SimpleGrid, - useBreakpointValue, - useFileUpload, -} from "@chakra-ui/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import { MapProvider } from "react-map-gl/dist/esm/exports-maplibre"; -import Header from "./components/header"; +import { useEffect, useMemo, useState } from "react"; +import { Box, Container, FileUpload, useFileUpload } from "@chakra-ui/react"; +import type { StacCollection, StacItem } from "stac-ts"; import Map from "./components/map"; -import Panel from "./components/panel"; +import Overlay from "./components/overlay"; import { Toaster } from "./components/ui/toaster"; -import { StacMapProvider } from "./provider"; +import useStacValue from "./hooks/stac-value"; +import type { BBox2D, Color } from "./types/map"; +import type { DatetimeBounds, StacValue } from "./types/stac"; +import { getItemDatetimes } from "./utils/stac"; + +// TODO make this configurable by the user. +const lineColor: Color = [207, 63, 2, 100]; +const fillColor: Color = [207, 63, 2, 50]; export default function App() { - const queryClient = new QueryClient({}); + // State const [href, setHref] = useState(getInitialHref()); const fileUpload = useFileUpload({ maxFiles: 1 }); - const isHeaderAbovePanel = useBreakpointValue({ base: true, md: false }); + const [userItems, setItems] = useState(); + const [picked, setPicked] = useState(); + const [bbox, setBbox] = useState(); + const [datetimeBounds, setDatetimeBounds] = useState(); + const [filter, setFilter] = useState(true); + const [stacGeoparquetItemId, setStacGeoparquetItemId] = useState(); + + // Derived state + const { + value, + error, + collections, + catalogs, + items: linkedItems, + table, + stacGeoparquetItem, + } = useStacValue({ + href, + fileUpload, + datetimeBounds, + stacGeoparquetItemId, + }); + const items = userItems || linkedItems; + const filteredCollections = useMemo(() => { + if (filter && collections) { + return collections.filter( + (collection) => + (!bbox || isCollectionInBbox(collection, bbox)) && + (!datetimeBounds || + isCollectionInDatetimeBounds(collection, datetimeBounds)) + ); + } else { + return undefined; + } + }, [collections, filter, bbox, datetimeBounds]); + const filteredItems = useMemo(() => { + if (filter && items) { + return items.filter( + (item) => + (!bbox || isItemInBbox(item, bbox)) && + (!datetimeBounds || isItemInDatetimeBounds(item, datetimeBounds)) + ); + } else { + return undefined; + } + }, [items, filter, bbox, datetimeBounds]); + // Effects useEffect(() => { function handlePopState() { setHref(new URLSearchParams(location.search).get("href") ?? ""); } - window.addEventListener("popstate", handlePopState); + + const href = new URLSearchParams(location.search).get("href"); + if (href) { + try { + new URL(href); + } catch { + history.pushState(null, "", location.pathname); + } + } + return () => { window.removeEventListener("popstate", handlePopState); }; @@ -51,66 +100,82 @@ export default function App() { } }, [fileUpload.acceptedFiles]); - const header = ( -
- ); + useEffect(() => { + setPicked(undefined); + setItems(undefined); + setDatetimeBounds(undefined); - return ( - - - - - - - - - - - - - - - {isHeaderAbovePanel && {header}} - - - - {!isHeaderAbovePanel && ( - - {header} - - )} - - - - - - - ); -} + if (value && (value.title || value.id)) { + document.title = "stac-map | " + (value.title || value.id); + } else { + document.title = "stac-map"; + } + }, [value]); + + useEffect(() => { + setPicked(stacGeoparquetItem); + }, [stacGeoparquetItem]); -function MapFallback({ error }: { error: Error }) { return ( - - - - - - Error while rendering the map - {error.message} - - + <> + + + + + + - + + + + + ); } @@ -123,3 +188,73 @@ function getInitialHref() { } return href; } + +function isCollectionInBbox(collection: StacCollection, bbox: BBox2D) { + if (bbox[2] - bbox[0] >= 360) { + // A global bbox always contains every collection + return true; + } + const collectionBbox = collection?.extent?.spatial?.bbox?.[0]; + if (collectionBbox) { + return ( + !( + collectionBbox[0] < bbox[0] && + collectionBbox[1] < bbox[1] && + collectionBbox[2] > bbox[2] && + collectionBbox[3] > bbox[3] + ) && + !( + collectionBbox[0] > bbox[2] || + collectionBbox[1] > bbox[3] || + collectionBbox[2] < bbox[0] || + collectionBbox[3] < bbox[1] + ) + ); + } else { + return false; + } +} + +function isCollectionInDatetimeBounds( + collection: StacCollection, + bounds: DatetimeBounds +) { + const interval = collection.extent.temporal.interval[0]; + const start = interval[0] ? new Date(interval[0]) : null; + const end = interval[1] ? new Date(interval[1]) : null; + return !((end && end < bounds.start) || (start && start > bounds.end)); +} + +function isItemInBbox(item: StacItem, bbox: BBox2D) { + if (bbox[2] - bbox[0] >= 360) { + // A global bbox always contains every item + return true; + } + const itemBbox = item.bbox; + if (itemBbox) { + return ( + !( + itemBbox[0] < bbox[0] && + itemBbox[1] < bbox[1] && + itemBbox[2] > bbox[2] && + itemBbox[3] > bbox[3] + ) && + !( + itemBbox[0] > bbox[2] || + itemBbox[1] > bbox[3] || + itemBbox[2] < bbox[0] || + itemBbox[3] < bbox[1] + ) + ); + } else { + return false; + } +} + +function isItemInDatetimeBounds(item: StacItem, bounds: DatetimeBounds) { + const datetimes = getItemDatetimes(item); + return !( + (datetimes.end && datetimes.end < bounds.start) || + (datetimes.start && datetimes.start > bounds.end) + ); +} diff --git a/src/components/about.tsx b/src/components/about.tsx deleted file mode 100644 index c9eabdc..0000000 --- a/src/components/about.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - CloseButton, - DataList, - Dialog, - IconButton, - Link, - Portal, - Stack, - Text, -} from "@chakra-ui/react"; -import { LuExternalLink, LuInfo } from "react-icons/lu"; - -export default function About() { - return ( - - - - - - - - - - - About - - - - stac-map was created and is maintained by{" "} - - Development Seed - - , and it is public and free for modification and re-use under - the{" "} - - MIT license - - . - - - {import.meta.env.VITE_APP_VERSION && ( - - App version - - {import.meta.env.VITE_APP_VERSION} - - - )} - {import.meta.env.VITE_APP_DEPLOY_DATETIME && ( - - Deployed at - - {import.meta.env.VITE_APP_DEPLOY_DATETIME} - - - )} - - - - - - - - - - - ); -} diff --git a/src/components/assets.tsx b/src/components/assets.tsx index bb294bf..03ef2ab 100644 --- a/src/components/assets.tsx +++ b/src/components/assets.tsx @@ -1,52 +1,79 @@ +import { useState } from "react"; +import { LuDownload } from "react-icons/lu"; import { - Badge, + Button, ButtonGroup, Card, + Collapsible, + DataList, HStack, - IconButton, - Stack, - Text, + Image, } from "@chakra-ui/react"; -import { LuDownload } from "react-icons/lu"; import type { StacAsset } from "stac-ts"; +import Properties from "./properties"; +import type { StacAssets } from "../types/stac"; -export default function Assets({ - assets, -}: { - assets: { [k: string]: StacAsset }; -}) { +export default function Assets({ assets }: { assets: StacAssets }) { return ( - - {Object.entries(assets).map(([key, asset]) => ( - - - - - {asset.title || key} - {asset.roles && - asset.roles.map((role) => {role})} - - - {asset.description && ( - {asset.description} - )} - - - - - - - - - - {asset.type && ( - - {asset.type} - - )} - - + + {Object.keys(assets).map((key) => ( + + {key} + + + + ))} - + + ); +} + +function Asset({ asset }: { asset: StacAsset }) { + const [imageError, setImageError] = useState(false); + // eslint-disable-next-line + const { href, roles, type, title, ...properties } = asset; + + return ( + + + {asset.title && {asset.title}} + + + {!imageError && ( + setImageError(true)} /> + )} + + {asset.roles && ( + + Roles + {asset.roles?.join(", ")} + + )} + {asset.type && ( + + Type + {asset.type} + + )} + + {Object.keys(properties).length > 0 && ( + + Properties + + + + + )} + + + + + + + ); } diff --git a/src/components/breadcrumbs.tsx b/src/components/breadcrumbs.tsx new file mode 100644 index 0000000..0949769 --- /dev/null +++ b/src/components/breadcrumbs.tsx @@ -0,0 +1,132 @@ +import { Breadcrumb } from "@chakra-ui/react"; +import type { StacValue } from "../types/stac"; + +export default function Breadcrumbs({ + value, + picked, + setPicked, + setHref, +}: { + value: StacValue; + picked: StacValue | undefined; + setPicked: (picked: StacValue | undefined) => void; + setHref: (href: string | undefined) => void; +}) { + let selfHref; + let rootHref; + let parentHref; + if (value.links) { + for (const link of value.links) { + switch (link.rel) { + case "self": + selfHref = link.href; + break; + case "parent": + parentHref = link.href; + break; + case "root": + rootHref = link.href; + break; + } + } + } + const breadcrumbs = []; + if (rootHref && selfHref != rootHref) { + breadcrumbs.push( + + ); + } + if (parentHref && selfHref != parentHref && rootHref != parentHref) { + breadcrumbs.push( + + ); + } + if (picked) { + breadcrumbs.push( + + { + e.preventDefault(); + setPicked(undefined); + }} + > + {getStacType(value)} + + + ); + breadcrumbs.push( + + + {"Picked " + getStacType(picked).toLowerCase()} + + + ); + } else { + breadcrumbs.push( + + {getStacType(value)} + + ); + } + return ( + + + {breadcrumbs.flatMap((value, i) => [ + value, + i < breadcrumbs.length - 1 && ( + + ), + ])} + + + ); +} + +function BreadcrumbItem({ + href, + setHref, + text, +}: { + href: string; + setHref: (href: string | undefined) => void; + text: string; +}) { + return ( + + { + e.preventDefault(); + setHref(href); + }} + > + {text} + + + ); +} + +function getStacType(value: StacValue) { + switch (value.type) { + case "Feature": + return "Item"; + case "FeatureCollection": + return "Item collection"; + case "Catalog": + case "Collection": + return value.type; + default: + return "Unknown"; + } +} diff --git a/src/components/catalog.tsx b/src/components/catalog.tsx deleted file mode 100644 index 430c599..0000000 --- a/src/components/catalog.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Stack } from "@chakra-ui/react"; -import type { StacCatalog } from "stac-ts"; -import type { SetHref } from "../types/app"; -import { Children } from "./children"; -import Value from "./value"; - -export function Catalog({ - catalog, - setHref, -}: { - catalog: StacCatalog; - setHref: SetHref; -}) { - return ( - - - - - ); -} diff --git a/src/components/catalogs.tsx b/src/components/catalogs.tsx new file mode 100644 index 0000000..b0b3855 --- /dev/null +++ b/src/components/catalogs.tsx @@ -0,0 +1,51 @@ +import { MarkdownHooks } from "react-markdown"; +import { Card, Link, Stack, Text } from "@chakra-ui/react"; +import type { StacCatalog } from "stac-ts"; + +export default function Catalogs({ + catalogs, + setHref, +}: { + catalogs: StacCatalog[]; + setHref: (href: string | undefined) => void; +}) { + return ( + + {catalogs.map((catalog) => ( + + ))} + + ); +} + +export function CatalogCard({ + catalog, + setHref, +}: { + catalog: StacCatalog; + setHref: (href: string | undefined) => void; +}) { + const selfHref = catalog.links.find((link) => link.rel === "self")?.href; + return ( + + + + selfHref && setHref(selfHref)}> + {catalog.title || catalog.id} + + + + + + {catalog.description} + + + + + + ); +} diff --git a/src/components/children.tsx b/src/components/children.tsx deleted file mode 100644 index 7868717..0000000 --- a/src/components/children.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Card, Checkbox, Link, Stack, Text } from "@chakra-ui/react"; -import { useEffect, useState, type ReactNode } from "react"; -import { - LuFiles, - LuFolderPlus, - LuFolders, - LuFolderSearch, -} from "react-icons/lu"; -import { MarkdownHooks } from "react-markdown"; -import type { StacCatalog, StacCollection, StacItem } from "stac-ts"; -import useStacMap from "../hooks/stac-map"; -import { useChildren } from "../hooks/stac-value"; -import type { SetHref } from "../types/app"; -import { CollectionSearch } from "./search/collection"; -import Section from "./section"; -import { useMap } from "react-map-gl/maplibre"; -import type { BBox } from "geojson"; - -export function Children({ - value, - setHref, -}: { - value: StacCatalog | StacCollection; - setHref: SetHref; -}) { - const { collections, items, filteredItems } = useStacMap(); - const children = useChildren(value, !collections); - const { map } = useMap(); - const selfHref = value?.links?.find((link) => link.rel === "self")?.href; - const [mapBbox, setMapBbox] = useState(); - const [filterByViewport, setFilterByViewport] = useState(true); - const [filteredCollections, setFilteredCollections] = useState(collections); - - useEffect(() => { - if (map) { - map.on("moveend", () => { - if (map) { - setMapBbox(map.getBounds().toArray().flat() as BBox); - } - }); - } - }, [map]); - - useEffect(() => { - if (filterByViewport && mapBbox) { - setFilteredCollections( - collections?.filter((collection) => - isCollectionInBbox(collection, mapBbox), - ), - ); - } else { - setFilteredCollections(collections); - } - }, [collections, filterByViewport, mapBbox]); - - return ( - <> - {collections && - filteredCollections && - filteredCollections?.length > 0 && ( - <> -
- -
- -
- - setFilterByViewport(!!e.checked)} - > - - - Filter by viewport - - {filteredCollections.map((collection) => ( - - ))} - -
- - )} - - {children && children.length > 0 && ( -
- - {children.map((child) => ( - - ))} - -
- )} - - {items && items.length > 0 && ( -
- - {(filteredItems || items).map((item) => ( - - ))} - -
- )} - - ); -} - -export function ChildCard({ - child, - footer, - setHref, -}: { - child: StacCatalog | StacCollection; - footer?: ReactNode; - setHref: SetHref; -}) { - const selfHref = child.links.find((link) => link.rel === "self")?.href; - - return ( - - - - selfHref && setHref(selfHref)}> - {child.title || child.id} - - - - - {child.description} - - - - {footer && ( - - {footer} - - )} - - ); -} -function ItemCard({ item, setHref }: { item: StacItem; setHref: SetHref }) { - const selfHref = item.links.find((link) => link.rel === "self")?.href; - - return ( - - - - selfHref && setHref(selfHref)}>{item.id} - - - {item.properties.datetime || - item.properties.start_datetime || - "unbounded" + " to " + item.properties.end_datetime || - "unbounded"} - - - - ); -} - -function isCollectionInBbox(collection: StacCollection, bbox: BBox) { - if (bbox[2] - bbox[0] >= 360) { - // A global bbox always contains every collection - return true; - } - const collectionBbox = collection?.extent?.spatial?.bbox?.[0]; - if (collectionBbox) { - return ( - !( - collectionBbox[0] < bbox[0] && - collectionBbox[1] < bbox[1] && - collectionBbox[2] > bbox[2] && - collectionBbox[3] > bbox[3] - ) && - !( - collectionBbox[0] > bbox[2] || - collectionBbox[1] > bbox[3] || - collectionBbox[2] < bbox[0] || - collectionBbox[3] < bbox[1] - ) - ); - } else { - return false; - } -} diff --git a/src/components/collection-search.tsx b/src/components/collection-search.tsx new file mode 100644 index 0000000..dacf278 --- /dev/null +++ b/src/components/collection-search.tsx @@ -0,0 +1,289 @@ +import { useMemo, useRef, useState } from "react"; +import { LuSearch } from "react-icons/lu"; +import { + CloseButton, + Combobox, + createListCollection, + Field, + HStack, + Input, + InputGroup, + Portal, + SegmentGroup, + SkeletonText, + Stack, +} from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import type { StacCollection } from "stac-ts"; +import { CollectionCard } from "./collections"; +import type { NaturalLanguageCollectionSearchResult } from "../types/stac"; + +export default function CollectionSearch({ + collections, + catalogHref, + setHref, +}: { + collections: StacCollection[]; + catalogHref: string | undefined; + setHref: (href: string | undefined) => void; +}) { + const [value, setValue] = useState<"Text" | "Natural language">("Text"); + return ( + + + + setValue(e.value as "Text" | "Natural language") + } + > + + + + + {value === "Text" && ( + + )} + {value === "Natural language" && catalogHref && ( + + )} + + ); +} + +function CollectionCombobox({ + collections, + setHref, +}: { + collections: StacCollection[]; + setHref: (href: string | undefined) => void; +}) { + const [searchValue, setSearchValue] = useState(""); + + const filteredCollections = useMemo(() => { + return collections.filter( + (collection) => + collection.title?.toLowerCase().includes(searchValue.toLowerCase()) || + collection.id.toLowerCase().includes(searchValue.toLowerCase()) || + collection.description.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue, collections]); + + const collection = useMemo( + () => + createListCollection({ + items: filteredCollections, + itemToString: (collection) => collection.title || collection.id, + itemToValue: (collection) => collection.id, + }), + + [filteredCollections] + ); + + return ( + setSearchValue(details.inputValue)} + onSelect={(details) => { + const collection = collections.find( + (collection) => collection.id == details.itemValue + ); + if (collection) { + const selfHref = collection.links.find( + (link) => link.rel == "self" + )?.href; + if (selfHref) { + setHref(selfHref); + } + } + }} + > + + + + + + + + + + + + + {filteredCollections.map((collection) => ( + + {collection.title || collection.id} + + + + ))} + + No collections found + + + + + + ); +} + +function NaturalLanguageCollectionSearch({ + href, + setHref, + collections, +}: { + href: string; + setHref: (href: string | undefined) => void; + collections: StacCollection[]; +}) { + const [query, setQuery] = useState(); + const [value, setValue] = useState(""); + const inputRef = useRef(null); + + const endElement = value ? ( + { + setValue(""); + setQuery(undefined); + inputRef.current?.focus(); + }} + me="-2" + /> + ) : undefined; + + return ( + +
{ + e.preventDefault(); + setQuery(value); + }} + > + + } + endElement={endElement} + > + setValue(e.target.value)} + > + + + Natural language collection search is experimental, and can be + rather slow. + + +
+ {query && ( + + )} +
+ ); +} + +function Results({ + query, + href, + setHref, + collections, +}: { + query: string; + href: string; + setHref: (href: string | undefined) => void; + collections: StacCollection[]; +}) { + const { data } = useQuery<{ + results: NaturalLanguageCollectionSearchResult[]; + }>({ + queryKey: [href, query], + queryFn: async () => { + const body = JSON.stringify({ + query, + catalog_url: href, + }); + const url = new URL( + "search", + import.meta.env.VITE_STAC_NATURAL_QUERY_API + ); + return await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + }).then((response) => { + if (response.ok) { + return response.json(); + } else { + throw new Error( + `Error while doing a natural language search against ${href}: ${response.statusText}` + ); + } + }); + }, + }); + + const results = useMemo(() => { + return data?.results.map( + (result: NaturalLanguageCollectionSearchResult) => { + return { + result, + collection: collections.find( + (collection) => collection.id == result.collection_id + ), + }; + } + ); + }, [data, collections]); + + if (results) { + return ( + + {results.map((result) => { + if (result.collection) { + return ( + + ); + } else { + return null; + } + })} + + ); + } else { + return ; + } +} diff --git a/src/components/collection.tsx b/src/components/collection.tsx deleted file mode 100644 index a2ed809..0000000 --- a/src/components/collection.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { - Badge, - Box, - DataList, - HStack, - Span, - Stack, - Text, -} from "@chakra-ui/react"; -import { LuFileSearch } from "react-icons/lu"; -import type { StacCollection, StacLink } from "stac-ts"; -import { useStacLinkContainer } from "../hooks/stac-value"; -import type { SetHref } from "../types/app"; -import type { StacSearch } from "../types/stac"; -import { ChildCard, Children } from "./children"; -import { SpatialExtent, TemporalExtent } from "./extents"; -import ItemSearch from "./search/item"; -import Section from "./section"; -import Value from "./value"; - -export function Collection({ - collection, - setHref, - search, - setSearch, - setSearchLink, - autoLoad, - setAutoLoad, -}: { - collection: StacCollection; - setHref: SetHref; - search: StacSearch | undefined; - setSearch: (search: StacSearch | undefined) => void; - setSearchLink: (link: StacLink | undefined) => void; - autoLoad: boolean; - setAutoLoad: (autoLoad: boolean) => void; -}) { - const root = useStacLinkContainer(collection, "root"); - const searchLinks = - (root && root.links?.filter((link) => link.rel == "search")) || []; - - return ( - - - - - - {searchLinks.length > 0 && ( -
- Item search{" "} - Under development - - } - > - -
- )} - - -
- ); -} - -export function CollectionCard({ - collection, - setHref, - explanation, -}: { - collection: StacCollection; - setHref: SetHref; - explanation?: string; -}) { - return ( - - - {collection.extent?.spatial?.bbox && ( - - )} - - {collection.extent?.temporal?.interval && ( - - )} - - {explanation && {explanation}} - - } - > - ); -} - -export function Extents({ collection }: { collection: StacCollection }) { - return ( - - {collection.extent?.spatial?.bbox?.[0] && ( - - Spatial extent - - - - - )} - {collection.extent?.temporal?.interval?.[0] && ( - - Temporal extent - - - - - )} - - ); -} diff --git a/src/components/collections.tsx b/src/components/collections.tsx new file mode 100644 index 0000000..cc42383 --- /dev/null +++ b/src/components/collections.tsx @@ -0,0 +1,70 @@ +import { MarkdownHooks } from "react-markdown"; +import { Card, Link, Stack, Text } from "@chakra-ui/react"; +import type { StacCollection } from "stac-ts"; +import { SpatialExtent, TemporalExtent } from "./extent"; + +export default function Collections({ + collections, + setHref, +}: { + collections: StacCollection[]; + setHref: (href: string | undefined) => void; +}) { + return ( + + {collections.map((collection) => ( + + ))} + + ); +} + +export function CollectionCard({ + collection, + setHref, + footer, +}: { + collection: StacCollection; + setHref: (href: string | undefined) => void; + footer?: string; +}) { + const selfHref = collection.links.find((link) => link.rel === "self")?.href; + return ( + + + + selfHref && setHref(selfHref)}> + {collection.title || collection.id} + + + + + + {collection.description} + + + {collection.extent?.temporal?.interval && ( + + )} + {collection.extent?.spatial?.bbox && ( + + )} + + + + {footer && ( + + {footer} + + )} + + ); +} diff --git a/src/components/download.tsx b/src/components/download.tsx deleted file mode 100644 index 4b0751a..0000000 --- a/src/components/download.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Button, ButtonGroup, DownloadTrigger } from "@chakra-ui/react"; -import { LuDownload } from "react-icons/lu"; -import type { StacItem } from "stac-ts"; -import * as stac_wasm from "stac-wasm"; - -export default function DownloadButtons({ - items, - disabled, -}: { - items: StacItem[]; - disabled?: boolean; -}) { - const downloadJson = () => { - return JSON.stringify( - items ? { type: "FeatureCollection", features: items } : {}, - ); - }; - const downloadStacGeoparquet = () => { - return new Blob(items ? [stac_wasm.stacJsonToParquet(items)] : []); - }; - - return ( - - - - - - - - - ); -} diff --git a/src/components/error-component.tsx b/src/components/error-component.tsx new file mode 100644 index 0000000..4405576 --- /dev/null +++ b/src/components/error-component.tsx @@ -0,0 +1,33 @@ +import { AbsoluteCenter, Alert, Box, Button, Stack } from "@chakra-ui/react"; + +export function ErrorComponent({ + error, + resetErrorBoundary, +}: { + error: Error; + resetErrorBoundary: () => void; +}) { + return ( + + + + + + Unhandled application error + + + {error.message} + + + + + + + + ); +} diff --git a/src/components/examples.tsx b/src/components/examples.tsx new file mode 100644 index 0000000..56f5220 --- /dev/null +++ b/src/components/examples.tsx @@ -0,0 +1,30 @@ +import { type ReactNode } from "react"; +import { Badge, Menu, Portal, Span } from "@chakra-ui/react"; +import { EXAMPLES } from "../constants"; + +export function Examples({ + setHref, + children, +}: { + setHref: (href: string | undefined) => void; + children: ReactNode; +}) { + return ( + setHref(details.value)}> + {children} + + + + {EXAMPLES.map(({ title, badge, href }, index) => ( + + {title} + + {badge} + + ))} + + + + + ); +} diff --git a/src/components/extents.tsx b/src/components/extent.tsx similarity index 81% rename from src/components/extents.tsx rename to src/components/extent.tsx index 6157807..5fea497 100644 --- a/src/components/extents.tsx +++ b/src/components/extent.tsx @@ -1,18 +1,19 @@ +import { Box } from "@chakra-ui/react"; import type { SpatialExtent as StacSpatialExtent, TemporalExtent as StacTemporalExtent, } from "stac-ts"; export function SpatialExtent({ bbox }: { bbox: StacSpatialExtent }) { - return <>[{bbox.map((n) => Number(n.toFixed(4))).join(", ")}]; + return [{bbox.map((n) => Number(n.toFixed(4))).join(", ")}]; } export function TemporalExtent({ interval }: { interval: StacTemporalExtent }) { return ( - <> + —{" "} - + ); } diff --git a/src/components/filter.tsx b/src/components/filter.tsx new file mode 100644 index 0000000..5c9a772 --- /dev/null +++ b/src/components/filter.tsx @@ -0,0 +1,136 @@ +import { useEffect, useMemo, useState } from "react"; +import { Checkbox, DataList, Slider, Stack, Text } from "@chakra-ui/react"; +import type { StacCollection, StacItem } from "stac-ts"; +import { SpatialExtent } from "./extent"; +import type { BBox2D } from "../types/map"; +import type { DatetimeBounds, StacValue } from "../types/stac"; +import { getItemDatetimes } from "../utils/stac"; + +export default function Filter({ + filter, + setFilter, + bbox, + setDatetimeBounds, + value, + items, + collections, +}: { + filter: boolean; + setFilter: (filter: boolean) => void; + bbox: BBox2D | undefined; + setDatetimeBounds: (bounds: DatetimeBounds | undefined) => void; + value: StacValue; + items: StacItem[] | undefined; + collections: StacCollection[] | undefined; +}) { + const [filterStart, setFilterStart] = useState(); + const [filterEnd, setFilterEnd] = useState(); + const [sliderValue, setSliderValue] = useState(); + + const datetimes = useMemo(() => { + let start = + value.start_datetime && typeof value.start_datetime === "string" + ? new Date(value.start_datetime as string) + : null; + let end = + value.end_datetime && typeof value.end_datetime === "string" + ? new Date(value.end_datetime as string) + : null; + + if (items) { + for (const item of items) { + const itemDatetimes = getItemDatetimes(item); + if (itemDatetimes.start && (!start || itemDatetimes.start < start)) + start = itemDatetimes.start; + if (itemDatetimes.end && (!end || itemDatetimes.end > end)) + end = itemDatetimes.end; + } + } + + if (collections) { + for (const collection of collections) { + const extents = collection.extent?.temporal?.interval?.[0]; + if (extents) { + const collectionStart = extents[0] ? new Date(extents[0]) : null; + if (collectionStart && (!start || collectionStart < start)) + start = collectionStart; + const collectionEnd = extents[1] ? new Date(extents[1]) : null; + if (collectionEnd && (!end || collectionEnd > end)) + end = collectionEnd; + } + } + } + + return start && end ? { start, end } : null; + }, [value, items, collections]); + + useEffect(() => { + if (datetimes && !filterStart && !filterEnd) { + setSliderValue([datetimes.start.getTime(), datetimes.end.getTime()]); + } + }, [datetimes, filterStart, filterEnd]); + + useEffect(() => { + if (filterStart && filterEnd) { + setSliderValue([filterStart.getTime(), filterEnd.getTime()]); + setDatetimeBounds({ start: filterStart, end: filterEnd }); + } + }, [filterStart, filterEnd, setDatetimeBounds]); + + return ( + + setFilter(!!e.checked)} + > + + Filter collections and items? + + + + + + Bounding box + + {(bbox && ) || "not set"} + + + {datetimes && ( + + Datetime + + + + {filterStart + ? filterStart.toLocaleDateString() + : datetimes.start.toLocaleDateString()}{" "} + —{" "} + {filterEnd + ? filterEnd.toLocaleDateString() + : datetimes.end.toLocaleDateString()} + + { + setFilterStart(new Date(e.value[0])); + setFilterEnd(new Date(e.value[1])); + }} + > + + + + + + + + + + + )} + + + ); +} diff --git a/src/components/filter/temporal.tsx b/src/components/filter/temporal.tsx deleted file mode 100644 index 703cde4..0000000 --- a/src/components/filter/temporal.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useState } from "react"; -import useStacMap from "../../hooks/stac-map"; -import { HStack, Slider } from "@chakra-ui/react"; -import Section from "../section"; -import { LuFilter } from "react-icons/lu"; - -export default function TemporalFilter({ - start, - end, -}: { - start: Date; - end: Date; -}) { - const { setTemporalFilter } = useStacMap(); - const [filterStart, setFilterStart] = useState(); - const [filterEnd, setFilterEnd] = useState(); - const [value, setValue] = useState([start.getTime(), end.getTime()]); - - useEffect(() => { - setValue([ - (filterStart && filterStart.getTime()) || start.getTime(), - (filterEnd && filterEnd.getTime()) || end.getTime(), - ]); - }, [filterStart, filterEnd, start, end]); - - useEffect(() => { - if (filterStart && filterEnd) { - setTemporalFilter({ start: filterStart, end: filterEnd }); - } else { - setTemporalFilter(undefined); - } - }, [filterStart, filterEnd, setTemporalFilter]); - - return ( -
- { - setFilterStart(new Date(e.value[0])); - setFilterEnd(new Date(e.value[1])); - }} - min={start.getTime()} - max={end.getTime()} - gap={2} - > - - - - - - - - - {(filterStart && filterStart.toLocaleString()) || - start.toLocaleString()} - - - {(filterEnd && filterEnd.toLocaleString()) || end.toLocaleString()} - - - -
- ); -} diff --git a/src/components/header.tsx b/src/components/header.tsx deleted file mode 100644 index a27946f..0000000 --- a/src/components/header.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { - Box, - Button, - ButtonGroup, - FileUpload, - HStack, - IconButton, - Input, - type UseFileUploadReturn, -} from "@chakra-ui/react"; -import { useEffect, useState } from "react"; -import { LuUpload } from "react-icons/lu"; -import { Examples } from "../examples"; -import type { SetHref } from "../types/app"; -import About from "./about"; -import { ColorModeButton } from "./ui/color-mode"; - -export default function Header({ - href, - setHref, - fileUpload, -}: { - href: string | undefined; - setHref: SetHref; - fileUpload: UseFileUploadReturn; -}) { - const [value, setValue] = useState(href || ""); - - useEffect(() => { - if (href) { - setValue(href); - } - }, [href]); - - return ( - - { - e.preventDefault(); - setHref(value); - }} - w={"full"} - > - setValue(e.target.value)} - > - - - - - - - - - - - ); -} - -function Upload({ fileUpload }: { fileUpload: UseFileUploadReturn }) { - return ( - - - - - - - - - ); -} diff --git a/src/components/introduction.tsx b/src/components/introduction.tsx index 652a997..d27c8fa 100644 --- a/src/components/introduction.tsx +++ b/src/components/introduction.tsx @@ -1,18 +1,17 @@ import { - type UseFileUploadReturn, FileUpload, Link, Stack, + type UseFileUploadReturn, } from "@chakra-ui/react"; -import { Examples } from "../examples"; -import type { SetHref } from "../types/app"; +import { Examples } from "./examples"; export default function Introduction({ fileUpload, setHref, }: { fileUpload: UseFileUploadReturn; - setHref: SetHref; + setHref: (href: string | undefined) => void; }) { return ( diff --git a/src/components/item-collection.tsx b/src/components/item-collection.tsx deleted file mode 100644 index b64ef11..0000000 --- a/src/components/item-collection.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { - Accordion, - createTreeCollection, - FormatNumber, - Span, - Stack, - Table, - Text, - TreeView, -} from "@chakra-ui/react"; -import type { ReactNode } from "react"; -import { LuCircle, LuCircleDot } from "react-icons/lu"; -import useStacMap from "../hooks/stac-map"; -import type { StacGeoparquetMetadata, StacItemCollection } from "../types/stac"; -import Value from "./value"; - -export default function ItemCollection({ - itemCollection, -}: { - itemCollection: StacItemCollection; -}) { - const { stacGeoparquetMetadata } = useStacMap(); - - return ( - - - {itemCollection.features.length > 0 && - itemCollection.features.length + - " item" + - (itemCollection.features.length > 1 ? "s" : "")} - - {stacGeoparquetMetadata && ( - - )} - - ); -} - -interface Node { - id: string; - value: ReactNode; - children?: Node[]; -} - -function StacGeoparquetInfo({ - metadata, -}: { - metadata: StacGeoparquetMetadata; -}) { - const collection = createTreeCollection({ - rootNode: { - id: "root", - value: "Metadata", - children: metadata.keyValue.map((kv) => intoNode(kv.key, kv.value)), - }, - }); - - return ( - - - Number of items: - - - - - Schema - - - - - - - - Name - Type - - - - {metadata.describe.map((row) => ( - - {row.column_name} - {row.column_type} - - ))} - - - - - - - - Key-value metadata - - - - - - - - } - render={({ node, nodeState }) => - nodeState.isBranch ? ( - - - - {node.value} - - - ) : ( - - - {node.value} - - ) - } - > - - - - - - - - ); -} - -// eslint-disable-next-line -function intoNode(key: string, value: any) { - const children: Node[] = []; - - switch (typeof value) { - case "string": - case "number": - case "bigint": - case "boolean": - children.push({ - id: `${key}-value`, - value: value.toString(), - }); - break; - case "symbol": - case "undefined": - case "function": - children.push({ - id: `${key}-value`, - value: ( - - opaque - - ), - }); - break; - case "object": - if (Array.isArray(value)) { - children.push( - ...value.map((v, index) => intoNode(index.toString(), v)), - ); - } else { - children.push(...Object.entries(value).map(([k, v]) => intoNode(k, v))); - } - } - - return { - id: key, - value: key, - children, - }; -} diff --git a/src/components/item-search.tsx b/src/components/item-search.tsx new file mode 100644 index 0000000..40ffcb0 --- /dev/null +++ b/src/components/item-search.tsx @@ -0,0 +1,356 @@ +import { useEffect, useMemo, useState } from "react"; +import { LuPause, LuPlay, LuSearch, LuStepForward, LuX } from "react-icons/lu"; +import { + Alert, + Button, + ButtonGroup, + createListCollection, + Field, + Group, + Heading, + HStack, + IconButton, + Input, + Portal, + Progress, + Select, + Span, + Stack, + Switch, + Text, +} from "@chakra-ui/react"; +import type { + StacCollection, + StacItem, + StacLink, + TemporalExtent, +} from "stac-ts"; +import { SpatialExtent } from "./extent"; +import useStacSearch from "../hooks/stac-search"; +import type { BBox2D } from "../types/map"; +import type { StacSearch } from "../types/stac"; + +export default function ItemSearch({ + search, + setSearch, + links, + bbox, + collection, + setItems, +}: { + search: StacSearch | undefined; + setSearch: (search: StacSearch | undefined) => void; + links: StacLink[]; + bbox: BBox2D | undefined; + collection: StacCollection; + setItems: (items: StacItem[] | undefined) => void; +}) { + // We trust that there's at least one link. + const [link, setLink] = useState(links[0]); + const [useViewportBounds, setUseViewportBounds] = useState(true); + const [datetime, setDatetime] = useState( + search?.datetime + ); + const methods = createListCollection({ + items: links.map((link) => { + return { + label: (link.method as string) || "GET", + value: (link.method as string) || "GET", + }; + }), + }); + + return ( + + + setUseViewportBounds(e.checked)} + size={"sm"} + > + + Use viewport bounds + + + {bbox && useViewportBounds && ( + + + + )} + + + + + + { + const link = links.find( + (link) => (link.method || "GET") == e.value + ); + if (link) setLink(link); + }} + maxW={100} + > + + + + + + + + + + + + + {methods.items.map((method) => ( + + {method.label} + + + ))} + + + + + + + + + {search && ( + setSearch(undefined)} + setItems={setItems} + /> + )} + + ); +} + +function Search({ + search, + link, + onClear, + setItems, +}: { + search: StacSearch; + link: StacLink; + onClear: () => void; + setItems: (items: StacItem[] | undefined) => void; +}) { + const result = useStacSearch(search, link); + const numberMatched = result.data?.pages.at(0)?.numberMatched; + const items = useMemo(() => { + return result.data?.pages.flatMap((page) => page.features); + }, [result.data]); + const [autoFetch, setAutoFetch] = useState(false); + + useEffect(() => { + if (autoFetch && !result.isFetching && result.hasNextPage) + result.fetchNextPage(); + }, [result, autoFetch]); + + useEffect(() => { + setItems(items); + }, [items, setItems]); + + return ( + + Search results + {(numberMatched && ( + + + + + + + + + )) || + (items && {items.length} item(s) fetched)} + {result.error && ( + + + + Error while searching + {result.error.toString()} + + + )} + + + + + + + + ); +} + +function Datetime({ + interval, + setDatetime, +}: { + interval: TemporalExtent | undefined; + setDatetime: (datetime: string | undefined) => void; +}) { + const [startDatetime, setStartDatetime] = useState( + interval?.[0] ? new Date(interval[0]) : undefined + ); + const [endDatetime, setEndDatetime] = useState( + interval?.[1] ? new Date(interval[1]) : undefined + ); + + useEffect(() => { + if (startDatetime || endDatetime) { + setDatetime( + `${startDatetime?.toISOString() || ".."}/${endDatetime?.toISOString() || ".."}` + ); + } else { + setDatetime(undefined); + } + }, [startDatetime, endDatetime, setDatetime]); + + return ( + + + + + + ); +} + +function DatetimeInput({ + label, + datetime, + setDatetime, +}: { + label: string; + datetime: Date | undefined; + setDatetime: (datetime: Date | undefined) => void; +}) { + const [error, setError] = useState(); + const dateValue = datetime?.toISOString().split("T")[0] || ""; + const timeValue = datetime?.toISOString().split("T")[1].slice(0, 8) || ""; + + const setDatetimeChecked = (datetime: Date) => { + try { + datetime.toISOString(); + // eslint-disable-next-line + } catch (e: any) { + setError(e.toString()); + return; + } + setDatetime(datetime); + setError(undefined); + }; + const setDate = (date: string) => { + setDatetimeChecked( + new Date(date + "T" + (timeValue == "" ? "00:00:00" : timeValue) + "Z") + ); + }; + const setTime = (time: string) => { + if (dateValue != "") { + const newDatetime = new Date(dateValue); + const timeParts = time.split(":").map(Number); + newDatetime.setUTCHours(timeParts[0]); + newDatetime.setUTCMinutes(timeParts[1]); + if (timeParts.length == 3) { + newDatetime.setUTCSeconds(timeParts[2]); + } + setDatetimeChecked(newDatetime); + } + }; + + return ( + + {label} + + setDate(e.target.value)} + size={"sm"} + > + setTime(e.target.value)} + size={"sm"} + > + setDatetime(undefined)} + > + + + + {error} + + ); +} diff --git a/src/components/item.tsx b/src/components/item.tsx deleted file mode 100644 index 1593f5e..0000000 --- a/src/components/item.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Stack } from "@chakra-ui/react"; -import { LuFileImage } from "react-icons/lu"; -import type { StacItem } from "stac-ts"; -import Assets from "./assets"; -import Section from "./section"; -import Value from "./value"; - -export default function Item({ item }: { item: StacItem }) { - return ( - - -
- -
-
- ); -} diff --git a/src/components/items.tsx b/src/components/items.tsx new file mode 100644 index 0000000..999c59d --- /dev/null +++ b/src/components/items.tsx @@ -0,0 +1,29 @@ +import { Link, List } from "@chakra-ui/react"; +import type { StacItem } from "stac-ts"; + +export default function Items({ + items, + setHref, +}: { + items: StacItem[]; + setHref: (href: string | undefined) => void; +}) { + return ( + + {items.map((item, i) => ( + + { + const selfHref = item.links.find( + (link) => link.rel === "self" + )?.href; + if (selfHref) setHref(selfHref); + }} + > + {item.id} + + + ))} + + ); +} diff --git a/src/components/links.tsx b/src/components/links.tsx new file mode 100644 index 0000000..b5cce10 --- /dev/null +++ b/src/components/links.tsx @@ -0,0 +1,53 @@ +import { LuArrowUpToLine, LuExternalLink } from "react-icons/lu"; +import { ButtonGroup, IconButton, Link, List, Span } from "@chakra-ui/react"; +import type { StacLink } from "stac-ts"; + +const SET_HREF_REL_TYPES = [ + "root", + "parent", + "child", + "collection", + "item", + "search", + "items", +]; + +export default function Links({ + links, + setHref, +}: { + links: StacLink[]; + setHref: (href: string | undefined) => void; +}) { + return ( + + {links.map((link, i) => ( + + + {link.rel + (link.method ? ` (${link.method})` : "")} + + + {SET_HREF_REL_TYPES.includes(link.rel) && + (!link.method || link.method === "GET") && ( + { + e.preventDefault(); + setHref(link.href); + }} + > + + + + + )} + + + + + + + + ))} + + ); +} diff --git a/src/components/map.tsx b/src/components/map.tsx index 1e85a22..579b85f 100644 --- a/src/components/map.tsx +++ b/src/components/map.tsx @@ -1,152 +1,167 @@ -import { useBreakpointValue } from "@chakra-ui/react"; -import { Layer, type DeckProps } from "@deck.gl/core"; +import { type RefObject, useEffect, useMemo, useRef } from "react"; +import { + Map as MaplibreMap, + type MapRef, + useControl, +} from "react-map-gl/maplibre"; +import { type DeckProps, Layer } from "@deck.gl/core"; import { GeoJsonLayer } from "@deck.gl/layers"; import { MapboxOverlay } from "@deck.gl/mapbox"; import { GeoArrowPolygonLayer } from "@geoarrow/deck.gl-layers"; -import { bbox as turfBbox } from "@turf/bbox"; +import bbox from "@turf/bbox"; import bboxPolygon from "@turf/bbox-polygon"; -import { featureCollection } from "@turf/helpers"; -import type { BBox, Feature, FeatureCollection, GeoJSON } from "geojson"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useEffect, useRef, type RefObject } from "react"; -import { - Map as MaplibreMap, - useControl, - type MapRef, -} from "react-map-gl/maplibre"; -import type { StacCollection } from "stac-ts"; -import useStacMap from "../hooks/stac-map"; -import type { StacValue } from "../types/stac"; +import type { Table } from "apache-arrow"; +import type { SpatialExtent, StacCollection, StacItem } from "stac-ts"; +import type { BBox, Feature, FeatureCollection } from "geojson"; import { useColorModeValue } from "./ui/color-mode"; -import { useChildren } from "../hooks/stac-value"; - -const fillColor: [number, number, number, number] = [207, 63, 2, 50]; -const lineColor: [number, number, number, number] = [207, 63, 2, 100]; -// FIXME terrible and ugly -const inverseFillColor: [number, number, number, number] = [ - 256 - 207, - 256 - 63, - 256 - 2, - 50, -]; -const inverseLineColor: [number, number, number, number] = [ - 256 - 207, - 256 - 63, - 256 - 2, - 100, -]; +import type { BBox2D, Color } from "../types/map"; +import type { StacValue } from "../types/stac"; -export default function Map() { +export default function Map({ + value, + collections, + filteredCollections, + items, + filteredItems, + fillColor, + lineColor, + setBbox, + picked, + setPicked, + table, + setStacGeoparquetItemId, +}: { + value: StacValue | undefined; + collections: StacCollection[] | undefined; + filteredCollections: StacCollection[] | undefined; + items: StacItem[] | undefined; + filteredItems: StacItem[] | undefined; + fillColor: Color; + lineColor: Color; + setBbox: (bbox: BBox2D | undefined) => void; + picked: StacValue | undefined; + setPicked: (picked: StacValue | undefined) => void; + table: Table | undefined; + setStacGeoparquetItemId: (id: string | undefined) => void; +}) { const mapRef = useRef(null); const mapStyle = useColorModeValue( "positron-gl-style", - "dark-matter-gl-style", - ); - const { - value, - collections, - items, - filteredItems, - picked, - setPicked, - stacGeoparquetTable, - stacGeoparquetMetadata, - setStacGeoparquetItemId, - } = useStacMap(); - const children = useChildren(value, !collections); - const { - geojson, - bbox: valueBbox, - filled, - } = useStacValueLayerProperties( - value, - collections || children.filter((child) => child.type === "Collection"), + "dark-matter-gl-style" ); - const small = useBreakpointValue({ base: true, md: false }); - const bbox = valueBbox || stacGeoparquetMetadata?.bbox; - - useEffect(() => { - if (bbox && mapRef.current) { - const padding = small - ? { - top: window.innerHeight / 10 + window.innerHeight / 2, - bottom: window.innerHeight / 20, - right: window.innerWidth / 20, - left: window.innerWidth / 20, - } - : { - top: window.innerHeight / 10, - bottom: window.innerHeight / 20, - right: window.innerWidth / 20, - left: window.innerWidth / 20 + window.innerWidth / 3, - }; - mapRef.current.fitBounds(sanitizeBbox(bbox), { - linear: true, - padding, - }); + const valueGeoJson = useMemo(() => { + if (value) { + return valueToGeoJson(value); + } else { + return undefined; + } + }, [value]); + const pickedGeoJson = useMemo(() => { + if (picked) { + return valueToGeoJson(picked); + } else { + return undefined; } - }, [bbox, small]); + }, [picked]); + const collectionsGeoJson = useMemo(() => { + return (filteredCollections || collections) + ?.map( + (collection) => + collection.extent?.spatial?.bbox && + bboxPolygon(getCollectionExtents(collection) as BBox) + ) + .filter((feature) => !!feature); + }, [collections, filteredCollections]); + + const inverseFillColor: Color = [ + 256 - fillColor[0], + 256 - fillColor[1], + 256 - fillColor[2], + fillColor[3], + ]; + const inverseLineColor: Color = [ + 256 - fillColor[0], + 256 - fillColor[1], + 256 - fillColor[2], + fillColor[3], + ]; const layers: Layer[] = [ new GeoJsonLayer({ id: "picked", - data: picked as Feature | undefined, + data: pickedGeoJson, filled: true, - stroked: true, getFillColor: inverseFillColor, getLineColor: inverseLineColor, - lineWidthUnits: "pixels", getLineWidth: 2, + lineWidthUnits: "pixels", }), new GeoJsonLayer({ id: "items", data: (filteredItems || items) as Feature[] | undefined, filled: true, - stroked: true, getFillColor: fillColor, getLineColor: lineColor, - lineWidthUnits: "pixels", getLineWidth: 2, + lineWidthUnits: "pixels", pickable: true, onClick: (info) => { setPicked(info.object); }, }), new GeoJsonLayer({ - id: "value", - data: geojson, - filled: filled && !picked && (!items || items.length == 0), - stroked: true, - getFillColor: fillColor, + id: "collections", + data: collectionsGeoJson, + filled: false, getLineColor: lineColor, + getLineWidth: 2, lineWidthUnits: "pixels", + }), + new GeoJsonLayer({ + id: "value", + data: valueGeoJson, + filled: !items, + getFillColor: collections ? inverseFillColor : fillColor, + getLineColor: collections ? inverseLineColor : lineColor, getLineWidth: 2, - updateTriggers: [picked, items], + lineWidthUnits: "pixels", + pickable: value?.type !== "Collection", + onClick: (info) => { + setPicked(info.object); + }, }), ]; - if (stacGeoparquetTable) { + if (table) layers.push( new GeoArrowPolygonLayer({ - id: "stac-geoparquet", - data: stacGeoparquetTable, + id: "table", + data: table, filled: true, - stroked: true, getFillColor: fillColor, getLineColor: lineColor, - lineWidthUnits: "pixels", getLineWidth: 2, - autoHighlight: true, + lineWidthUnits: "pixels", pickable: true, onClick: (info) => { - setStacGeoparquetItemId( - stacGeoparquetTable.getChild("id")?.get(info.index), - ); + setStacGeoparquetItemId(table.getChild("id")?.get(info.index)); }, - updateTriggers: [picked], - }), + }) ); - } + + useEffect(() => { + if (value && mapRef.current) { + const padding = { + top: window.innerHeight / 10, + bottom: window.innerHeight / 20, + right: window.innerWidth / 20, + left: window.innerWidth / 20 + window.innerWidth / 3, + }; + const bbox = getBbox(value, collections); + if (bbox) mapRef.current.fitBounds(bbox, { linear: true, padding }); + } + }, [value, collections]); return ( { + if (mapRef.current && !mapRef.current.isMoving()) + setBbox(sanitizeBbox(mapRef.current?.getBounds().toArray().flat())); + }} > 0 && - turfBbox(value as FeatureCollection)) || - undefined, - filled: true, - }; - } - } else { - return { geojson: undefined, bbox: undefined, filled: undefined }; +function valueToGeoJson(value: StacValue) { + switch (value.type) { + case "Catalog": + return undefined; + case "Collection": + return ( + value.extent?.spatial?.bbox && + bboxPolygon(getCollectionExtents(value) as BBox) + ); + case "Feature": + return value as Feature; + case "FeatureCollection": + return value as FeatureCollection; } } -function useCollectionsLayerProperties( - collections: StacCollection[] | undefined, -) { - if (collections) { - const bbox: [number, number, number, number] = [180, 90, -180, -90]; - const polygons = collections - .map((collection) => { - if (collection.extent?.spatial?.bbox) { - const sanitizedBbox = sanitizeBbox(collection.extent.spatial.bbox[0]); - if (sanitizedBbox[0] < bbox[0]) { - bbox[0] = sanitizedBbox[0]; - } - if (sanitizedBbox[1] < bbox[1]) { - bbox[1] = sanitizedBbox[1]; - } - if (sanitizedBbox[2] > bbox[2]) { - bbox[2] = sanitizedBbox[2]; - } - if (sanitizedBbox[3] > bbox[3]) { - bbox[3] = sanitizedBbox[3]; - } - return bboxPolygon(sanitizedBbox); - } else { - return undefined; - } - }) - .filter((bbox) => !!bbox); - if (polygons.length > 0) { - return { geojson: featureCollection(polygons), bbox }; - } else { - return { geojson: undefined, bbox: undefined }; - } - } else { - return { geojson: undefined, bbox: undefined }; - } +function getCollectionExtents(collection: StacCollection) { + return collection.extent?.spatial?.bbox?.[0]; } -function sanitizeBbox(bbox: number[]) { - const newBbox = (bbox.length == 6 && [ - bbox[0], - bbox[1], - bbox[3], - bbox[4], - ]) || [bbox[0], bbox[1], bbox[2], bbox[3]]; - if (newBbox[0] < -180) { - newBbox[0] = -180; - } - if (newBbox[1] < -90) { - newBbox[1] = -90; - } - if (newBbox[2] > 180) { - newBbox[2] = 180; +function getBbox( + value: StacValue, + collections: StacCollection[] | undefined +): BBox2D | undefined { + let valueBbox; + switch (value.type) { + case "Catalog": + valueBbox = + collections && collections.length > 0 + ? sanitizeBbox( + collections + .map((collection) => getCollectionExtents(collection)) + .filter((extents) => !!extents) + .reduce((accumulator, currentValue) => { + return [ + Math.min(accumulator[0], currentValue[0]), + Math.min(accumulator[1], currentValue[1]), + Math.max(accumulator[2], currentValue[2]), + Math.max(accumulator[3], currentValue[3]), + ]; + }) + ) + : undefined; + break; + case "Collection": + valueBbox = getCollectionExtents(value); + break; + case "Feature": + valueBbox = value.bbox; + break; + case "FeatureCollection": + valueBbox = bbox(value as FeatureCollection) as BBox2D; + break; } - if (newBbox[3] > 90) { - newBbox[3] = 90; + return valueBbox ? sanitizeBbox(valueBbox) : undefined; +} + +function sanitizeBbox(bbox: BBox | SpatialExtent): BBox2D { + if (bbox.length === 6) { + return [ + Math.max(bbox[0], -180), + Math.max(bbox[1], -90), + Math.min(bbox[3], 180), + Math.min(bbox[4], 90), + ]; + } else { + return [ + Math.max(bbox[0], -180), + Math.max(bbox[1], -90), + Math.min(bbox[2], 180), + Math.min(bbox[3], 90), + ]; } - return newBbox as [number, number, number, number]; } diff --git a/src/components/navigation-breadcrumbs.tsx b/src/components/navigation-breadcrumbs.tsx deleted file mode 100644 index 4d4457e..0000000 --- a/src/components/navigation-breadcrumbs.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { Breadcrumb, HStack, Spinner } from "@chakra-ui/react"; -import { useEffect, useState, type ReactNode } from "react"; -import { LuFile, LuFiles, LuFolder, LuFolderPlus } from "react-icons/lu"; -import useStacMap from "../hooks/stac-map"; -import { useStacLinkContainer } from "../hooks/stac-value"; -import type { SetHref } from "../types/app"; -import type { StacValue } from "../types/stac"; - -export function NavigationBreadcrumbs({ - href, - setHref, -}: { - href: string | undefined; - setHref: SetHref; -}) { - const { value, picked } = useStacMap()!; - const root = useStacLinkContainer(value, "root"); - const parent = useStacLinkContainer(value, "parent"); - const [breadcrumbs, setBreadcrumbs] = useState(); - - useEffect(() => { - const breadcrumbs = []; - if (value) { - if (root && !hasSameHref(root, value)) { - breadcrumbs.push( - , - ); - } - if ( - parent && - !hasSameHref(parent, value) && - (!root || !hasSameHref(root, parent)) - ) { - breadcrumbs.push( - , - ); - } - breadcrumbs.push( - , - ); - if (picked) { - breadcrumbs.push( - , - ); - } - } else if (href) { - breadcrumbs.push( - - - - - , - ); - } else { - breadcrumbs.push( - - stac-map - , - ); - } - setBreadcrumbs( - breadcrumbs.flatMap((value, i) => [ - value, - i < breadcrumbs.length - 1 && ( - - ), - ]), - ); - }, [href, value, parent, root, picked, setHref]); - - return ( - - {breadcrumbs} - - ); -} - -function BreadcrumbItem({ - value, - text, - setHref, - current = false, - clearPicked = false, -}: { - value: StacValue; - text: string; - setHref: (href: string | undefined) => void; - current?: boolean; - clearPicked?: boolean; -}) { - const { setPicked } = useStacMap()!; - const href = value.links?.find((link) => link.rel == "self")?.href; - return ( - - {(current && ( - - - {getValueIcon(value)} - {text} - - - )) || ( - { - e.preventDefault(); - if (clearPicked) { - setPicked(undefined); - } else if (href) { - setHref(href); - } - }} - > - {getValueIcon(value)} - {text} - - )} - - ); -} - -function getValueIcon(value: StacValue) { - switch (value.type) { - case "Catalog": - return ; - case "Collection": - return ; - case "Feature": - return ; - case "FeatureCollection": - return ; - } -} - -function getValueType(value: StacValue) { - switch (value.type) { - case "Catalog": - return "Catalog"; - case "Collection": - return "Collection"; - case "Feature": - return "Item"; - case "FeatureCollection": - return "ItemCollection"; - } -} - -function hasSameHref(a: StacValue, b: StacValue) { - const aHref = a.links?.find((link) => link.rel == "self")?.href; - const bHref = b.links?.find((link) => link.rel == "self")?.href; - return aHref && bHref && aHref === bHref; -} diff --git a/src/components/overlay.tsx b/src/components/overlay.tsx new file mode 100644 index 0000000..009754b --- /dev/null +++ b/src/components/overlay.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { + Box, + Button, + GridItem, + HStack, + Input, + SimpleGrid, + type UseFileUploadReturn, +} from "@chakra-ui/react"; +import type { StacCatalog, StacCollection, StacItem } from "stac-ts"; +import Breadcrumbs from "./breadcrumbs"; +import { Examples } from "./examples"; +import Panel from "./panel"; +import type { BBox2D } from "../types/map"; +import type { DatetimeBounds, StacValue } from "../types/stac"; + +export default function Overlay({ + href, + setHref, + fileUpload, + value, + error, + catalogs, + collections, + filteredCollections, + filter, + setFilter, + bbox, + picked, + setPicked, + items, + filteredItems, + setItems, + setDatetimeBounds, +}: { + href: string | undefined; + setHref: (href: string | undefined) => void; + error: Error | undefined; + value: StacValue | undefined; + catalogs: StacCatalog[] | undefined; + collections: StacCollection[] | undefined; + filteredCollections: StacCollection[] | undefined; + fileUpload: UseFileUploadReturn; + filter: boolean; + setFilter: (filter: boolean) => void; + bbox: BBox2D | undefined; + picked: StacValue | undefined; + setPicked: (picked: StacValue | undefined) => void; + items: StacItem[] | undefined; + filteredItems: StacItem[] | undefined; + setItems: (items: StacItem[] | undefined) => void; + setDatetimeBounds: (bounds: DatetimeBounds | undefined) => void; +}) { + return ( + + + + + {(value && ( + + )) || stac-map} + + + + + + + + + + + + + + ); +} + +function HrefInput({ + href, + setHref, +}: { + href: string | undefined; + setHref: (href: string | undefined) => void; +}) { + const [value, setValue] = useState(href || ""); + + useEffect(() => { + if (href) { + setValue(href); + } + }, [href]); + + return ( + { + e.preventDefault(); + setHref(value); + }} + w={"full"} + > + setValue(e.target.value)} + > + + ); +} diff --git a/src/components/panel.tsx b/src/components/panel.tsx index 71d338d..f0d203c 100644 --- a/src/components/panel.tsx +++ b/src/components/panel.tsx @@ -1,179 +1,311 @@ +import { type ReactNode, useEffect, useMemo, useState } from "react"; +import type { IconType } from "react-icons/lib"; import { + LuFiles, + LuFilter, + LuFilterX, + LuFolder, + LuFolderPlus, + LuFolderSearch, + LuLink, + LuList, + LuSearch, +} from "react-icons/lu"; +import { + Accordion, Alert, Box, + HStack, + Icon, SkeletonText, - Stack, type UseFileUploadReturn, } from "@chakra-ui/react"; -import { useEffect, useMemo, useState } from "react"; -import type { StacLink } from "stac-ts"; -import useStacMap from "../hooks/stac-map"; -import type { SetHref } from "../types/app"; -import type { StacSearch, StacValue } from "../types/stac"; -import { Catalog } from "./catalog"; -import { Collection } from "./collection"; +import { useQuery } from "@tanstack/react-query"; +import type { StacCatalog, StacCollection, StacItem } from "stac-ts"; +import Assets from "./assets"; +import Catalogs from "./catalogs"; +import CollectionSearch from "./collection-search"; +import Collections from "./collections"; +import Filter from "./filter"; import Introduction from "./introduction"; -import Item from "./item"; -import ItemCollection from "./item-collection"; -import { NavigationBreadcrumbs } from "./navigation-breadcrumbs"; -import { ItemSearchResults } from "./search/item"; -import { getItemDatetimes } from "../stac"; -import TemporalFilter from "./filter/temporal"; +import ItemSearch from "./item-search"; +import Items from "./items"; +import Links from "./links"; +import Properties from "./properties"; +import Value from "./value"; +import type { BBox2D } from "../types/map"; +import type { + DatetimeBounds, + StacAssets, + StacSearch, + StacValue, +} from "../types/stac"; +import { fetchStac } from "../utils/stac"; export default function Panel({ + value, + error, + catalogs, + collections, + filteredCollections, + items, + filteredItems, href, setHref, fileUpload, + filter, + setFilter, + bbox, + setItems, + setDatetimeBounds, }: { + value: StacValue | undefined; + error: Error | undefined; + catalogs: StacCatalog[] | undefined; + collections: StacCollection[] | undefined; + filteredCollections: StacCollection[] | undefined; + items: StacItem[] | undefined; + filteredItems: StacItem[] | undefined; href: string | undefined; - setHref: SetHref; + setHref: (href: string | undefined) => void; fileUpload: UseFileUploadReturn; + filter: boolean; + setFilter: (filter: boolean) => void; + bbox: BBox2D | undefined; + setItems: (items: StacItem[] | undefined) => void; + setDatetimeBounds: (bounds: DatetimeBounds | undefined) => void; }) { - const { value, picked, setPicked, items, setItems } = useStacMap(); const [search, setSearch] = useState(); - const [searchLink, setSearchLink] = useState(); - const [autoLoad, setAutoLoad] = useState(false); - - useEffect(() => { - setItems(undefined); - setPicked(undefined); - }, [search, setPicked, setItems]); - - const { start: itemsStart, end: itemsEnd } = useMemo(() => { - if (items) { - let start = null; - let end = null; - for (const item of items) { - const { start: itemStart, end: itemEnd } = getItemDatetimes(item); - if (itemStart && (!start || itemStart < start)) start = itemStart; - if (itemEnd && (!end || itemEnd > end)) end = itemEnd; + const rootHref = value?.links?.find((link) => link.rel === "root")?.href; + const rootData = useQuery({ + queryKey: ["stac-value", rootHref], + enabled: !!rootHref, + queryFn: () => fetchStac(rootHref), + }); + const searchLinks = rootData.data?.links?.filter( + (link) => link.rel === "search" + ); + const { links, assets, properties } = useMemo(() => { + if (value) { + if (value.type === "Feature") { + return { + links: value.links, + assets: value.assets as StacAssets | undefined, + properties: value.properties, + }; + } else { + const { links, assets, ...properties } = value; + return { links, assets: assets as StacAssets | undefined, properties }; } - return { start, end }; } else { - return { start: null, end: null }; + return { links: undefined, assets: undefined, properties: undefined }; } - }, [items]); + }, [value]); - let content; - if (!href) { - content = ( - - ); - } else if (!value) { - content = ; - } else if (picked) { - content = ( - - ); - } else { - content = ( - - ); - } + // Handled by the value + if (properties?.description) delete properties["description"]; + const thumbnailAsset = + assets && + ((Object.keys(assets).includes("thumbnail") && assets["thumbnail"]) || + Object.values(assets).find((asset) => + asset.roles?.includes("thumbnail") + )); + const nextLink = links?.find((link) => link.rel === "next"); + const prevLink = links?.find((link) => link.rel === "previous"); + // We already provide linked children and items in their own pane. + const filteredLinks = links?.filter( + (link) => link.rel !== "child" && link.rel !== "item" + ); + + useEffect(() => { + setItems(undefined); + }, [search, setItems]); return ( - - - + {(href && value && ( + - - - {content} - {search && searchLink && ( - - )} - {itemsStart && itemsEnd && ( - + nextLink={nextLink} + prevLink={prevLink} + /> + )) || + (error && ( + + + + Error while fetching STAC value + {error.toString()} + + + )) || + (href && ) || ( + )} - + + {value && ( + + {catalogs && ( +
+ +
+ )} + + {collections && ( +
+ Collections{" "} + {(filteredCollections && + `(${filteredCollections?.length}/${collections.length})`) || + `(${collections.length})`} + + } + AccordionIcon={LuFolderPlus} + accordionValue="collections" + > + +
+ )} + + {collections && ( +
+ +
+ )} + + {value.type === "Collection" && + searchLinks && + searchLinks.length > 0 && ( +
+ +
+ )} + + {(items || collections || value.type === "FeatureCollection") && ( +
+ +
+ )} + + {items && ( +
+ Items{" "} + {(filteredItems && + `(${filteredItems?.length}/${items.length})`) || + `(${items.length})`} + + } + AccordionIcon={LuFolderPlus} + accordionValue="collections" + > + +
+ )} + + {assets && ( +
+ +
+ )} + + {filteredLinks && filteredLinks.length > 0 && ( +
+ +
+ )} + + {properties && ( +
+ +
+ )} +
+ )}
); } -function ValueContent({ - value, - setHref, - search, - setSearch, - setSearchLink, - autoLoad, - setAutoLoad, +function Section({ + title, + AccordionIcon, + accordionValue, + children, }: { - value: StacValue; - setHref: SetHref; - search: StacSearch | undefined; - setSearch: (search: StacSearch | undefined) => void; - setSearchLink: (link: StacLink | undefined) => void; - autoLoad: boolean; - setAutoLoad: (autoLoad: boolean) => void; + title: ReactNode; + AccordionIcon: IconType; + accordionValue: string; + children: ReactNode; }) { - switch (value.type) { - case "Catalog": - return ; - case "Collection": - return ( - - ); - case "Feature": - return ; - case "FeatureCollection": - return ; - case undefined: - return ( - - - - Value does not have a "type" field - - - ); - default: - return ( - - - - Unknown "type" field - - { - // @ts-expect-error Fallback for unknown types - value.type - }{" "} - is not a valid STAC type - - - - ); - } + return ( + + + + + + {" "} + {title} + + + + + {children} + + + ); } diff --git a/src/components/properties.tsx b/src/components/properties.tsx new file mode 100644 index 0000000..a03e94b --- /dev/null +++ b/src/components/properties.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { LuFileJson } from "react-icons/lu"; +import { Code, DataList, Dialog, IconButton, Portal } from "@chakra-ui/react"; + +export default function Properties({ + properties, +}: { + properties: { [k: string]: unknown }; +}) { + return ( + + {Object.keys(properties).map((key) => ( + + ))} + + ); +} + +function Property({ + propertyKey, + propertyValue, +}: { + propertyKey: string; + propertyValue: unknown; +}) { + return ( + + {propertyKey} + {getValue(propertyValue)} + + ); +} + +function getValue(value: unknown): ReactNode { + switch (typeof value) { + case "string": + case "number": + case "bigint": + case "boolean": + case "undefined": + return value; + case "object": + return ( + + + + + + + + + + + +
+                    {JSON.stringify(value, null, 2)}
+                  
+
+
+
+
+
+ ); + case "symbol": + case "function": + return null; + } +} diff --git a/src/components/search/collection.tsx b/src/components/search/collection.tsx deleted file mode 100644 index 1aea9aa..0000000 --- a/src/components/search/collection.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { - Combobox, - createListCollection, - HStack, - Portal, - SegmentGroup, - Stack, -} from "@chakra-ui/react"; -import { useMemo, useState } from "react"; -import type { StacCollection } from "stac-ts"; -import type { SetHref } from "../../types/app"; -import { NaturalLanguageCollectionSearch } from "./natural-language"; - -export function CollectionSearch({ - href, - collections, - setHref, -}: { - href?: string; - collections: StacCollection[]; - setHref: SetHref; -}) { - const [value, setValue] = useState<"Text" | "Natural language">("Text"); - return ( - - - - setValue(e.value as "Text" | "Natural language") - } - > - - - - - {value === "Text" && ( - - )} - {value === "Natural language" && href && ( - - )} - - ); -} - -export function CollectionCombobox({ - collections, - setHref, -}: { - collections: StacCollection[]; - setHref: SetHref; -}) { - const [searchValue, setSearchValue] = useState(""); - - const filteredCollections = useMemo(() => { - return collections.filter( - (collection) => - collection.title?.toLowerCase().includes(searchValue.toLowerCase()) || - collection.id.toLowerCase().includes(searchValue.toLowerCase()) || - collection.description - .toLowerCase() - .includes(searchValue.toLowerCase()), - ); - }, [searchValue, collections]); - - const collection = useMemo( - () => - createListCollection({ - items: filteredCollections, - itemToString: (collection) => collection.title || collection.id, - itemToValue: (collection) => collection.id, - }), - - [filteredCollections], - ); - - return ( - setSearchValue(details.inputValue)} - onSelect={(details) => { - const collection = collections.find( - (collection) => collection.id == details.itemValue, - ); - if (collection) { - const selfHref = collection.links.find( - (link) => link.rel == "self", - )?.href; - if (selfHref) { - setHref(selfHref); - } - } - }} - > - - - - - - - - - - - - - {filteredCollections.map((collection) => ( - - {collection.title || collection.id} - - - - ))} - - No collections found - - - - - - ); -} diff --git a/src/components/search/item.tsx b/src/components/search/item.tsx deleted file mode 100644 index c767d9e..0000000 --- a/src/components/search/item.tsx +++ /dev/null @@ -1,470 +0,0 @@ -import { - Accordion, - Alert, - Button, - ButtonGroup, - Checkbox, - createListCollection, - Field, - Group, - Heading, - HStack, - IconButton, - Input, - Link, - Portal, - Progress, - Select, - Spinner, - Stack, - Switch, - Text, -} from "@chakra-ui/react"; -import type { BBox } from "geojson"; -import { useEffect, useState } from "react"; -import { - LuFiles, - LuPause, - LuPlay, - LuSearch, - LuStepForward, - LuX, -} from "react-icons/lu"; -import { useMap } from "react-map-gl/maplibre"; -import type { StacCollection, StacLink, TemporalExtent } from "stac-ts"; -import useStacMap from "../../hooks/stac-map"; -import useStacSearch from "../../hooks/stac-search"; -import type { StacSearch } from "../../types/stac"; -import DownloadButtons from "../download"; -import { SpatialExtent } from "../extents"; -import Section from "../section"; - -interface NormalizedBbox { - bbox: BBox; - isCrossingAntimeridian: boolean; -} - -export default function ItemSearch({ - collection, - links, - search, - setSearch, - setSearchLink, - autoLoad, - setAutoLoad, -}: { - collection: StacCollection; - links: StacLink[]; - search: StacSearch | undefined; - setSearch: (search: StacSearch | undefined) => void; - setSearchLink: (link: StacLink | undefined) => void; - autoLoad: boolean; - setAutoLoad: (autoLoad: boolean) => void; -}) { - const [link, setLink] = useState(links[0]); - const [normalizedBbox, setNormalizedBbox] = useState(); - const [datetime, setDatetime] = useState( - search?.datetime, - ); - const [useViewportBounds, setUseViewportBounds] = useState(true); - const { map } = useMap(); - - const methods = createListCollection({ - items: links.map((link) => { - return { - label: (link.method as string) || "GET", - value: (link.method as string) || "GET", - }; - }), - }); - - useEffect(() => { - function getNormalizedMapBounds() { - return normalizeBbox(map?.getBounds().toArray().flat() as BBox); - } - - const listener = () => { - setNormalizedBbox(getNormalizedMapBounds()); - }; - - if (useViewportBounds && map) { - map.on("moveend", listener); - setNormalizedBbox(getNormalizedMapBounds()); - } else { - map?.off("moveend", listener); - setNormalizedBbox(undefined); - } - }, [map, useViewportBounds]); - - return ( - - - - - - Spatial - - - - - - setUseViewportBounds(e.checked)} - size={"sm"} - > - - Use viewport bounds - - - {normalizedBbox && ( - - - - )} - {normalizedBbox?.isCrossingAntimeridian && ( - - - - Antimeridian-crossing viewport - - The viewport bounds cross the{" "} - - antimeridian - - , and may servers do not support antimeridian-crossing - bounding boxes. The search bounding box has been reduced - to only one side of the antimeridian. - - - - )} - - - - - - - - Temporal - - - - - - - - - - - - - - setLink(links.find((link) => (link.method || "GET") == e.value)) - } - maxW={100} - > - - - - - - - - - - - - - {methods.items.map((method) => ( - - {method.label} - - - ))} - - - - - setAutoLoad(!!e.checked)} - > - - - - - Auto-load? - - - - ); -} - -export function ItemSearchResults({ - search, - link, - autoLoad, - setAutoLoad, - setSearch, -}: { - search: StacSearch; - link: StacLink; - autoLoad: boolean; - setAutoLoad: (autoLoad: boolean) => void; - setSearch: (search: StacSearch | undefined) => void; -}) { - const results = useStacSearch(search, link); - const { items, setItems } = useStacMap(); - - useEffect(() => { - setItems(results.data?.pages.flatMap((page) => page.features)); - }, [results.data, setItems]); - - useEffect(() => { - if (autoLoad && !results.isFetching && results.hasNextPage) { - results.fetchNextPage(); - } - }, [results, autoLoad]); - - const numberMatched = results.data?.pages[0].numberMatched; - const value = items?.length || 0; - - return ( -
- - - - - - - - {items?.length || 0} / {numberMatched || "?"} - - - - - - results.fetchNextPage()} - > - - - setAutoLoad(!autoLoad)} - disabled={!results.hasNextPage} - > - {(autoLoad && ) || } - - { - setSearch(undefined); - }} - > - - - - {((autoLoad && results.hasNextPage) || results.isFetching) && ( - - )} - - {results.error && ( - - - - Error while searching - {results.error.toString()} - - - )} - {items && ( - - )} - -
- ); -} - -function Datetime({ - interval, - setDatetime, -}: { - interval: TemporalExtent | undefined; - setDatetime: (datetime: string | undefined) => void; -}) { - const [startDatetime, setStartDatetime] = useState( - interval?.[0] ? new Date(interval[0]) : undefined, - ); - const [endDatetime, setEndDatetime] = useState( - interval?.[1] ? new Date(interval[1]) : undefined, - ); - - useEffect(() => { - if (startDatetime || endDatetime) { - setDatetime( - `${startDatetime?.toISOString() || ".."}/${endDatetime?.toISOString() || ".."}`, - ); - } else { - setDatetime(undefined); - } - }, [startDatetime, endDatetime, setDatetime]); - - return ( - - - - - - ); -} - -function DatetimeInput({ - label, - datetime, - setDatetime, -}: { - label: string; - datetime: Date | undefined; - setDatetime: (datetime: Date | undefined) => void; -}) { - const [error, setError] = useState(); - const dateValue = datetime?.toISOString().split("T")[0] || ""; - const timeValue = datetime?.toISOString().split("T")[1].slice(0, 8) || ""; - - const setDatetimeChecked = (datetime: Date) => { - try { - datetime.toISOString(); - // eslint-disable-next-line - } catch (e: any) { - setError(e.toString()); - return; - } - setDatetime(datetime); - setError(undefined); - }; - const setDate = (date: string) => { - setDatetimeChecked( - new Date(date + "T" + (timeValue == "" ? "00:00:00" : timeValue) + "Z"), - ); - }; - const setTime = (time: string) => { - if (dateValue != "") { - const newDatetime = new Date(dateValue); - const timeParts = time.split(":").map(Number); - newDatetime.setUTCHours(timeParts[0]); - newDatetime.setUTCMinutes(timeParts[1]); - if (timeParts.length == 3) { - newDatetime.setUTCSeconds(timeParts[2]); - } - setDatetimeChecked(newDatetime); - } - }; - - return ( - - {label} - - setDate(e.target.value)} - size={"sm"} - > - setTime(e.target.value)} - size={"sm"} - > - setDatetime(undefined)} - > - - - - {error} - - ); -} - -function normalizeBbox(bbox: BBox): NormalizedBbox { - if (bbox[2] - bbox[0] >= 360) { - return { - bbox: [-180, bbox[1], 180, bbox[3]], - isCrossingAntimeridian: false, - }; - } else if (bbox[0] < -180) { - return normalizeBbox([bbox[0] + 360, bbox[1], bbox[2] + 360, bbox[3]]); - } else if (bbox[0] > 180) { - return normalizeBbox([bbox[0] - 360, bbox[1], bbox[2] - 360, bbox[3]]); - } else if (bbox[2] > 180) { - if ((bbox[0] + bbox[2]) / 2 > 180) { - return { - bbox: [-180, bbox[1], bbox[2] - 360, bbox[3]], - isCrossingAntimeridian: true, - }; - } else { - return { - bbox: [bbox[0], bbox[1], 180, bbox[3]], - isCrossingAntimeridian: true, - }; - } - } else { - return { bbox: bbox, isCrossingAntimeridian: false }; - } -} diff --git a/src/components/search/natural-language.tsx b/src/components/search/natural-language.tsx deleted file mode 100644 index b80df10..0000000 --- a/src/components/search/natural-language.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - CloseButton, - Field, - Input, - InputGroup, - SkeletonText, - Stack, -} from "@chakra-ui/react"; -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; -import { LuSearch } from "react-icons/lu"; -import type { StacCollection } from "stac-ts"; -import type { SetHref } from "../../types/app"; -import type { NaturalLanguageCollectionSearchResult } from "../../types/stac"; -import { CollectionCard } from "../collection"; - -export function NaturalLanguageCollectionSearch({ - href, - setHref, - collections, -}: { - href: string; - setHref: SetHref; - collections: StacCollection[]; -}) { - const [query, setQuery] = useState(); - const [value, setValue] = useState(""); - const inputRef = useRef(null); - - const endElement = value ? ( - { - setValue(""); - setQuery(undefined); - inputRef.current?.focus(); - }} - me="-2" - /> - ) : undefined; - - return ( - -
{ - e.preventDefault(); - setQuery(value); - }} - > - - } - endElement={endElement} - > - setValue(e.target.value)} - > - - - Natural language collection search is experimental, and can be - rather slow. - - -
- {query && ( - - )} -
- ); -} - -function Results({ - query, - href, - setHref, - collections, -}: { - query: string; - href: string; - setHref: SetHref; - collections: StacCollection[]; -}) { - const [results, setResults] = useState< - { - collection: StacCollection | undefined; - result: NaturalLanguageCollectionSearchResult; - }[] - >(); - const { data } = useQuery<{ - results: NaturalLanguageCollectionSearchResult[]; - }>({ - queryKey: [href, query], - queryFn: async () => { - const body = JSON.stringify({ - query, - catalog_url: href, - }); - const url = new URL( - "search", - import.meta.env.VITE_STAC_NATURAL_QUERY_API, - ); - return await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body, - }).then((response) => { - if (response.ok) { - return response.json(); - } else { - throw new Error( - `Error while doing a natural language search against ${href}: ${response.statusText}`, - ); - } - }); - }, - }); - - useEffect(() => { - if (data) { - setResults( - data.results.map((result: NaturalLanguageCollectionSearchResult) => { - return { - result, - collection: collections.find( - (collection) => collection.id == result.collection_id, - ), - }; - }), - ); - } else { - setResults(undefined); - } - }, [data, collections]); - - if (results) { - return ( - - {results.map((result) => { - if (result.collection) { - return ( - - ); - } else { - return null; - } - })} - - ); - } else { - return ; - } -} diff --git a/src/components/section.tsx b/src/components/section.tsx deleted file mode 100644 index 749a9fb..0000000 --- a/src/components/section.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Collapsible, HStack, Heading, Icon } from "@chakra-ui/react"; -import { type ReactNode, useState } from "react"; -import type { IconType } from "react-icons/lib"; -import { LuChevronDown, LuChevronRight } from "react-icons/lu"; - -export default function Section({ - title, - TitleIcon, - titleSize = "lg", - children, -}: { - title: ReactNode; - TitleIcon?: IconType; - titleSize?: - | "sm" - | "md" - | "lg" - | "xl" - | "2xl" - | "xs" - | "3xl" - | "4xl" - | "5xl" - | "6xl" - | "7xl" - | undefined; - children: ReactNode; -}) { - const [open, isOpen] = useState(true); - return ( - isOpen(details.open)} - > - - - - - {TitleIcon && ( - - - - )} - {title} - - - - {(open && ) || ( - - )} - - - - {children} - - ); -} diff --git a/src/components/thumbnail.tsx b/src/components/thumbnail.tsx deleted file mode 100644 index 4c094c8..0000000 --- a/src/components/thumbnail.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Center, Image, Skeleton } from "@chakra-ui/react"; -import { useEffect, useState } from "react"; -import type { StacAsset } from "stac-ts"; - -export default function Thumbnail({ asset }: { asset: StacAsset }) { - const [loading, setLoading] = useState(true); - - useEffect(() => { - setLoading(true); - }, [asset.href]); - - return ( - <> - {loading && ( -
- -
- )} - setLoading(false)} - onError={() => setLoading(false)} - width="100%" - borderRadius={4} - display={loading ? "none" : "auto"} - /> - - ); -} diff --git a/src/components/ui/color-mode.tsx b/src/components/ui/color-mode.tsx index 891a7fe..bf8e346 100644 --- a/src/components/ui/color-mode.tsx +++ b/src/components/ui/color-mode.tsx @@ -1,11 +1,11 @@ "use client"; +import * as React from "react"; +import { LuMoon, LuSun } from "react-icons/lu"; import type { IconButtonProps, SpanProps } from "@chakra-ui/react"; import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"; import type { ThemeProviderProps } from "next-themes"; import { ThemeProvider, useTheme } from "next-themes"; -import * as React from "react"; -import { LuMoon, LuSun } from "react-icons/lu"; export interface ColorModeProviderProps extends ThemeProviderProps {} @@ -87,7 +87,7 @@ export const LightMode = React.forwardRef( {...props} /> ); - }, + } ); export const DarkMode = React.forwardRef( @@ -103,5 +103,5 @@ export const DarkMode = React.forwardRef( {...props} /> ); - }, + } ); diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 33fc2e0..f7defd2 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -2,11 +2,11 @@ import { Toaster as ChakraToaster, + createToaster, Portal, Spinner, Stack, Toast, - createToaster, } from "@chakra-ui/react"; export const toaster = createToaster({ diff --git a/src/components/ui/toggle-tip.tsx b/src/components/ui/toggle-tip.tsx index 6b41007..0ae19ee 100644 --- a/src/components/ui/toggle-tip.tsx +++ b/src/components/ui/toggle-tip.tsx @@ -1,6 +1,6 @@ -import { Popover as ChakraPopover, IconButton, Portal } from "@chakra-ui/react"; import * as React from "react"; import { HiOutlineInformationCircle } from "react-icons/hi"; +import { Popover as ChakraPopover, IconButton, Portal } from "@chakra-ui/react"; export interface ToggleTipProps extends ChakraPopover.RootProps { showArrow?: boolean; @@ -47,7 +47,7 @@ export const ToggleTip = React.forwardRef( ); - }, + } ); export const InfoTip = React.forwardRef< diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 66fb414..7425d1c 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,5 +1,5 @@ -import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"; import * as React from "react"; +import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"; export interface TooltipProps extends ChakraTooltip.RootProps { showArrow?: boolean; @@ -42,5 +42,5 @@ export const Tooltip = React.forwardRef( ); - }, + } ); diff --git a/src/components/value.tsx b/src/components/value.tsx index 0cdf0f2..89bbe44 100644 --- a/src/components/value.tsx +++ b/src/components/value.tsx @@ -1,84 +1,135 @@ -import { Button, ButtonGroup, Stack } from "@chakra-ui/react"; -import { type ReactNode } from "react"; -import { LuExternalLink } from "react-icons/lu"; +import { useState } from "react"; +import { + LuArrowLeft, + LuArrowRight, + LuExternalLink, + LuFile, + LuFileQuestion, + LuFiles, + LuFolder, + LuFolderPlus, +} from "react-icons/lu"; import { MarkdownHooks } from "react-markdown"; -import type { StacAsset } from "stac-ts"; -import type { StacValue } from "../types/stac"; -import Section from "./section"; -import Thumbnail from "./thumbnail"; +import { + Button, + ButtonGroup, + Heading, + HStack, + Icon, + Image, + Span, + Stack, +} from "@chakra-ui/react"; +import type { StacAsset, StacLink } from "stac-ts"; import { Prose } from "./ui/prose"; +import type { StacValue } from "../types/stac"; export default function Value({ value, - children, + thumbnailAsset, + href, + setHref, + nextLink, + prevLink, }: { value: StacValue; - children?: ReactNode; + thumbnailAsset: StacAsset | undefined; + href: string; + setHref: (href: string | undefined) => void; + nextLink: StacLink | undefined; + prevLink: StacLink | undefined; }) { - const thumbnailAsset = - value.assets && - typeof value.assets === "object" && - "thumbnail" in value.assets - ? (value.assets.thumbnail as StacAsset) - : undefined; - const selfHref = value.links?.find((link) => link.rel == "self")?.href; + const [thumbnailError, setThumbnailError] = useState(false); + const selfHref = value.links?.find((link) => link.rel === "self")?.href; return ( -
- - {thumbnailAsset && } + + + + {getValueIcon(value)} + {(value.title as string) || + value.id || + href.split("/").at(-1)?.split("?").at(0)} + + - {!!value.description && ( - - {value.description as string} - - )} + {thumbnailAsset && !thumbnailError && ( + setThumbnailError(true)} + /> + )} - {children} + {!!value.description && ( + + {value.description as string} + + )} - {selfHref && ( - - )} - -
- ); -} + {selfHref && ( + + + + {value.type === "Feature" && ( + + )} + + )} -function SelfHrefButtons({ href, isItem }: { href: string; isItem: boolean }) { - return ( - - - - {isItem && ( - + {(prevLink || nextLink) && ( + + {prevLink && ( + + )} + + {nextLink && ( + + )} + )} - +
); } + +function getValueIcon(value: StacValue) { + switch (value.type) { + case "Catalog": + return ; + case "Collection": + return ; + case "Feature": + return ; + case "FeatureCollection": + return ; + default: + return ; + } +} diff --git a/src/examples.tsx b/src/constants.ts similarity index 53% rename from src/examples.tsx rename to src/constants.ts index d1ab8e1..d9b4d2c 100644 --- a/src/examples.tsx +++ b/src/constants.ts @@ -1,8 +1,4 @@ -import { Badge, Menu, Portal, Span } from "@chakra-ui/react"; -import { type ReactNode } from "react"; -import type { SetHref } from "./types/app"; - -const EXAMPLES = [ +export const EXAMPLES = [ { title: "eoAPI DevSeed", badge: "API", href: "https://stac.eoapi.dev/" }, { title: "Microsoft Planetary Computer", @@ -35,30 +31,3 @@ const EXAMPLES = [ href: "https://raw.githubusercontent.com/radiantearth/stac-spec/refs/heads/master/examples/simple-item.json", }, ]; - -export function Examples({ - setHref, - children, -}: { - setHref: SetHref; - children: ReactNode; -}) { - return ( - setHref(details.value)}> - {children} - - - - {EXAMPLES.map(({ title, badge, href }, index) => ( - - {title} - - {badge} - - ))} - - - - - ); -} diff --git a/src/context.ts b/src/context.ts deleted file mode 100644 index 908289e..0000000 --- a/src/context.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Table } from "apache-arrow"; -import { createContext } from "react"; -import type { StacCollection, StacItem } from "stac-ts"; -import type { StacGeoparquetMetadata, StacValue } from "./types/stac"; -import type { TemporalFilter } from "./types/datetime"; - -export const StacMapContext = createContext(null); - -/// To keep things simple, this state should only hold things the need to be -/// shared with the map. -interface StacMapContextType { - /// The root STAC value. - value: StacValue | undefined; - - /// Collections either loaded from the collections endpoint or linked from the value. - collections: StacCollection[] | undefined; - - /// STAC items, either linked or from a search. - items: StacItem[] | undefined; - - /// Set the items. - setItems: (items: StacItem[] | undefined) => void; - - /// The stac-geoparquet table that's currently loaded. - stacGeoparquetTable: Table | undefined | null; - - /// The stac-geoparquet metadata. - stacGeoparquetMetadata: StacGeoparquetMetadata | undefined; - - /// Set the id of a stac-geoparquet item that should be fetched from the - /// parquet table and loaded into the picked item. - setStacGeoparquetItemId: (id: string | undefined) => void; - - /// A picked item. - /// - /// "picking" usually involves clicking on the map. - picked: StacItem | undefined; - - /// Set the picked item. - setPicked: (value: StacItem | undefined) => void; - - /// The temporal filter for items. - temporalFilter: TemporalFilter | undefined; - - /// Sets the temporal filter. - setTemporalFilter: (temporalFilter: TemporalFilter | undefined) => void; - - /// Filtered items. - filteredItems: StacItem[] | undefined; -} diff --git a/src/hooks/stac-geoparquet.ts b/src/hooks/stac-geoparquet.ts deleted file mode 100644 index d5ea891..0000000 --- a/src/hooks/stac-geoparquet.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; -import { io } from "@geoarrow/geoarrow-js"; -import { useQuery } from "@tanstack/react-query"; -import { - Binary, - Data, - makeData, - makeVector, - Table, - vectorFromArray, -} from "apache-arrow"; -import { useDuckDb } from "duckdb-wasm-kit"; -import { useEffect, useState } from "react"; -import * as stacWasm from "stac-wasm"; -import type { StacGeoparquetMetadata } from "../types/stac"; - -export default function useStacGeoparquet( - path: string | undefined, - temporalFilter: { start: Date; end: Date } | undefined, -) { - const [id, setId] = useState(); - const { db } = useDuckDb(); - const [connection, setConnection] = useState(); - const { data: table } = useQuery({ - queryKey: ["stac-geoparquet-table", path, temporalFilter], - queryFn: async () => { - if (path && connection) { - return await getTable(path, connection, temporalFilter); - } else { - return null; - } - }, - placeholderData: (previousData) => previousData, - enabled: !!connection, - }); - const { data: metadata } = useQuery({ - queryKey: ["stac-geoparquet-metadata", path], - queryFn: async () => { - if (path && connection) { - return await getMetadata(path, connection); - } - }, - enabled: !!(connection && path), - }); - const { data: item } = useQuery({ - queryKey: ["stac-geoparquet-item", path, id], - queryFn: async () => { - if (path && connection && id) { - return await getItem(path, connection, id); - } - }, - enabled: !!(connection && path && id), - }); - - useEffect(() => { - (async () => { - if (db) { - const connection = await db.connect(); - connection.query("LOAD spatial;"); - setConnection(connection); - } - })(); - }, [db]); - - return { table, metadata, item, setId }; -} - -async function getTable( - path: string, - connection: AsyncDuckDBConnection, - temporalFilter: { start: Date; end: Date } | undefined, -) { - let query = `SELECT ST_AsWKB(geometry) as geometry, id FROM read_parquet('${path}')`; - if (temporalFilter) { - const describeResult = await connection.query( - `DESCRIBE SELECT * FROM read_parquet('${path}')`, - ); - const columnNames = describeResult - .toArray() - .map((row) => row.toJSON().column_name); - const startDatetimeColumnName = columnNames.includes("start_datetime") - ? "start_datetime" - : "datetime"; - const endDatetimeColumnName = columnNames.includes("end_datetime") - ? "start_datetime" - : "datetime"; - - query += ` WHERE ${startDatetimeColumnName} >= DATETIME '${temporalFilter.start.toISOString()}' AND ${endDatetimeColumnName} <= DATETIME '${temporalFilter.end.toISOString()}'`; - } - - const result = await connection.query(query); - const geometry: Uint8Array[] = result.getChildAt(0)?.toArray(); - const wkb = new Uint8Array(geometry?.flatMap((array) => [...array])); - const valueOffsets = new Int32Array(geometry.length + 1); - for (let i = 0, len = geometry.length; i < len; i++) { - const current = valueOffsets[i]; - valueOffsets[i + 1] = current + geometry[i].length; - } - const data: Data = makeData({ - type: new Binary(), - data: wkb, - valueOffsets, - }); - const polygons = io.parseWkb(data, io.WKBType.Polygon, 2); - const table = new Table({ - // @ts-expect-error: 2769 - geometry: makeVector(polygons), - id: vectorFromArray(result.getChild("id")?.toArray()), - }); - table.schema.fields[0].metadata.set( - "ARROW:extension:name", - "geoarrow.polygon", - ); - return table; -} - -async function getMetadata( - path: string, - connection: AsyncDuckDBConnection, -): Promise { - const describeResult = await connection.query( - `DESCRIBE SELECT * FROM read_parquet('${path}')`, - ); - const describe = describeResult.toArray().map((row) => row.toJSON()); - const columnNames = describe.map((row) => row.column_name); - const startDatetimeColumnName = columnNames.includes("start_datetime") - ? "start_datetime" - : "datetime"; - const endDatetimeColumnName = columnNames.includes("end_datetime") - ? "start_datetime" - : "datetime"; - - const summaryResult = await connection.query( - `SELECT COUNT(*) as count, MIN(bbox.xmin) as xmin, MIN(bbox.ymin) as ymin, MAX(bbox.xmax) as xmax, MAX(bbox.ymax) as ymax, MIN(${startDatetimeColumnName}) as start_datetime, MAX(${endDatetimeColumnName}) as end_datetime FROM read_parquet('${path}')`, - ); - const summaryRow = summaryResult.toArray().map((row) => row.toJSON())[0]; - - const kvMetadataResult = await connection.query( - `SELECT key, value FROM parquet_kv_metadata('${path}')`, - ); - const decoder = new TextDecoder("utf-8"); - const kvMetadata = kvMetadataResult.toArray().map((row) => { - const jsonRow = row.toJSON(); - const key = decoder.decode(jsonRow.key); - let value; - try { - value = JSON.parse(decoder.decode(jsonRow.value)); - } catch { - // pass - } - return { - key, - value, - }; - }); - - return { - count: summaryRow.count, - bbox: [summaryRow.xmin, summaryRow.ymin, summaryRow.xmax, summaryRow.ymax], - keyValue: kvMetadata, - startDatetime: summaryRow.start_datetime - ? new Date(summaryRow.start_datetime) - : null, - endDatetime: summaryRow.end_datetime - ? new Date(summaryRow.end_datetime) - : null, - describe, - }; -} - -async function getItem( - path: string, - connection: AsyncDuckDBConnection, - id: string, -) { - const result = await connection.query( - `SELECT * REPLACE ST_AsGeoJSON(geometry) as geometry FROM read_parquet('${path}') WHERE id = '${id}'`, - ); - const item = stacWasm.arrowToStacJson(result)[0]; - item.geometry = JSON.parse(item.geometry); - return item; -} diff --git a/src/hooks/stac-map.ts b/src/hooks/stac-map.ts deleted file mode 100644 index c5bef33..0000000 --- a/src/hooks/stac-map.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useContext } from "react"; -import { StacMapContext } from "../context"; - -export default function useStacMap() { - const context = useContext(StacMapContext); - if (context) { - return context; - } else { - throw new Error("useStacMap must be used from within a StacMapProvider"); - } -} diff --git a/src/hooks/stac-search.ts b/src/hooks/stac-search.ts index 7218c5c..5bdb2fd 100644 --- a/src/hooks/stac-search.ts +++ b/src/hooks/stac-search.ts @@ -1,7 +1,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { StacLink } from "stac-ts"; -import { fetchStac } from "../http"; import type { StacItemCollection, StacSearch } from "../types/stac"; +import { fetchStac } from "../utils/stac"; export default function useStacSearch(search: StacSearch, link: StacLink) { return useInfiniteQuery({ @@ -14,11 +14,11 @@ export default function useStacSearch(search: StacSearch, link: StacLink) { } async function fetchSearch({ pageParam }: { pageParam: StacLink }) { - return await fetchStac( + return (await fetchStac( pageParam.href, pageParam.method as "GET" | "POST" | undefined, - (pageParam.body as StacSearch) && JSON.stringify(pageParam.body), - ); + (pageParam.body as StacSearch) && JSON.stringify(pageParam.body) + )) as StacItemCollection; } function updateLink(link: StacLink, search: StacSearch) { diff --git a/src/hooks/stac-value.ts b/src/hooks/stac-value.ts index da60995..97d4e11 100644 --- a/src/hooks/stac-value.ts +++ b/src/hooks/stac-value.ts @@ -1,203 +1,175 @@ +import { useEffect, useMemo, useState } from "react"; import type { UseFileUploadReturn } from "@chakra-ui/react"; +import { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; import { useInfiniteQuery, useQueries, useQuery } from "@tanstack/react-query"; -import { AsyncDuckDB, isParquetFile, useDuckDb } from "duckdb-wasm-kit"; -import { useEffect } from "react"; -import type { StacCatalog, StacCollection, StacItem, StacLink } from "stac-ts"; -import { fetchStac, fetchStacLink } from "../http"; -import type { TemporalFilter } from "../types/datetime"; -import type { - StacCollections, - StacItemCollection, - StacValue, -} from "../types/stac"; +import { useDuckDb } from "duckdb-wasm-kit"; +import type { DatetimeBounds, StacCollections, StacValue } from "../types/stac"; +import { getStacJsonValue } from "../utils/stac"; +import { + getStacGeoparquet, + getStacGeoparquetItem, + getStacGeoparquetTable, +} from "../utils/stac-geoparquet"; -export function useStacValue({ +export default function useStacValue({ href, fileUpload, + datetimeBounds, + stacGeoparquetItemId, }: { href: string | undefined; - fileUpload?: UseFileUploadReturn; - temporalFilter?: TemporalFilter; -}): { - value?: StacValue; - parquetPath?: string; - collections: StacCollection[] | undefined; - items: StacItem[]; -} { + fileUpload: UseFileUploadReturn; + datetimeBounds: DatetimeBounds | undefined; + stacGeoparquetItemId: string | undefined; +}) { const { db } = useDuckDb(); - const { data } = useStacValueQuery({ href, fileUpload, db }); - const { values: items } = useStacValues( - data?.value.links?.filter((link) => link.rel == "item"), - ); - const { collections } = useStacCollections(data?.value); + const [connection, setConnection] = useState(); - return { - value: data?.value, - parquetPath: data?.parquetPath, - collections, - items: items.filter((item) => item.type == "Feature"), - }; -} + useEffect(() => { + if (db && href?.endsWith(".parquet")) { + (async () => { + const connection = await db.connect(); + await connection.query("LOAD spatial;"); + try { + new URL(href); + } catch { + const file = fileUpload.acceptedFiles[0]; + db.registerFileBuffer(href, new Uint8Array(await file.arrayBuffer())); + } + setConnection(connection); + })(); + } + }, [db, href, fileUpload]); -export function useStacLinkContainer( - value: StacValue | undefined, - rel: string, -) { - const result = useStacValueQuery({ - href: value?.links?.find((link) => link.rel == rel)?.href, - }); - if ( - result.data?.value.type == "Catalog" || - result.data?.value.type == "Collection" - ) { - return result.data?.value; - } else { - return undefined; - } -} + const enableStacGeoparquet = + (connection && href && href.endsWith(".parquet")) || false; -export function useChildren( - value: StacValue | undefined, - includeCollections: boolean, -) { - return useStacValues( - value?.links?.filter((link) => link.rel == "child"), - ).values.filter( - (value) => - value.type == "Catalog" || - (includeCollections && value.type == "Collection"), - ) as (StacCatalog | StacCollection)[]; -} + const jsonResult = useQuery({ + queryKey: ["stac-value", href], + queryFn: () => getStacJsonValue(href || "", fileUpload), + enabled: (href && !href.endsWith(".parquet")) || false, + }); + const stacGeoparquetResult = useQuery({ + queryKey: ["stac-geoparquet", href], + queryFn: () => + (href && connection && getStacGeoparquet(href, connection)) || null, + enabled: enableStacGeoparquet, + }); + const stacGeoparquetTableResult = useQuery({ + queryKey: ["stac-geoparquet", href, datetimeBounds], + queryFn: () => + (href && + connection && + getStacGeoparquetTable(href, connection, datetimeBounds)) || + null, + placeholderData: (previousData) => previousData, + enabled: enableStacGeoparquet, + }); + const stacGeoparquetItem = useQuery({ + queryKey: ["stac-geoparquet-item", href, stacGeoparquetItemId], + queryFn: () => + href && + connection && + stacGeoparquetItemId && + getStacGeoparquetItem(href, connection, stacGeoparquetItemId), + enabled: enableStacGeoparquet && !!stacGeoparquetItemId, + }); + const value = jsonResult.data || stacGeoparquetResult.data || undefined; + const table = stacGeoparquetTableResult.data || undefined; + const error = + jsonResult.error || + stacGeoparquetResult.error || + stacGeoparquetTableResult.error || + undefined; -function useStacValueQuery({ - href, - fileUpload, - db, -}: { - href: string | undefined; - fileUpload?: UseFileUploadReturn; - db?: AsyncDuckDB; -}) { - return useQuery<{ - value: StacValue; - parquetPath: string | undefined; - } | null>({ - queryKey: ["stac-value", href, fileUpload?.acceptedFiles], - queryFn: async () => { - if (href) { - return await getStacValue(href, fileUpload, db); + const collectionsLink = value?.links?.find((link) => link.rel == "data"); + const collectionsResult = useInfiniteQuery({ + queryKey: ["stac-collections", collectionsLink?.href], + queryFn: async ({ pageParam }) => { + if (pageParam) { + return await fetch(pageParam).then((response) => { + if (response.ok) return response.json(); + else + throw new Error( + `Error while fetching collections from ${pageParam}` + ); + }); } else { return null; } }, - enabled: !!href, - }); -} - -function useStacValues(links: StacLink[] | undefined) { - const results = useQueries({ - queries: - links?.map((link) => { - return { - queryKey: ["link", link], - queryFn: () => fetchStacLink(link), - }; - }) || [], + initialPageParam: collectionsLink?.href, + getNextPageParam: (lastPage: StacCollections | null) => + lastPage?.links?.find((link) => link.rel == "next")?.href, + enabled: !!collectionsLink, }); - return { - values: results - .map((value) => value.data) - .filter((value) => !!value) as StacValue[], - }; -} - -function useStacCollections(value: StacValue | undefined) { - const href = value?.links?.find((link) => link.rel == "data")?.href; - const { data, isFetching, hasNextPage, fetchNextPage } = - useInfiniteQuery({ - queryKey: ["collections", href], - enabled: !!href, - queryFn: async ({ pageParam }) => { - if (pageParam) { - // @ts-expect-error Not worth templating stuff - return await fetchStac(pageParam); - } else { - return null; - } - }, - initialPageParam: href, - getNextPageParam: (lastPage: StacCollections | null) => - lastPage?.links?.find((link) => link.rel == "next")?.href, - }); - + // TODO add a ceiling on the number of collections to fetch + // https://github.com/developmentseed/stac-map/issues/101 useEffect(() => { - if (!isFetching && hasNextPage) { - fetchNextPage(); + if (!collectionsResult.isFetching && collectionsResult.hasNextPage) { + collectionsResult.fetchNextPage(); } - }, [isFetching, hasNextPage, fetchNextPage]); - - return { - collections: data?.pages.flatMap((page) => page?.collections || []), - }; -} + }, [collectionsResult]); -async function getStacValue( - href: string, - fileUpload: UseFileUploadReturn | undefined, - db: AsyncDuckDB | undefined, -) { - if (isUrl(href)) { - // TODO allow this to be forced - if (href.endsWith(".parquet")) { + const linkResults = useQueries({ + queries: + value?.links + ?.filter((link) => link.rel === "child" || link.rel === "item") + .map((link) => { + return { + queryKey: ["stac-value", link.href], + queryFn: () => getStacJsonValue(link.href), + enabled: !collectionsLink, + }; + }) || [], + combine: (results) => { return { - value: getStacGeoparquetItemCollection(href), - parquetPath: href, + data: results.map((result) => result.data), }; - } else { + }, + }); + + const { collections, catalogs, items } = useMemo(() => { + if (collectionsLink) { return { - value: await fetchStac(href), - parquetPath: undefined, + collections: collectionsResult.data?.pages.flatMap( + (page) => page?.collections || [] + ), + catalogs: undefined, + items: undefined, }; - } - } else if (fileUpload?.acceptedFiles.length == 1) { - const file = fileUpload.acceptedFiles[0]; - if (await isParquetFile(file)) { - if (db) { - db.registerFileBuffer(href, new Uint8Array(await file.arrayBuffer())); - return { - value: getStacGeoparquetItemCollection(href), - parquetPath: href, - }; - } else { - return null; - } } else { + const collections = []; + const catalogs = []; + const items = []; + for (const value of linkResults.data) { + switch (value?.type) { + case "Catalog": + catalogs.push(value); + break; + case "Collection": + collections.push(value); + break; + case "Feature": + items.push(value); + break; + } + } return { - value: JSON.parse(await file.text()), - parquetPath: undefined, + collections: collections.length > 0 ? collections : undefined, + catalogs: catalogs.length > 0 ? catalogs : undefined, + items: items.length > 0 ? items : undefined, }; } - } else { - throw new Error( - `Href '${href}' is not a URL, but there is not one (and only one) uploaded, accepted file`, - ); - } -} + }, [collectionsLink, collectionsResult.data, linkResults.data]); -function getStacGeoparquetItemCollection(href: string): StacItemCollection { return { - type: "FeatureCollection", - features: [], - title: href.split("/").pop(), - description: "A stac-geoparquet file", + value, + error, + collections, + catalogs, + items, + table, + stacGeoparquetItem: stacGeoparquetItem.data, }; } - -function isUrl(href: string) { - try { - new URL(href); - return true; - } catch { - return false; - } -} diff --git a/src/main.tsx b/src/main.tsx index 8b24f69..fe66d4e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,31 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { ErrorBoundary } from "react-error-boundary"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./app.tsx"; +import { ErrorComponent } from "./components/error-component.tsx"; import { Provider } from "./components/ui/provider.tsx"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + retry: 1, + }, + }, +}); + createRoot(document.getElementById("root")!).render( - + + history.pushState(null, "", location.pathname)} + > + + + - , + ); diff --git a/src/provider.tsx b/src/provider.tsx deleted file mode 100644 index e45bb89..0000000 --- a/src/provider.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { UseFileUploadReturn } from "@chakra-ui/react"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; -import type { StacItem } from "stac-ts"; -import { StacMapContext } from "./context"; -import useStacGeoparquet from "./hooks/stac-geoparquet"; -import { useStacValue } from "./hooks/stac-value"; -import type { TemporalFilter } from "./types/datetime"; -import { getItemDatetimes } from "./stac"; - -export function StacMapProvider({ - href, - fileUpload, - children, -}: { - href: string | undefined; - fileUpload: UseFileUploadReturn; - children: ReactNode; -}) { - const [unlinkedItems, setUnlinkedItems] = useState(); - const [picked, setPicked] = useState(); - const [temporalFilter, setTemporalFilter] = useState(); - - // TODO we should probably consolidate useStacValue and useStacGeoparquet into - // a single hook, since they're coupled. - const { - value, - parquetPath, - collections, - items: linkedItems, - } = useStacValue({ - href, - fileUpload, - }); - const { - table: stacGeoparquetTable, - metadata: stacGeoparquetMetadata, - setId: setStacGeoparquetItemId, - item: stacGeoparquetItem, - } = useStacGeoparquet(parquetPath, temporalFilter); - - useEffect(() => { - if (value?.title || value?.id) { - document.title = "stac-map | " + (value.title || value.id); - } else { - document.title = "stac-map"; - } - - setUnlinkedItems(undefined); - setPicked(undefined); - }, [value]); - - useEffect(() => { - setPicked(stacGeoparquetItem); - }, [stacGeoparquetItem]); - - const items = useMemo(() => { - return unlinkedItems || linkedItems; - }, [unlinkedItems, linkedItems]); - - const filteredItems = useMemo(() => { - if (items && temporalFilter) { - return items.filter((item) => - isItemWithinTemporalFilter(item, temporalFilter), - ); - } else { - return undefined; - } - }, [items, temporalFilter]); - - return ( - - {children} - - ); -} - -function isItemWithinTemporalFilter( - item: StacItem, - temporalFilter: TemporalFilter, -) { - const { start, end } = getItemDatetimes(item); - return ( - start && end && start >= temporalFilter.start && end <= temporalFilter.end - ); -} diff --git a/src/stac.ts b/src/stac.ts deleted file mode 100644 index da5572a..0000000 --- a/src/stac.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { StacItem } from "stac-ts"; - -export function getItemDatetimes(item: StacItem) { - const start = item.properties?.start_datetime - ? new Date(item.properties.start_datetime) - : item.properties?.datetime - ? new Date(item.properties.datetime) - : null; - const end = item.properties?.end_datetime - ? new Date(item.properties.end_datetime) - : item.properties?.datetime - ? new Date(item.properties.datetime) - : null; - return { start, end }; -} diff --git a/src/types/app.d.ts b/src/types/app.d.ts deleted file mode 100644 index b29fa6f..0000000 --- a/src/types/app.d.ts +++ /dev/null @@ -1 +0,0 @@ -export type SetHref = (href: string | undefined) => void; diff --git a/src/types/datetime.d.ts b/src/types/datetime.d.ts deleted file mode 100644 index 8fb85a9..0000000 --- a/src/types/datetime.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface TemporalFilter { - start: Date; - end: Date; -} diff --git a/src/types/map.d.ts b/src/types/map.d.ts new file mode 100644 index 0000000..9a8d953 --- /dev/null +++ b/src/types/map.d.ts @@ -0,0 +1,2 @@ +export type Color = [number, number, number, number]; +export type BBox2D = [number, number, number, number]; diff --git a/src/types/stac.d.ts b/src/types/stac.d.ts index 74cf7c7..5666784 100644 --- a/src/types/stac.d.ts +++ b/src/types/stac.d.ts @@ -1,13 +1,4 @@ -import type { BBox } from "geojson"; -import type { StacCatalog, StacCollection, StacItem, StacLink } from "stac-ts"; - -export type StacValue = - | StacCatalog - | StacCollection - | StacItem - | StacItemCollection; - -export type StacContainer = StacCatalog | StacCollection; +import type { StacAsset, StacCatalog, StacCollection, StacItem } from "stac-ts"; export interface StacItemCollection { type: "FeatureCollection"; @@ -20,42 +11,28 @@ export interface StacItemCollection { [k: string]: unknown; } +export type StacValue = + | StacCatalog + | StacCollection + | StacItem + | StacItemCollection; + export interface StacCollections { collections: StacCollection[]; links?: StacLink[]; } +export interface NaturalLanguageCollectionSearchResult { + collection_id: string; + explanation: string; +} + +export type StacAssets = { [k: string]: StacAsset }; + export interface StacSearch { collections?: string[]; datetime?: string; bbox?: number[]; - limit?: number; -} - -export interface StacSearchRequest { - search: StacSearch; - link: StacLink; -} - -export interface StacGeoparquetMetadata { - count: number; - bbox: BBox; - keyValue: KeyValueMetadata[]; - startDatetime: Date | null; - endDatetime: Date | null; - describe: { - column_name: string; - column_type: string; - }[]; -} - -export interface KeyValueMetadata { - key: string; - // eslint-disable-next-line - value: any; } -export interface NaturalLanguageCollectionSearchResult { - collection_id: string; - explanation: string; -} +export type DatetimeBounds = { start: Date; end: Date }; diff --git a/src/utils/stac-geoparquet.ts b/src/utils/stac-geoparquet.ts new file mode 100644 index 0000000..607ec7b --- /dev/null +++ b/src/utils/stac-geoparquet.ts @@ -0,0 +1,127 @@ +import { io } from "@geoarrow/geoarrow-js"; +import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm"; +import { + Binary, + Data, + makeData, + makeVector, + Table, + vectorFromArray, +} from "apache-arrow"; +import * as stacWasm from "stac-wasm"; +import type { DatetimeBounds, StacItemCollection } from "../types/stac"; + +export async function getStacGeoparquet( + href: string, + connection: AsyncDuckDBConnection +) { + const { startDatetimeColumnName, endDatetimeColumnName } = + await getStacGeoparquetDatetimeColumns(href, connection); + + const summaryResult = await connection.query( + `SELECT COUNT(*) as count, MIN(bbox.xmin) as xmin, MIN(bbox.ymin) as ymin, MAX(bbox.xmax) as xmax, MAX(bbox.ymax) as ymax, MIN(${startDatetimeColumnName}) as start_datetime, MAX(${endDatetimeColumnName}) as end_datetime FROM read_parquet('${href}')` + ); + const summaryRow = summaryResult.toArray().map((row) => row.toJSON())[0]; + + const kvMetadataResult = await connection.query( + `SELECT key, value FROM parquet_kv_metadata('${href}')` + ); + const decoder = new TextDecoder("utf-8"); + const kvMetadata = Object.fromEntries( + kvMetadataResult.toArray().map((row) => { + const jsonRow = row.toJSON(); + const key = decoder.decode(jsonRow.key); + let value; + try { + value = JSON.parse(decoder.decode(jsonRow.value)); + } catch { + // pass + } + return [key, value]; + }) + ); + + return { + type: "FeatureCollection", + bbox: [summaryRow.xmin, summaryRow.ymin, summaryRow.xmax, summaryRow.ymax], + features: [], + title: href.split("/").at(-1), + description: `A stac-geoparquet file with ${summaryRow.count} items`, + start_datetime: summaryRow.start_datetime + ? new Date(summaryRow.start_datetime).toLocaleString() + : null, + end_datetime: summaryRow.end_datetime + ? new Date(summaryRow.end_datetime).toLocaleString() + : null, + geoparquet_metadata: kvMetadata, + } as StacItemCollection; +} + +export async function getStacGeoparquetTable( + href: string, + connection: AsyncDuckDBConnection, + datetimeBounds: DatetimeBounds | undefined +) { + const { startDatetimeColumnName, endDatetimeColumnName } = + await getStacGeoparquetDatetimeColumns(href, connection); + + let query = `SELECT ST_AsWKB(geometry) as geometry, id FROM read_parquet('${href}')`; + if (datetimeBounds) { + query += ` WHERE ${startDatetimeColumnName} >= DATETIME '${datetimeBounds.start.toISOString()}' AND ${endDatetimeColumnName} <= DATETIME '${datetimeBounds.end.toISOString()}'`; + } + const result = await connection.query(query); + const geometry: Uint8Array[] = result.getChildAt(0)?.toArray(); + const wkb = new Uint8Array(geometry?.flatMap((array) => [...array])); + const valueOffsets = new Int32Array(geometry.length + 1); + for (let i = 0, len = geometry.length; i < len; i++) { + const current = valueOffsets[i]; + valueOffsets[i + 1] = current + geometry[i].length; + } + const data: Data = makeData({ + type: new Binary(), + data: wkb, + valueOffsets, + }); + const polygons = io.parseWkb(data, io.WKBType.Polygon, 2); + const table = new Table({ + // @ts-expect-error: 2769 + geometry: makeVector(polygons), + id: vectorFromArray(result.getChild("id")?.toArray()), + }); + table.schema.fields[0].metadata.set( + "ARROW:extension:name", + "geoarrow.polygon" + ); + return table; +} + +export async function getStacGeoparquetItem( + href: string, + connection: AsyncDuckDBConnection, + id: string +) { + const result = await connection.query( + `SELECT * REPLACE ST_AsGeoJSON(geometry) as geometry FROM read_parquet('${href}') WHERE id = '${id}'` + ); + const item = stacWasm.arrowToStacJson(result)[0]; + item.geometry = JSON.parse(item.geometry); + return item; +} + +async function getStacGeoparquetDatetimeColumns( + href: string, + connection: AsyncDuckDBConnection +) { + const describeResult = await connection.query( + `DESCRIBE SELECT * FROM read_parquet('${href}')` + ); + const describe = describeResult.toArray().map((row) => row.toJSON()); + const columnNames = describe.map((row) => row.column_name); + const startDatetimeColumnName = columnNames.includes("start_datetime") + ? "start_datetime" + : "datetime"; + const endDatetimeColumnName = columnNames.includes("end_datetime") + ? "start_datetime" + : "datetime"; + return { startDatetimeColumnName, endDatetimeColumnName }; +} diff --git a/src/http.ts b/src/utils/stac.ts similarity index 55% rename from src/http.ts rename to src/utils/stac.ts index ab727af..7830555 100644 --- a/src/http.ts +++ b/src/utils/stac.ts @@ -1,11 +1,37 @@ -import type { StacLink } from "stac-ts"; -import type { StacValue } from "./types/stac"; +import type { UseFileUploadReturn } from "@chakra-ui/react"; +import type { StacItem } from "stac-ts"; +import type { StacValue } from "../types/stac"; + +export async function getStacJsonValue( + href: string, + fileUpload?: UseFileUploadReturn +): Promise { + let url; + try { + url = new URL(href); + } catch { + if (fileUpload) { + return getStacJsonValueFromUpload(fileUpload); + } else { + throw new Error( + `Cannot get STAC JSON value from href=${href} without a fileUpload` + ); + } + } + return await fetchStac(url); +} + +async function getStacJsonValueFromUpload(fileUpload: UseFileUploadReturn) { + // We assume there's one and only on file. + const file = fileUpload.acceptedFiles[0]; + return JSON.parse(await file.text()); +} export async function fetchStac( href: string | URL, method: "GET" | "POST" = "GET", - body?: string, -) { + body?: string +): Promise { return await fetch(href, { method, headers: { @@ -16,7 +42,7 @@ export async function fetchStac( if (response.ok) { return response .json() - .then((json) => makeStacHrefsAbsolute(json, href.toString())) + .then((json) => makeHrefsAbsolute(json, href.toString())) .then((json) => maybeAddTypeField(json)); } else { throw new Error(`${method} ${href}: ${response.statusText}`); @@ -24,24 +50,9 @@ export async function fetchStac( }); } -export async function fetchStacLink(link: StacLink, href?: string | undefined) { - return fetchStac( - new URL(link.href, href), - link.method as "GET" | "POST" | undefined, - // eslint-disable-next-line - (link.body as any) && JSON.stringify(link.body), - ); -} - -/** - * Attempt to convert links and asset URLS to absolute URLs while ensuring a self link exists. - * - * @param value Source stac item, collection, or catalog - * @param baseUrl base location of the STAC document - */ -export function makeStacHrefsAbsolute( +export function makeHrefsAbsolute( value: T, - baseUrl: string, + baseUrl: string ): T { const baseUrlObj = new URL(baseUrl); @@ -70,30 +81,6 @@ export function makeStacHrefsAbsolute( return value; } -/** - * Determine if the URL is absolute - * @returns true if absolute, false otherwise - */ -function isAbsolute(url: string) { - try { - new URL(url); - return true; - } catch { - return false; - } -} - -/** - * Attempt to convert a possibly relative URL to an absolute URL - * - * If the URL is already absolute, it is returned unchanged. - * - * **WARNING**: if the URL is http it will be returned as URL encoded - * - * @param href - * @param baseUrl - * @returns absolute URL - */ export function toAbsoluteUrl(href: string, baseUrl: URL): string { if (isAbsolute(href)) return href; @@ -101,12 +88,20 @@ export function toAbsoluteUrl(href: string, baseUrl: URL): string { if (targetUrl.protocol === "http:" || targetUrl.protocol === "https:") { return targetUrl.toString(); + } else if (targetUrl.protocol === "s3:") { + return decodeURI(targetUrl.toString()); + } else { + return targetUrl.toString(); } +} - // S3 links should not be encoded - if (targetUrl.protocol === "s3:") return decodeURI(targetUrl.toString()); - - return targetUrl.toString(); +function isAbsolute(url: string) { + try { + new URL(url); + return true; + } catch { + return false; + } } // eslint-disable-next-line @@ -124,3 +119,17 @@ function maybeAddTypeField(value: any) { } return value; } + +export function getItemDatetimes(item: StacItem) { + const start = item.properties?.start_datetime + ? new Date(item.properties.start_datetime) + : item.properties?.datetime + ? new Date(item.properties.datetime) + : null; + const end = item.properties?.end_datetime + ? new Date(item.properties.end_datetime) + : item.properties?.datetime + ? new Date(item.properties.datetime) + : null; + return { start, end }; +} diff --git a/tests/app.spec.tsx b/tests/app.spec.tsx index 560d0ac..dd357cc 100644 --- a/tests/app.spec.tsx +++ b/tests/app.spec.tsx @@ -1,23 +1,60 @@ -import { describe, test } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, test } from "vitest"; import { render } from "vitest-browser-react"; import App from "../src/app"; import { Provider } from "../src/components/ui/provider"; +import { EXAMPLES } from "../src/constants"; + +const queryClient = new QueryClient(); function renderApp() { return render( - - , + + + + ); } -describe("navigation", () => { - test("static catalog", async () => { +describe("app", () => { + test("has a map", async () => { + const app = renderApp(); + await expect + .element(app.getByRole("region", { name: "Map" })) + .toBeVisible(); + }); + + test("has a input text box", async () => { + const app = renderApp(); + await expect + .element( + app.getByRole("textbox", { + name: "Enter a url to STAC JSON or GeoParquet", + }) + ) + .toBeVisible(); + }); + + describe.for(EXAMPLES)("example $title", ({ title }) => { + test("updates title", async ({ expect }) => { + const app = renderApp(); + await app.getByRole("button", { name: "Examples" }).click(); + await app.getByRole("menuitem", { name: title }).click(); + expect(document.title !== "stac-map"); + }); + }); + + test("CSDA Planet", async () => { + // https://github.com/developmentseed/stac-map/issues/96 + window.history.pushState( + {}, + "", + "?href=https://csdap.earthdata.nasa.gov/stac/collections/planet" + ); const app = renderApp(); - await app.getByRole("button", { name: "Examples" }).click(); - await app.getByRole("menuitem", { name: "Maxar Open Data static" }).click(); - await app.getByText("Bay of Bengal Cyclone Mocha").click(); - await app.getByText("10300100E6747500", { exact: true }).click(); - // TODO test map clicking, oof + await expect + .element(app.getByRole("heading", { name: "Planet" })) + .toBeVisible(); }); }); diff --git a/tests/header.spec.tsx b/tests/header.spec.tsx deleted file mode 100644 index d277402..0000000 --- a/tests/header.spec.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { Provider } from "../src/components/ui/provider"; -import { render } from "vitest-browser-react"; -import App from "../src/app"; - -function renderApp() { - return render( - - - , - ); -} - -describe("initial state", () => { - test("renders example button", async () => { - const app = renderApp(); - await expect - .element(app.getByRole("button", { name: "examples" })) - .toBeVisible(); - }); -}); diff --git a/tests/panel.spec.tsx b/tests/panel.spec.tsx deleted file mode 100644 index 97fdac3..0000000 --- a/tests/panel.spec.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { render } from "vitest-browser-react"; -import App from "../src/app"; -import { Provider } from "../src/components/ui/provider"; - -function renderApp(path?: string) { - window.history.pushState({}, "", path || ""); - return render( - - - , - ); -} - -describe("loading", () => { - test("stac.eoapi.dev", async () => { - const app = renderApp("?href=https://stac.eoapi.dev/"); - await expect.element(app.getByText(/eoAPI-stac/i)).toBeVisible(); - await expect.element(app.getByText(/collections/i)).toBeVisible(); - - await app.getByRole("heading", { name: "Afghanistan Earthquake" }).click(); - expect(new URL(window.location.href).search).toBe( - "?href=https://stac.eoapi.dev/collections/MAXAR_afghanistan_earthquake22", - ); - }); - - test("CSDA Planet", async () => { - // https://github.com/developmentseed/stac-map/issues/96 - const app = renderApp( - "?href=https://csdap.earthdata.nasa.gov/stac/collections/planet", - ); - await expect - .element(app.getByRole("heading", { name: "Planet" })) - .toBeVisible(); - }); - - test("Item has TiTiler link", async () => { - const app = renderApp( - "?href=https://raw.githubusercontent.com/radiantearth/stac-spec/refs/heads/master/examples/simple-item.json", - ); - await expect - .element(app.getByRole("link", { name: "TiTiler" })) - .toBeVisible(); - }); -}); diff --git a/tests/http.spec.ts b/tests/stac.spec.ts similarity index 81% rename from tests/http.spec.ts rename to tests/stac.spec.ts index 63928f8..4903590 100644 --- a/tests/http.spec.ts +++ b/tests/stac.spec.ts @@ -1,46 +1,46 @@ -import { expect, test } from "vitest"; -import { makeStacHrefsAbsolute, toAbsoluteUrl } from "../src/http"; import { StacItem } from "stac-ts"; +import { expect, test } from "vitest"; +import { makeHrefsAbsolute, toAbsoluteUrl } from "../src/utils/stac"; test("should preserve UTF8 characters while making URLS absolute", async () => { expect(toAbsoluteUrl("🦄.tiff", new URL("s3://some-bucket"))).equals( - "s3://some-bucket/🦄.tiff", + "s3://some-bucket/🦄.tiff" ); expect( - toAbsoluteUrl("https://foo/bar/🦄.tiff", new URL("s3://some-bucket")), + toAbsoluteUrl("https://foo/bar/🦄.tiff", new URL("s3://some-bucket")) ).equals("https://foo/bar/🦄.tiff"); expect( - toAbsoluteUrl("../../../🦄.tiff", new URL("s3://some-bucket/🌈/path/a/b/")), + toAbsoluteUrl("../../../🦄.tiff", new URL("s3://some-bucket/🌈/path/a/b/")) ).equals("s3://some-bucket/🌈/🦄.tiff"); expect(toAbsoluteUrl("a+🦄.tiff", new URL("s3://some-bucket/🌈/"))).equals( - "s3://some-bucket/🌈/a+🦄.tiff", + "s3://some-bucket/🌈/a+🦄.tiff" ); expect( - toAbsoluteUrl("../../../🦄.tiff", new URL("https://some-url/🌈/path/a/b/")), + toAbsoluteUrl("../../../🦄.tiff", new URL("https://some-url/🌈/path/a/b/")) ).equals("https://some-url/%F0%9F%8C%88/%F0%9F%A6%84.tiff"); expect( toAbsoluteUrl( "foo/🦄.tiff?width=1024", - new URL("https://user@[2601:195:c381:3560::f42a]:1234/test"), - ), + new URL("https://user@[2601:195:c381:3560::f42a]:1234/test") + ) ).equals( - "https://user@[2601:195:c381:3560::f42a]:1234/foo/%F0%9F%A6%84.tiff?width=1024", + "https://user@[2601:195:c381:3560::f42a]:1234/foo/%F0%9F%A6%84.tiff?width=1024" ); }); test("should convert relative links to absolute", () => { expect( - makeStacHrefsAbsolute( + makeHrefsAbsolute( { links: [ { href: "a/b/c", rel: "child" }, { href: "/d/e/f", rel: "child" }, ], } as unknown as StacItem, - "https://example.com/root/item.json", - ).links, + "https://example.com/root/item.json" + ).links ).deep.equals([ { href: "https://example.com/root/a/b/c", rel: "child" }, { href: "https://example.com/d/e/f", rel: "child" }, @@ -50,15 +50,15 @@ test("should convert relative links to absolute", () => { test("should convert relative assets to absolute", () => { expect( - makeStacHrefsAbsolute( + makeHrefsAbsolute( { assets: { tiff: { href: "./foo.tiff" }, thumbnail: { href: "../thumbnails/foo.png" }, }, } as unknown as StacItem, - "https://example.com/root/item.json", - ).assets, + "https://example.com/root/item.json" + ).assets ).deep.equals({ tiff: { href: "https://example.com/root/foo.tiff" }, thumbnail: { href: "https://example.com/thumbnails/foo.png" }, diff --git a/yarn.lock b/yarn.lock index 5ee0c37..8236a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -104,7 +104,7 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.28.3": +"@babel/generator@^7.26.5", "@babel/generator@^7.28.3": version "7.28.3" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw== @@ -176,7 +176,7 @@ "@babel/template" "^7.27.2" "@babel/types" "^7.28.4" -"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4": +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.26.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== @@ -211,7 +211,7 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.4": +"@babel/traverse@^7.26.7", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b" integrity sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ== @@ -224,7 +224,7 @@ "@babel/types" "^7.28.4" debug "^4.3.1" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4": +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.26.7", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4": version "7.28.4" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== @@ -1962,6 +1962,18 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== +"@trivago/prettier-plugin-sort-imports@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-5.2.2.tgz#38983f0b83490a0a7d974a6f1e409fb4bf678d02" + integrity sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA== + dependencies: + "@babel/generator" "^7.26.5" + "@babel/parser" "^7.26.7" + "@babel/traverse" "^7.26.7" + "@babel/types" "^7.26.7" + javascript-natural-sort "^0.7.1" + lodash "^4.17.21" + "@tufjs/canonical-json@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" @@ -5437,6 +5449,11 @@ java-properties@^1.0.2: resolved "https://registry.yarnpkg.com/java-properties/-/java-properties-1.0.2.tgz#ccd1fa73907438a5b5c38982269d0e771fe78211" integrity sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ== +javascript-natural-sort@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" + integrity sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -5771,6 +5788,11 @@ lodash.uniqby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + long@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/long/-/long-3.2.0.tgz#d821b7138ca1cb581c172990ef14db200b5c474b"