From d673876c9ff3765c45d0e3b3251e422033518720 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 4 Dec 2025 10:54:38 -0700 Subject: [PATCH 1/3] refactor: move things to hooks, add tests --- .gitignore | 2 +- src/app.tsx | 110 ++------------- src/hooks/document-title.ts | 12 ++ src/hooks/href-param.ts | 55 ++++++++ src/hooks/stac-filters.ts | 53 ++++++++ src/utils/stac.ts | 14 ++ tests/hooks/document-title.spec.ts | 201 +++++++++++++++++++++++++++ tests/hooks/href-param.spec.ts | 69 ++++++++++ tests/hooks/stac-filters.spec.ts | 211 +++++++++++++++++++++++++++++ 9 files changed, 630 insertions(+), 97 deletions(-) create mode 100644 src/hooks/document-title.ts create mode 100644 src/hooks/href-param.ts create mode 100644 src/hooks/stac-filters.ts create mode 100644 tests/hooks/document-title.spec.ts create mode 100644 tests/hooks/href-param.spec.ts create mode 100644 tests/hooks/stac-filters.spec.ts 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..fad36dd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,21 +1,17 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, 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 useDocumentTitle from "./hooks/document-title"; +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 { getCogTileHref } from "./utils/stac"; // TODO make this configurable by the user. const lineColor: Color = [207, 63, 2, 100]; @@ -23,8 +19,8 @@ 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(fileUpload); const [userCollections, setCollections] = useState(); const [userItems, setItems] = useState(); const [picked, setPicked] = useState(); @@ -54,77 +50,22 @@ 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]); + const { filteredCollections, filteredItems } = useStacFilters({ + collections, + 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); - }; - }, []); - - useEffect(() => { - if (href && new URLSearchParams(location.search).get("href") != href) { - history.pushState(null, "", "?href=" + href); - } else if (href === "") { - history.pushState(null, "", location.pathname); - } - }, [href]); - - useEffect(() => { - // It should never be more than 1. - if (fileUpload.acceptedFiles.length == 1) { - setHref(fileUpload.acceptedFiles[0].name); - } - }, [fileUpload.acceptedFiles]); + useDocumentTitle(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(() => { @@ -201,26 +142,3 @@ export default function App() { ); } - -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/hooks/document-title.ts b/src/hooks/document-title.ts new file mode 100644 index 0000000..8bb999d --- /dev/null +++ b/src/hooks/document-title.ts @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import type { StacValue } from "../types/stac"; + +export default function useDocumentTitle(value: StacValue | undefined) { + useEffect(() => { + if (value && (value.title || value.id)) { + document.title = "stac-map | " + (value.title || value.id); + } else { + document.title = "stac-map"; + } + }, [value]); +} diff --git a/src/hooks/href-param.ts b/src/hooks/href-param.ts new file mode 100644 index 0000000..b71d222 --- /dev/null +++ b/src/hooks/href-param.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import type { UseFileUploadReturn } from "@chakra-ui/react"; + +function getInitialHref(): string | undefined { + const href = new URLSearchParams(location.search).get("href") || ""; + try { + new URL(href); + } catch { + return undefined; + } + return href; +} + +export default function useHrefParam(fileUpload: UseFileUploadReturn) { + const [href, setHref] = useState(getInitialHref()); + + // Sync href with URL params + useEffect(() => { + if (href && new URLSearchParams(location.search).get("href") != href) { + history.pushState(null, "", "?href=" + href); + } else if (href === "") { + history.pushState(null, "", location.pathname); + } + }, [href]); + + // Handle browser back/forward + 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); + }; + }, []); + + // Handle file uploads + useEffect(() => { + if (fileUpload.acceptedFiles.length == 1) { + setHref(fileUpload.acceptedFiles[0].name); + } + }, [fileUpload.acceptedFiles]); + + return { href, setHref }; +} diff --git a/src/hooks/stac-filters.ts b/src/hooks/stac-filters.ts new file mode 100644 index 0000000..3bfdba2 --- /dev/null +++ b/src/hooks/stac-filters.ts @@ -0,0 +1,53 @@ +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) { + 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]); + + return { filteredCollections, filteredItems }; +} 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/tests/hooks/document-title.spec.ts b/tests/hooks/document-title.spec.ts new file mode 100644 index 0000000..fda04f8 --- /dev/null +++ b/tests/hooks/document-title.spec.ts @@ -0,0 +1,201 @@ +import type { StacCatalog, StacCollection, StacItem } from "stac-ts"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { StacItemCollection, StacValue } from "../../src/types/stac"; + +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) { + if (value && (value.title || value.id)) { + document.title = "stac-map | " + (value.title || value.id); + } else { + document.title = "stac-map"; + } + } + + 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"); + }); +}); From 115e55a4c07cc29cc76a58b60371d9794cf473e0 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 10 Dec 2025 17:38:26 -0700 Subject: [PATCH 2/3] fix: use onFileChange --- src/app.tsx | 11 +++++++++-- src/hooks/href-param.ts | 10 +--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index fad36dd..c44bbbb 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -19,8 +19,15 @@ const fillColor: Color = [207, 63, 2, 50]; export default function App() { // User state - const fileUpload = useFileUpload({ maxFiles: 1 }); - const { href, setHref } = useHrefParam(fileUpload); + 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(); diff --git a/src/hooks/href-param.ts b/src/hooks/href-param.ts index b71d222..c7fa69c 100644 --- a/src/hooks/href-param.ts +++ b/src/hooks/href-param.ts @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import type { UseFileUploadReturn } from "@chakra-ui/react"; function getInitialHref(): string | undefined { const href = new URLSearchParams(location.search).get("href") || ""; @@ -11,7 +10,7 @@ function getInitialHref(): string | undefined { return href; } -export default function useHrefParam(fileUpload: UseFileUploadReturn) { +export default function useHrefParam() { const [href, setHref] = useState(getInitialHref()); // Sync href with URL params @@ -44,12 +43,5 @@ export default function useHrefParam(fileUpload: UseFileUploadReturn) { }; }, []); - // Handle file uploads - useEffect(() => { - if (fileUpload.acceptedFiles.length == 1) { - setHref(fileUpload.acceptedFiles[0].name); - } - }, [fileUpload.acceptedFiles]); - return { href, setHref }; } From a5f1d74ffb2f0f52a21a3831b6e546d517112bbe Mon Sep 17 00:00:00 2001 From: sandrahoang686 Date: Tue, 16 Dec 2025 14:15:35 -0500 Subject: [PATCH 3/3] refactor: Updates to isolated hooks (#223) Hey @gadomski, I figured it would be quicker if I just made the changes I was going to comment onto your PR to hopefully be quicker. Let me know what you think? Should be able to follow my commits. I made sure to validate the behavior matched prod and these changes did - feel free to manually validate as well! **Changes:** * Updated the `useDocumentTitle` to no longer be a hook and instead made it a fn. The useEffect which depended on `value` from `useStacValue` - I just kept in `app.tsx` because thats the only place that would need that subscription so I didn't think a separate hook was needed. * Cleaned up `useHrefParam` * Updated `useStacFilters` to either return the filtered assets or the original assets array instead of `undefined`. This way we dont have to pass in `collections` & `filteredCollections` separately (same with items). * So => ```collections={filteredCollections}``` and same with items --------- Co-authored-by: Pete Gadomski --- src/app.tsx | 25 +++++++++----- src/components/panel.tsx | 8 ++++- src/components/sections/collections.tsx | 20 +++++------ src/components/sections/filter.tsx | 46 +++---------------------- src/components/sections/items.tsx | 13 +++---- src/components/value.tsx | 25 +++++++++----- src/hooks/document-title.ts | 12 ------- src/hooks/href-param.ts | 15 ++++---- src/hooks/stac-filters.ts | 17 +++++---- src/layers/map.tsx | 4 +-- src/layers/overlay.tsx | 10 ++++-- src/utils/datetimes.ts | 44 +++++++++++++++++++++++ src/utils/title.ts | 9 +++++ tests/hooks/document-title.spec.ts | 7 ++-- 14 files changed, 142 insertions(+), 113 deletions(-) delete mode 100644 src/hooks/document-title.ts create mode 100644 src/utils/datetimes.ts create mode 100644 src/utils/title.ts diff --git a/src/app.tsx b/src/app.tsx index c44bbbb..e94f9b6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,8 +1,7 @@ -import { useEffect, useState } from "react"; +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 useDocumentTitle from "./hooks/document-title"; import useHrefParam from "./hooks/href-param"; import useStacChildren from "./hooks/stac-children"; import useStacFilters from "./hooks/stac-filters"; @@ -11,7 +10,9 @@ 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 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]; @@ -65,8 +66,15 @@ export default function App() { datetimeBounds, }); + const datetimes = useMemo( + () => (value ? getDateTimes(value, items, collections) : null), + [value, items, collections] + ); + // Effects - useDocumentTitle(value); + useEffect(() => { + document.title = getDocumentTitle(value); + }, [value]); useEffect(() => { setPicked(undefined); @@ -99,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} @@ -130,19 +137,19 @@ 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} > 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/document-title.ts b/src/hooks/document-title.ts deleted file mode 100644 index 8bb999d..0000000 --- a/src/hooks/document-title.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useEffect } from "react"; -import type { StacValue } from "../types/stac"; - -export default function useDocumentTitle(value: StacValue | undefined) { - useEffect(() => { - if (value && (value.title || value.id)) { - document.title = "stac-map | " + (value.title || value.id); - } else { - document.title = "stac-map"; - } - }, [value]); -} diff --git a/src/hooks/href-param.ts b/src/hooks/href-param.ts index c7fa69c..686f28a 100644 --- a/src/hooks/href-param.ts +++ b/src/hooks/href-param.ts @@ -1,7 +1,11 @@ import { useEffect, useState } from "react"; +function getCurrentHref(): string { + return new URLSearchParams(location.search).get("href") || ""; +} + function getInitialHref(): string | undefined { - const href = new URLSearchParams(location.search).get("href") || ""; + const href = getCurrentHref(); try { new URL(href); } catch { @@ -15,7 +19,7 @@ export default function useHrefParam() { // Sync href with URL params useEffect(() => { - if (href && new URLSearchParams(location.search).get("href") != href) { + if (href && getCurrentHref() != href) { history.pushState(null, "", "?href=" + href); } else if (href === "") { history.pushState(null, "", location.pathname); @@ -25,14 +29,13 @@ export default function useHrefParam() { // Handle browser back/forward useEffect(() => { function handlePopState() { - setHref(new URLSearchParams(location.search).get("href") ?? ""); + setHref(getCurrentHref() ?? ""); } window.addEventListener("popstate", handlePopState); - const href = new URLSearchParams(location.search).get("href"); - if (href) { + if (getCurrentHref()) { try { - new URL(href); + new URL(getCurrentHref()); } catch { history.pushState(null, "", location.pathname); } diff --git a/src/hooks/stac-filters.ts b/src/hooks/stac-filters.ts index 3bfdba2..6d22e02 100644 --- a/src/hooks/stac-filters.ts +++ b/src/hooks/stac-filters.ts @@ -23,30 +23,33 @@ export default function useStacFilters({ filter, bbox, datetimeBounds, -}: UseStacFiltersProps) { +}: UseStacFiltersProps): { + filteredCollections: StacCollection[] | undefined; + filteredItems: StacItem[] | undefined; +} { const filteredCollections = useMemo(() => { if (filter && collections) { - return collections.filter( + const filtered = collections.filter( (collection) => (!bbox || isCollectionInBbox(collection, bbox)) && (!datetimeBounds || isCollectionInDatetimeBounds(collection, datetimeBounds)) ); - } else { - return undefined; + return filtered; } + return collections; }, [collections, filter, bbox, datetimeBounds]); const filteredItems = useMemo(() => { if (filter && items) { - return items.filter( + const filtered = items.filter( (item) => (!bbox || isItemInBbox(item, bbox)) && (!datetimeBounds || isItemInDatetimeBounds(item, datetimeBounds)) ); - } else { - return undefined; + 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/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 index fda04f8..4a3316d 100644 --- a/tests/hooks/document-title.spec.ts +++ b/tests/hooks/document-title.spec.ts @@ -1,6 +1,7 @@ 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; @@ -18,11 +19,7 @@ describe("useDocumentTitle logic", () => { }); function setDocumentTitle(value: StacValue | undefined) { - if (value && (value.title || value.id)) { - document.title = "stac-map | " + (value.title || value.id); - } else { - document.title = "stac-map"; - } + document.title = getDocumentTitle(value); } test("should set default title when value is undefined", () => {