diff --git a/.gitignore b/.gitignore index 7781638..2d7b7c0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? -tests/__screenshots__/ +tests/**/__screenshots__/ codebook.toml diff --git a/src/app.tsx b/src/app.tsx index cf4b09c..e94f9b6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,20 +2,17 @@ import { useEffect, useMemo, useState } from "react"; import { Box, Container, FileUpload, useFileUpload } from "@chakra-ui/react"; import type { StacCollection, StacItem } from "stac-ts"; import { Toaster } from "./components/ui/toaster"; +import useHrefParam from "./hooks/href-param"; import useStacChildren from "./hooks/stac-children"; +import useStacFilters from "./hooks/stac-filters"; import useStacValue from "./hooks/stac-value"; import Map from "./layers/map"; import Overlay from "./layers/overlay"; import type { BBox2D, Color } from "./types/map"; import type { DatetimeBounds, StacValue } from "./types/stac"; -import { - isCog, - isCollectionInBbox, - isCollectionInDatetimeBounds, - isItemInBbox, - isItemInDatetimeBounds, - isVisual, -} from "./utils/stac"; +import getDateTimes from "./utils/datetimes"; +import { getCogTileHref } from "./utils/stac"; +import getDocumentTitle from "./utils/title"; // TODO make this configurable by the user. const lineColor: Color = [207, 63, 2, 100]; @@ -23,8 +20,15 @@ const fillColor: Color = [207, 63, 2, 50]; export default function App() { // User state - const [href, setHref] = useState(getInitialHref()); - const fileUpload = useFileUpload({ maxFiles: 1 }); + const { href, setHref } = useHrefParam(); + const fileUpload = useFileUpload({ + maxFiles: 1, + onFileChange: (details) => { + if (details.acceptedFiles.length === 1) { + setHref(details.acceptedFiles[0].name); + } + }, + }); const [userCollections, setCollections] = useState(); const [userItems, setItems] = useState(); const [picked, setPicked] = useState(); @@ -54,77 +58,29 @@ export default function App() { }); const collections = collectionsLink ? userCollections : linkedCollections; 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); - }; - }, []); + const { filteredCollections, filteredItems } = useStacFilters({ + collections, + items, + filter, + bbox, + datetimeBounds, + }); - useEffect(() => { - if (href && new URLSearchParams(location.search).get("href") != href) { - history.pushState(null, "", "?href=" + href); - } else if (href === "") { - history.pushState(null, "", location.pathname); - } - }, [href]); + const datetimes = useMemo( + () => (value ? getDateTimes(value, items, collections) : null), + [value, items, collections] + ); + // Effects useEffect(() => { - // It should never be more than 1. - if (fileUpload.acceptedFiles.length == 1) { - setHref(fileUpload.acceptedFiles[0].name); - } - }, [fileUpload.acceptedFiles]); + document.title = getDocumentTitle(value); + }, [value]); useEffect(() => { setPicked(undefined); setItems(undefined); setDatetimeBounds(undefined); setCogTileHref(value && getCogTileHref(value)); - - if (value && (value.title || value.id)) { - document.title = "stac-map | " + (value.title || value.id); - } else { - document.title = "stac-map"; - } }, [value]); useEffect(() => { @@ -151,8 +107,7 @@ export default function App() { table={table} collections={collections} filteredCollections={filteredCollections} - items={items} - filteredItems={filteredItems} + items={filteredItems} fillColor={fillColor} lineColor={lineColor} setBbox={setBbox} @@ -182,45 +137,22 @@ export default function App() { error={error} catalogs={catalogs} setCollections={setCollections} - collections={collections} - filteredCollections={filteredCollections} + collections={filteredCollections} + totalNumOfCollections={collections?.length} filter={filter} setFilter={setFilter} bbox={bbox} setPicked={setPicked} picked={picked} - items={items} - filteredItems={filteredItems} + items={filteredItems} setItems={setItems} setDatetimeBounds={setDatetimeBounds} cogTileHref={cogTileHref} setCogTileHref={setCogTileHref} + datetimes={datetimes} > ); } - -function getInitialHref() { - const href = new URLSearchParams(location.search).get("href") || ""; - try { - new URL(href); - } catch { - return undefined; - } - return href; -} - -function getCogTileHref(value: StacValue) { - let cogTileHref = undefined; - if (value.assets) { - for (const asset of Object.values(value.assets)) { - if (isCog(asset) && isVisual(asset)) { - cogTileHref = asset.href as string; - break; - } - } - } - return cogTileHref; -} diff --git a/src/components/panel.tsx b/src/components/panel.tsx index 58b64a3..83441a6 100644 --- a/src/components/panel.tsx +++ b/src/components/panel.tsx @@ -21,7 +21,13 @@ export default function Panel({ error, fileUpload, ...props -}: PanelProps) { +}: { + totalNumOfCollections: number | undefined; + datetimes: { + start: Date; + end: Date; + } | null; +} & PanelProps) { if (error) return ( diff --git a/src/components/sections/collections.tsx b/src/components/sections/collections.tsx index 7192d80..20224e1 100644 --- a/src/components/sections/collections.tsx +++ b/src/components/sections/collections.tsx @@ -11,23 +11,21 @@ interface CollectionsProps { export default function CollectionsSection({ collections, - filteredCollections, - numberOfCollections, + collectionsNumberMatched, + totalNumOfCollections, setHref, }: { - filteredCollections: StacCollection[] | undefined; - numberOfCollections: number | undefined; + collectionsNumberMatched: number | undefined; + totalNumOfCollections: number | undefined; } & CollectionsProps) { - const parenthetical = filteredCollections - ? `${filteredCollections.length}/${numberOfCollections || collections.length}` - : collections.length; + const parenthetical = + collections.length !== collectionsNumberMatched + ? `${collections.length}/${collectionsNumberMatched || totalNumOfCollections}` + : collections.length; const title = `Collections (${parenthetical})`; return (
- +
); } diff --git a/src/components/sections/filter.tsx b/src/components/sections/filter.tsx index d22dee7..00e2aa8 100644 --- a/src/components/sections/filter.tsx +++ b/src/components/sections/filter.tsx @@ -4,7 +4,6 @@ import { Checkbox, DataList, Slider, Stack, Text } from "@chakra-ui/react"; import type { StacCollection, StacItem } from "stac-ts"; import type { BBox2D } from "../../types/map"; import type { DatetimeBounds, StacValue } from "../../types/stac"; -import { getItemDatetimes } from "../../utils/stac"; import { SpatialExtent } from "../extent"; import Section from "../section"; @@ -16,6 +15,10 @@ interface FilterProps { value: StacValue; items: StacItem[] | undefined; collections: StacCollection[] | undefined; + datetimes: { + start: Date; + end: Date; + } | null; } export default function FilterSection({ filter, ...props }: FilterProps) { @@ -35,50 +38,11 @@ function Filter({ setFilter, bbox, setDatetimeBounds, - value, - items, - collections, + datetimes, }: FilterProps) { const [filterStart, setFilterStart] = useState(); const [filterEnd, setFilterEnd] = 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]); - const sliderValue = useMemo(() => { if (!datetimes) return undefined; if (filterStart && filterEnd) { diff --git a/src/components/sections/items.tsx b/src/components/sections/items.tsx index fb02ef2..0c558ac 100644 --- a/src/components/sections/items.tsx +++ b/src/components/sections/items.tsx @@ -9,17 +9,18 @@ interface ItemsProps { } export default function ItemsSection({ - filteredItems, + totalNumOfItems, items, ...props -}: { filteredItems: StacItem[] | undefined } & ItemsProps) { - const parenthetical = filteredItems - ? `${filteredItems.length}/${items.length}` - : items.length; +}: { totalNumOfItems: number | undefined } & ItemsProps) { + const parenthetical = + items.length !== totalNumOfItems + ? `${items.length}/${totalNumOfItems}` + : items.length; const title = `Items (${parenthetical})`; return (
- +
); } diff --git a/src/components/value.tsx b/src/components/value.tsx index b592e92..22bf7ad 100644 --- a/src/components/value.tsx +++ b/src/components/value.tsx @@ -46,9 +46,7 @@ export interface SharedValueProps { catalogs: StacCatalog[] | undefined; setCollections: (collections: StacCollection[] | undefined) => void; collections: StacCollection[] | undefined; - filteredCollections: StacCollection[] | undefined; items: StacItem[] | undefined; - filteredItems: StacItem[] | undefined; setHref: (href: string | undefined) => void; filter: boolean; setFilter: (filter: boolean) => void; @@ -70,10 +68,8 @@ export function Value({ value, catalogs, collections, - filteredCollections, setCollections, items, - filteredItems, setItems, filter, setFilter, @@ -81,7 +77,15 @@ export function Value({ setDatetimeBounds, cogTileHref, setCogTileHref, -}: ValueProps) { + totalNumOfCollections, + datetimes, +}: { + totalNumOfCollections: number | undefined; + datetimes: { + start: Date; + end: Date; + } | null; +} & ValueProps) { const [search, setSearch] = useState(); const [fetchAllCollections, setFetchAllCollections] = useState(false); const [thumbnailError, setThumbnailError] = useState(false); @@ -131,10 +135,12 @@ export function Value({ ); }, [assets]); - const numberOfCollections = useMemo(() => { + const collectionsNumberMatched = useMemo(() => { return collectionsResult.data?.pages.at(0)?.numberMatched; }, [collectionsResult.data]); + const totalNumOfItems = items?.length; + useEffect(() => { setCollections( collectionsResult.data?.pages.flatMap((page) => page?.collections || []) @@ -274,8 +280,8 @@ export function Value({ {collections && collections.length && ( )} @@ -310,13 +316,14 @@ export function Value({ value={value} items={items} collections={collections} + datetimes={datetimes} /> )} {items && ( )} diff --git a/src/hooks/href-param.ts b/src/hooks/href-param.ts new file mode 100644 index 0000000..686f28a --- /dev/null +++ b/src/hooks/href-param.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; + +function getCurrentHref(): string { + return new URLSearchParams(location.search).get("href") || ""; +} + +function getInitialHref(): string | undefined { + const href = getCurrentHref(); + try { + new URL(href); + } catch { + return undefined; + } + return href; +} + +export default function useHrefParam() { + const [href, setHref] = useState(getInitialHref()); + + // Sync href with URL params + useEffect(() => { + if (href && getCurrentHref() != href) { + history.pushState(null, "", "?href=" + href); + } else if (href === "") { + history.pushState(null, "", location.pathname); + } + }, [href]); + + // Handle browser back/forward + useEffect(() => { + function handlePopState() { + setHref(getCurrentHref() ?? ""); + } + window.addEventListener("popstate", handlePopState); + + if (getCurrentHref()) { + try { + new URL(getCurrentHref()); + } catch { + history.pushState(null, "", location.pathname); + } + } + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, []); + + return { href, setHref }; +} diff --git a/src/hooks/stac-filters.ts b/src/hooks/stac-filters.ts new file mode 100644 index 0000000..6d22e02 --- /dev/null +++ b/src/hooks/stac-filters.ts @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import type { StacCollection, StacItem } from "stac-ts"; +import type { BBox2D } from "../types/map"; +import type { DatetimeBounds } from "../types/stac"; +import { + isCollectionInBbox, + isCollectionInDatetimeBounds, + isItemInBbox, + isItemInDatetimeBounds, +} from "../utils/stac"; + +interface UseStacFiltersProps { + collections?: StacCollection[]; + items?: StacItem[]; + filter: boolean; + bbox?: BBox2D; + datetimeBounds?: DatetimeBounds; +} + +export default function useStacFilters({ + collections, + items, + filter, + bbox, + datetimeBounds, +}: UseStacFiltersProps): { + filteredCollections: StacCollection[] | undefined; + filteredItems: StacItem[] | undefined; +} { + const filteredCollections = useMemo(() => { + if (filter && collections) { + const filtered = collections.filter( + (collection) => + (!bbox || isCollectionInBbox(collection, bbox)) && + (!datetimeBounds || + isCollectionInDatetimeBounds(collection, datetimeBounds)) + ); + return filtered; + } + return collections; + }, [collections, filter, bbox, datetimeBounds]); + + const filteredItems = useMemo(() => { + if (filter && items) { + const filtered = items.filter( + (item) => + (!bbox || isItemInBbox(item, bbox)) && + (!datetimeBounds || isItemInDatetimeBounds(item, datetimeBounds)) + ); + return filtered; + } + return items; + }, [items, filter, bbox, datetimeBounds]); + + return { filteredCollections, filteredItems }; +} diff --git a/src/layers/map.tsx b/src/layers/map.tsx index 38dcb83..049142c 100644 --- a/src/layers/map.tsx +++ b/src/layers/map.tsx @@ -24,7 +24,6 @@ export default function Map({ collections, filteredCollections, items, - filteredItems, fillColor, lineColor, setBbox, @@ -38,7 +37,6 @@ export default function Map({ collections: StacCollection[] | undefined; filteredCollections: StacCollection[] | undefined; items: StacItem[] | undefined; - filteredItems: StacItem[] | undefined; fillColor: Color; lineColor: Color; setBbox: (bbox: BBox2D | undefined) => void; @@ -135,7 +133,7 @@ export default function Map({ }), new GeoJsonLayer({ id: "items", - data: (filteredItems || items) as Feature[] | undefined, + data: items as Feature[] | undefined, filled: true, getFillColor: fillColor, getLineColor: lineColor, diff --git a/src/layers/overlay.tsx b/src/layers/overlay.tsx index bbf46e0..ea8929c 100644 --- a/src/layers/overlay.tsx +++ b/src/layers/overlay.tsx @@ -29,9 +29,14 @@ export default function Overlay({ picked, setPicked, items, - filteredItems, ...props -}: OverlayProps) { +}: { + totalNumOfCollections: number | undefined; + datetimes: { + start: Date; + end: Date; + } | null; +} & OverlayProps) { return ( @@ -63,7 +68,6 @@ export default function Overlay({ value={picked || value} fileUpload={fileUpload} items={picked ? undefined : items} - filteredItems={picked ? undefined : filteredItems} {...props} /> diff --git a/src/utils/datetimes.ts b/src/utils/datetimes.ts new file mode 100644 index 0000000..91a4893 --- /dev/null +++ b/src/utils/datetimes.ts @@ -0,0 +1,44 @@ +import type { StacCollection, StacItem } from "stac-ts"; +import type { StacValue } from "../types/stac"; +import { getItemDatetimes } from "../utils/stac"; + +const getDateTimes = ( + value: StacValue, + items: StacItem[] | undefined, + collections: StacCollection[] | undefined +) => { + 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; +}; + +export default getDateTimes; diff --git a/src/utils/stac.ts b/src/utils/stac.ts index 57ed0b6..e8f241e 100644 --- a/src/utils/stac.ts +++ b/src/utils/stac.ts @@ -267,3 +267,17 @@ export function isVisual(asset: StacAsset) { } return false; } + +export function getCogTileHref(value: StacValue): string | undefined { + if (!value.assets) { + return undefined; + } + + for (const asset of Object.values(value.assets)) { + if (isCog(asset) && isVisual(asset)) { + return asset.href as string; + } + } + + return undefined; +} diff --git a/src/utils/title.ts b/src/utils/title.ts new file mode 100644 index 0000000..0c49f8b --- /dev/null +++ b/src/utils/title.ts @@ -0,0 +1,9 @@ +import type { StacValue } from "../types/stac"; + +export default function getDocumentTitle(value: StacValue | undefined) { + let title = "stac-map"; + if (value && (value.title || value.id)) { + title = "stac-map | " + (value.title || value.id); + } + return title; +} diff --git a/tests/hooks/document-title.spec.ts b/tests/hooks/document-title.spec.ts new file mode 100644 index 0000000..4a3316d --- /dev/null +++ b/tests/hooks/document-title.spec.ts @@ -0,0 +1,198 @@ +import type { StacCatalog, StacCollection, StacItem } from "stac-ts"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { StacItemCollection, StacValue } from "../../src/types/stac"; +import getDocumentTitle from "../../src/utils/title"; + +describe("useDocumentTitle logic", () => { + let originalTitle: string; + + beforeEach(() => { + if (typeof document !== "undefined") { + originalTitle = document.title; + } + }); + + afterEach(() => { + if (typeof document !== "undefined") { + document.title = originalTitle; + } + }); + + function setDocumentTitle(value: StacValue | undefined) { + document.title = getDocumentTitle(value); + } + + test("should set default title when value is undefined", () => { + setDocumentTitle(undefined); + + expect(document.title).toBe("stac-map"); + }); + + test("should set title with catalog title", () => { + const catalog: StacCatalog = { + id: "test-catalog", + type: "Catalog", + title: "Test Catalog", + description: "A test catalog", + stac_version: "1.0.0", + links: [], + }; + + setDocumentTitle(catalog); + + expect(document.title).toBe("stac-map | Test Catalog"); + }); + + test("should set title with catalog id when title is missing", () => { + const catalog: StacCatalog = { + id: "test-catalog", + type: "Catalog", + description: "A test catalog", + stac_version: "1.0.0", + links: [], + }; + + setDocumentTitle(catalog); + + expect(document.title).toBe("stac-map | test-catalog"); + }); + + test("should set title with collection title", () => { + const collection: StacCollection = { + id: "test-collection", + type: "Collection", + title: "Test Collection", + description: "A test collection", + stac_version: "1.0.0", + license: "MIT", + extent: { + spatial: { + bbox: [[-180, -90, 180, 90]], + }, + temporal: { + interval: [[null, null]], + }, + }, + links: [], + }; + + setDocumentTitle(collection); + + expect(document.title).toBe("stac-map | Test Collection"); + }); + + test("should set title with item title", () => { + const item: StacItem = { + id: "test-item", + type: "Feature", + title: "Test Item", + stac_version: "1.0.0", + geometry: { + type: "Point", + coordinates: [0, 0], + }, + properties: { + datetime: "2020-01-01T00:00:00Z", + }, + links: [], + assets: {}, + }; + + setDocumentTitle(item); + + expect(document.title).toBe("stac-map | Test Item"); + }); + + test("should set title with item collection title", () => { + const itemCollection: StacItemCollection = { + type: "FeatureCollection", + title: "Test Item Collection", + features: [], + }; + + setDocumentTitle(itemCollection); + + expect(document.title).toBe("stac-map | Test Item Collection"); + }); + + test("should set title with item collection id when title is missing", () => { + const itemCollection: StacItemCollection = { + type: "FeatureCollection", + id: "test-item-collection", + features: [], + }; + + setDocumentTitle(itemCollection); + + expect(document.title).toBe("stac-map | test-item-collection"); + }); + + test("should prefer title over id when both are present", () => { + const catalog: StacCatalog = { + id: "catalog-id", + type: "Catalog", + title: "Catalog Title", + description: "A test catalog", + stac_version: "1.0.0", + links: [], + }; + + setDocumentTitle(catalog); + + expect(document.title).toBe("stac-map | Catalog Title"); + }); + + test("should handle value without title or id", () => { + const itemCollection: StacItemCollection = { + type: "FeatureCollection", + features: [], + }; + + setDocumentTitle(itemCollection); + + expect(document.title).toBe("stac-map"); + }); + + test("should update title when value changes", () => { + const catalog1: StacCatalog = { + id: "catalog1", + type: "Catalog", + title: "First Catalog", + description: "First catalog", + stac_version: "1.0.0", + links: [], + }; + + const catalog2: StacCatalog = { + id: "catalog2", + type: "Catalog", + title: "Second Catalog", + description: "Second catalog", + stac_version: "1.0.0", + links: [], + }; + + setDocumentTitle(catalog1); + expect(document.title).toBe("stac-map | First Catalog"); + + setDocumentTitle(catalog2); + expect(document.title).toBe("stac-map | Second Catalog"); + }); + + test("should reset to default title when value becomes undefined", () => { + const catalog: StacCatalog = { + id: "test-catalog", + type: "Catalog", + title: "Test Catalog", + description: "A test catalog", + stac_version: "1.0.0", + links: [], + }; + + setDocumentTitle(catalog); + expect(document.title).toBe("stac-map | Test Catalog"); + + setDocumentTitle(undefined); + expect(document.title).toBe("stac-map"); + }); +}); diff --git a/tests/hooks/href-param.spec.ts b/tests/hooks/href-param.spec.ts new file mode 100644 index 0000000..b396383 --- /dev/null +++ b/tests/hooks/href-param.spec.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +// Test the getInitialHref logic by importing and testing the hook behavior +describe("useHrefParam", () => { + beforeEach(() => { + // Reset URL to clean state + if (typeof window !== "undefined") { + history.replaceState({}, "", "/"); + } + }); + + test("should parse valid URL from href parameter", () => { + const href = "https://example.com/catalog.json"; + const url = new URL(`http://localhost/?href=${href}`); + const params = new URLSearchParams(url.search); + const parsedHref = params.get("href") || ""; + + let isValid = false; + try { + new URL(parsedHref); + isValid = true; + } catch { + isValid = false; + } + + expect(isValid).toBe(true); + expect(parsedHref).toBe(href); + }); + + test("should reject invalid URLs in href parameter", () => { + const href = "not-a-valid-url"; + const url = new URL(`http://localhost/?href=${href}`); + const params = new URLSearchParams(url.search); + const parsedHref = params.get("href") || ""; + + let isValid = false; + try { + new URL(parsedHref); + isValid = true; + } catch { + isValid = false; + } + + expect(isValid).toBe(false); + }); + + test("should handle empty href parameter", () => { + const url = new URL(`http://localhost/`); + const params = new URLSearchParams(url.search); + const parsedHref = params.get("href") || ""; + + expect(parsedHref).toBe(""); + }); + + test("should construct proper query string with href", () => { + const href = "https://example.com/catalog.json"; + const queryString = "?href=" + href; + + expect(queryString).toBe("?href=https://example.com/catalog.json"); + }); + + test("should handle file upload state", () => { + const mockFile = { name: "test.json" }; + const acceptedFiles = [mockFile]; + + expect(acceptedFiles.length).toBe(1); + expect(acceptedFiles[0].name).toBe("test.json"); + }); +}); diff --git a/tests/hooks/stac-filters.spec.ts b/tests/hooks/stac-filters.spec.ts new file mode 100644 index 0000000..179ce61 --- /dev/null +++ b/tests/hooks/stac-filters.spec.ts @@ -0,0 +1,211 @@ +import type { StacCollection, StacItem } from "stac-ts"; +import { describe, expect, test } from "vitest"; +import type { BBox2D } from "../../src/types/map"; +import type { DatetimeBounds } from "../../src/types/stac"; +import { + isCollectionInBbox, + isCollectionInDatetimeBounds, + isItemInBbox, + isItemInDatetimeBounds, +} from "../../src/utils/stac"; + +describe("useStacFilters logic", () => { + const mockCollection1: StacCollection = { + id: "collection1", + type: "Collection", + stac_version: "1.0.0", + description: "Test collection 1", + license: "MIT", + extent: { + spatial: { + bbox: [[-180, -90, 180, 90]], + }, + temporal: { + interval: [["2020-01-01T00:00:00Z", "2020-12-31T23:59:59Z"]], + }, + }, + links: [], + }; + + const mockCollection2: StacCollection = { + id: "collection2", + type: "Collection", + stac_version: "1.0.0", + description: "Test collection 2", + license: "MIT", + extent: { + spatial: { + bbox: [[0, 0, 10, 10]], + }, + temporal: { + interval: [["2021-01-01T00:00:00Z", "2021-12-31T23:59:59Z"]], + }, + }, + links: [], + }; + + const mockItem1: StacItem = { + id: "item1", + type: "Feature", + stac_version: "1.0.0", + geometry: { + type: "Point", + coordinates: [0, 0], + }, + bbox: [-1, -1, 1, 1], + properties: { + datetime: "2020-06-15T00:00:00Z", + }, + links: [], + assets: {}, + }; + + const mockItem2: StacItem = { + id: "item2", + type: "Feature", + stac_version: "1.0.0", + geometry: { + type: "Point", + coordinates: [50, 50], + }, + bbox: [49, 49, 51, 51], + properties: { + datetime: "2021-06-15T00:00:00Z", + }, + links: [], + assets: {}, + }; + + test("should filter collections by bbox", () => { + const bbox: BBox2D = [0, 0, 10, 10]; + + const filtered = [mockCollection1, mockCollection2].filter((collection) => + isCollectionInBbox(collection, bbox) + ); + + // collection1 has global bbox, collection2 overlaps with the filter bbox + expect(filtered.length).toBe(1); + expect(filtered.map((c) => c.id)).toContain("collection2"); + }); + + test("should filter collections by datetime bounds", () => { + const datetimeBounds: DatetimeBounds = { + start: new Date("2020-01-01"), + end: new Date("2020-12-31"), + }; + + const filtered = [mockCollection1, mockCollection2].filter((collection) => + isCollectionInDatetimeBounds(collection, datetimeBounds) + ); + + expect(filtered.length).toBe(1); + expect(filtered[0].id).toBe("collection1"); + }); + + test("should filter collections by both bbox and datetime bounds", () => { + const bbox: BBox2D = [0, 0, 10, 10]; + const datetimeBounds: DatetimeBounds = { + start: new Date("2021-01-01"), + end: new Date("2021-12-31"), + }; + + const filtered = [mockCollection1, mockCollection2].filter( + (collection) => + isCollectionInBbox(collection, bbox) && + isCollectionInDatetimeBounds(collection, datetimeBounds) + ); + + expect(filtered.length).toBe(1); + expect(filtered[0].id).toBe("collection2"); + }); + + test("should filter items by bbox", () => { + const bbox: BBox2D = [-5, -5, 5, 5]; + + const filtered = [mockItem1, mockItem2].filter((item) => + isItemInBbox(item, bbox) + ); + + expect(filtered.length).toBe(1); + expect(filtered[0].id).toBe("item1"); + }); + + test("should filter items by datetime bounds", () => { + const datetimeBounds: DatetimeBounds = { + start: new Date("2020-01-01"), + end: new Date("2020-12-31"), + }; + + const filtered = [mockItem1, mockItem2].filter((item) => + isItemInDatetimeBounds(item, datetimeBounds) + ); + + expect(filtered.length).toBe(1); + expect(filtered[0].id).toBe("item1"); + }); + + test("should filter items by both bbox and datetime bounds", () => { + const bbox: BBox2D = [-5, -5, 5, 5]; + const datetimeBounds: DatetimeBounds = { + start: new Date("2020-01-01"), + end: new Date("2020-12-31"), + }; + + const filtered = [mockItem1, mockItem2].filter( + (item) => + isItemInBbox(item, bbox) && isItemInDatetimeBounds(item, datetimeBounds) + ); + + expect(filtered.length).toBe(1); + expect(filtered[0].id).toBe("item1"); + }); + + test("should return empty array when no items match filters", () => { + const bbox: BBox2D = [100, 100, 110, 110]; + + const filtered = [mockItem1, mockItem2].filter((item) => + isItemInBbox(item, bbox) + ); + + expect(filtered.length).toBe(0); + }); + + test("should handle global bbox (360 degrees wide)", () => { + const globalBbox: BBox2D = [-180, -90, 180, 90]; + + const filteredCollections = [mockCollection1, mockCollection2].filter( + (collection) => isCollectionInBbox(collection, globalBbox) + ); + + const filteredItems = [mockItem1, mockItem2].filter((item) => + isItemInBbox(item, globalBbox) + ); + + // Global bbox should include all collections and items + expect(filteredCollections.length).toBe(2); + expect(filteredItems.length).toBe(2); + }); + + test("should not filter when filter is false (logic test)", () => { + const filter = false; + const collections = [mockCollection1, mockCollection2]; + + const result = filter ? collections.filter(() => true) : undefined; + + expect(result).toBeUndefined(); + }); + + test("should filter when filter is true (logic test)", () => { + const filter = true; + const collections = [mockCollection1, mockCollection2]; + const bbox: BBox2D = [0, 0, 10, 10]; + + const result = filter + ? collections.filter((c) => isCollectionInBbox(c, bbox)) + : undefined; + + expect(result).toBeDefined(); + expect(result?.length).toBe(1); + expect(result?.[0].id).toBe("collection2"); + }); +});