From 8cb030d07da43a08137ccbaa03fd7178c9fd4974 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 13:04:40 -0600 Subject: [PATCH 01/19] feat(stac): browse a static catalog as a tree and search from what you pick --- .../src/components/layout/TopToolbar.tsx | 2 + .../geolibre-desktop/src/i18n/locales/en.json | 2 + e2e/stac-catalog-tree.spec.ts | 179 ++++++ packages/plugins/src/index.ts | 3 + packages/plugins/src/panel-dom.ts | 14 + packages/plugins/src/plugins/maplibre-stac.ts | 51 +- packages/plugins/src/plugins/stac-api.ts | 98 ++- .../plugins/src/plugins/stac-catalog-tree.ts | 283 +++++++++ tests/stac-api.test.ts | 431 ++++++++++++++ tests/stac-catalog-tree.test.ts | 561 ++++++++++++++++++ 10 files changed, 1610 insertions(+), 14 deletions(-) create mode 100644 e2e/stac-catalog-tree.spec.ts create mode 100644 packages/plugins/src/panel-dom.ts create mode 100644 packages/plugins/src/plugins/stac-catalog-tree.ts create mode 100644 tests/stac-catalog-tree.test.ts diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index be165c0e03..a5c52b7e82 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -907,6 +907,8 @@ export function TopToolbar({ searching: t("stacPlugin.searching"), loadingMore: t("stacPlugin.loadingMore"), noMatchesHere: t("stacPlugin.noMatchesHere"), + treeEmpty: t("stacPlugin.treeEmpty"), + treeOpenFailed: t("stacPlugin.treeOpenFailed"), noResults: t("stacPlugin.noResults"), searchFailed: t("stacPlugin.searchFailed"), showing: (count) => t("stacPlugin.showing", { count }), diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 42e3bf2389..a5035ad1a2 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -3400,6 +3400,8 @@ "searching": "Searching STAC items…", "loadingMore": "Loading more items…", "noMatchesHere": "Nothing matched in that part of the catalog. Load more to keep searching.", + "treeEmpty": "Empty", + "treeOpenFailed": "Could not open this catalog", "noResults": "No STAC items matched these filters.", "searchFailed": "STAC search failed", "showing": "Showing {{count}} items.", diff --git a/e2e/stac-catalog-tree.spec.ts b/e2e/stac-catalog-tree.spec.ts new file mode 100644 index 0000000000..ffc0d6e2bb --- /dev/null +++ b/e2e/stac-catalog-tree.spec.ts @@ -0,0 +1,179 @@ +import { expect, test, type Page } from "@playwright/test"; +import { waitForMap } from "./helpers"; + +// The catalog tree paints its selection through inline styles, which only a real browser resolves: +// `hsl(var(--primary))` is a string until a CSSOM accepts it and a stylesheet defines the variable. +// The unit tests run against a DOM double that stores any string it is given, so a highlight that +// never appears — or never goes away — reads as passing there. This suite is the check that cannot +// be faked: it asks the browser what colour the row actually is. +const ROOT = "https://stac.test/catalog.json"; + +const DOCUMENTS: Record = { + "https://stac.test/catalog.json": { + type: "Catalog", + id: "e2e", + title: "E2E Catalog", + links: [ + { rel: "child", href: "./hazards/collection.json", title: "Hazards" }, + { rel: "child", href: "./geology/collection.json", title: "Geology" }, + { rel: "child", href: "./topics/catalog.json", title: "Topics" }, + ], + }, + "https://stac.test/topics/catalog.json": { + type: "Catalog", + id: "topics", + links: [{ rel: "child", href: "./water/collection.json", title: "Water" }], + }, +}; + +/** Serves the fixture catalog, so the suite needs no network and no third-party catalog. */ +async function serveCatalog(page: Page): Promise { + await page.route("https://stac.test/**", async (route) => { + const document = DOCUMENTS[route.request().url()]; + if (!document) { + await route.fulfill({ status: 404, body: "not found" }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(document), + }); + }); +} + +async function openStacPanel(page: Page): Promise { + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + await page.getByRole("menuitem", { name: "Web Services" }).click(); + await page.getByRole("menuitem", { name: "STAC Catalogs" }).click(); + await page.getByPlaceholder("https://example.org/stac/").fill(ROOT); + await page.getByRole("button", { name: "Connect", exact: true }).click(); +} + +const backgroundOf = (page: Page, name: string) => + page.getByRole("treeitem", { name }).evaluate((row) => getComputedStyle(row).backgroundColor); + +test("the tree paints the selection, and lets go of it", async ({ page }) => { + await serveCatalog(page); + await waitForMap(page); + await openStacPanel(page); + + const hazards = page.getByRole("treeitem", { name: "Hazards" }); + const geology = page.getByRole("treeitem", { name: "Geology" }); + await expect(hazards).toBeVisible(); + + const unselected = await backgroundOf(page, "Hazards"); + await hazards.click(); + const selected = await backgroundOf(page, "Hazards"); + expect(selected).not.toBe(unselected); + expect(selected).not.toBe("rgba(0, 0, 0, 0)"); + + // The bug this guards: the highlight stayed on every row ever clicked, because the code removed + // it by string surgery on a `cssText` the browser had already rewritten. + await geology.click(); + expect(await backgroundOf(page, "Hazards")).toBe(unselected); + expect(await backgroundOf(page, "Geology")).toBe(selected); + await expect(hazards).toHaveAttribute("aria-selected", "false"); + await expect(geology).toHaveAttribute("aria-selected", "true"); +}); + +test("a selected row wears the theme's own highlight pair, the right way round", async ({ + page, +}) => { + await serveCatalog(page); + await waitForMap(page); + await openStacPanel(page); + + const hazards = page.getByRole("treeitem", { name: "Hazards" }); + await hazards.click(); + const [background, color] = await hazards.evaluate((row) => { + const style = getComputedStyle(row); + return [style.backgroundColor, style.color]; + }); + + // Resolved from the live theme rather than hard-coded, so this holds in light and dark alike — + // and still fails if the pair is swapped, or if only the background is painted. + const [primary, foreground] = await page.evaluate(() => { + const probe = document.createElement("div"); + document.body.append(probe); + probe.style.background = "hsl(var(--primary))"; + probe.style.color = "hsl(var(--primary-foreground))"; + const style = getComputedStyle(probe); + const pair = [style.backgroundColor, style.color]; + probe.remove(); + return pair; + }); + + expect(background).toBe(primary); + expect(color).toBe(foreground); + expect(background).not.toBe(color); +}); + +test("depth is indented, and a folder reads its children only when opened", async ({ page }) => { + await serveCatalog(page); + await waitForMap(page); + await openStacPanel(page); + + const topics = page.getByRole("treeitem", { name: "Topics" }); + await expect(topics).toHaveAttribute("aria-expanded", "false"); + await topics.click(); + + const water = page.getByRole("treeitem", { name: "Water" }); + await expect(water).toBeVisible(); + await expect(topics).toHaveAttribute("aria-expanded", "true"); + + // Indentation is what makes the nesting readable; a unitless or physical value would leave the + // tree flat in the browser while the inline string still looked right. + const [parent, child] = await Promise.all([ + topics.evaluate((row) => getComputedStyle(row).paddingInlineStart), + water.evaluate((row) => getComputedStyle(row).paddingInlineStart), + ]); + expect(parseFloat(child)).toBeGreaterThan(parseFloat(parent)); +}); + +test("the tree is one tab stop, and the arrows move within it", async ({ page }) => { + await serveCatalog(page); + await waitForMap(page); + await openStacPanel(page); + + const hazards = page.getByRole("treeitem", { name: "Hazards" }); + await expect(hazards).toBeVisible(); + + // Reaching the tree costs one tab, and reaching what follows it costs one more — not one per + // row, which on a catalog of hundreds is the difference between usable and not. + await page.getByPlaceholder("https://example.org/stac/").focus(); + const stops: string[] = []; + for (let press = 0; press < 6; press += 1) { + await page.keyboard.press("Tab"); + stops.push( + await page.evaluate(() => { + const active = document.activeElement; + return active?.getAttribute("role") === "treeitem" + ? `treeitem:${active.textContent}` + : (active?.tagName.toLowerCase() ?? "none"); + }), + ); + } + expect(stops.filter((stop) => stop.startsWith("treeitem"))).toHaveLength(1); + + await hazards.focus(); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("treeitem", { name: "Geology" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("treeitem", { name: "Topics" })).toBeFocused(); + + // Right opens a folder and steps into it; Enter chooses the row it lands on. + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("treeitem", { name: "Topics" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await page.keyboard.press("ArrowRight"); + const water = page.getByRole("treeitem", { name: "Water" }); + await expect(water).toBeFocused(); + + // Enter chooses, as it does on any button. Ctrl+Enter is the keyboard's double-click, so + // searching a collection is reachable without a mouse. + await page.keyboard.press("Enter"); + await expect(water).toHaveAttribute("aria-selected", "true"); +}); diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index ec74f81ef9..7e835ebfc4 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -480,12 +480,15 @@ export { isVisualizableAsset, itemBbox, loadStacIndex, + openCatalogNode, searchStacApi, searchStaticStac, STAC_INDEX_CATALOGS_URL, type StacAsset, + type StacCatalogNode, type StacCollection, type StacConnection, + type StacOpenedNode, type StacIndexCatalog, type StacItem, type StacNextPage, diff --git a/packages/plugins/src/panel-dom.ts b/packages/plugins/src/panel-dom.ts new file mode 100644 index 0000000000..4d995817f7 --- /dev/null +++ b/packages/plugins/src/panel-dom.ts @@ -0,0 +1,14 @@ +/** + * DOM helpers for plugin panels, which are built by hand: this package is framework-agnostic and + * cannot render with React. + */ + +/** Creates an element, optionally with its text content set. */ +export function el( + tag: K, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + return node; +} diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index 3c4b32ad69..9b3d45f2de 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -8,6 +8,7 @@ import { isVisualizableAsset, itemBbox, loadStacIndex, + openCatalogNode, searchStacApi, searchStaticStac, type StacAsset, @@ -18,6 +19,8 @@ import { type StacSearchResult, type StacSearchCursor, } from "./stac-api"; +import { buildCatalogTree } from "./stac-catalog-tree"; +import { el } from "../panel-dom"; export const STAC_PLUGIN_ID = "geolibre-stac-catalogs"; const PANEL_ID = STAC_PLUGIN_ID; @@ -114,6 +117,8 @@ export interface StacLabels { searching: string; loadingMore: string; noMatchesHere: string; + treeEmpty: string; + treeOpenFailed: string; noResults: string; searchFailed: string; loadMore: string; @@ -182,6 +187,8 @@ let labels: StacLabels = { searching: "Searching STAC items…", loadingMore: "Loading more items…", noMatchesHere: "Nothing matched in that part of the catalog. Load more to keep searching.", + treeEmpty: "Empty", + treeOpenFailed: "Could not open this catalog", noResults: "No STAC items matched these filters.", searchFailed: "STAC search failed", loadMore: "Load more", @@ -256,15 +263,6 @@ const style = { "border-radius:7px;background:hsl(var(--muted));", } as const; -function el( - tag: K, - text?: string, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (text !== undefined) node.textContent = text; - return node; -} - function field(label: string, type = "text"): { wrap: HTMLElement; input: HTMLInputElement } { const wrap = el("label"); wrap.style.cssText = "display:flex;flex:1 1 0;min-width:0;flex-direction:column;gap:2px;"; @@ -554,6 +552,13 @@ function buildPanel(container: HTMLElement): () => void { // Catalogs can advertise hundreds of collections, so let the list be dragged taller. collectionSelect.style.cssText = `${style.input}resize:vertical;overflow:auto;min-height:58px;`; collectionSelect.title = labels.collectionsHint; + // An API answers with a flat list of collections; a static catalog is a tree read as it opens. + const tree = buildCatalogTree({ + labels: { empty: labels.treeEmpty, openFailed: labels.treeOpenFailed }, + onError: (message) => setStatus(message, true), + onActivate: showCollection, + signal: controller.signal, + }); const extentRow = el("label"); extentRow.style.cssText = style.row; const useExtent = el("input"); @@ -599,6 +604,7 @@ function buildPanel(container: HTMLElement): () => void { searchSection.append( catalogInfo, collectionSelect, + tree.element, extentRow, bboxField.wrap, drawRow, @@ -868,7 +874,28 @@ function buildPanel(container: HTMLElement): () => void { return labels.showing(allItems.length); }; - const runSearch = async (append: boolean): Promise => { + // A double-click means the same here as in the tree: search this one. + collectionSelect.addEventListener("dblclick", () => { + const chosen = collectionSelect.selectedOptions[0]?.value; + const extent = connection?.collections.find((collection) => collection.id === chosen)?.extent; + const box = extent?.spatial?.bbox?.[0]; + void runSearch(false); + if (box && box.length >= 4) { + appRef?.fitBounds?.([box[0], box[1], box[box.length / 2], box[box.length / 2 + 1]]); + } + }); + + /** The tree asked for a collection: search it, and send the map to it. */ + function showCollection(href: string, bbox?: [number, number, number, number]): void { + void runSearch(false); + if (bbox) return void appRef?.fitBounds?.(bbox); + // A collection guessed from its link has never been read, so its extent has to be fetched. + void openCatalogNode(href, fetch, controller.signal) + .then((node) => node.bbox && appRef?.fitBounds?.(node.bbox)) + .catch(() => undefined); + } + + async function runSearch(append: boolean): Promise { if (!connection) return; const generation = ++searchGeneration; searchButton.disabled = true; @@ -885,6 +912,7 @@ function buildPanel(container: HTMLElement): () => void { bbox: parseBbox(), datetime, collections: selectedCollections, + entries: connection.isApi ? [] : tree.selection(), additional: parseAdditionalParams(), limit: 20, next: append ? nextPage : undefined, @@ -941,6 +969,9 @@ function buildPanel(container: HTMLElement): () => void { } else { collectionSelect.hidden = true; } + const children = connection.children ?? []; + tree.reset(children); + tree.element.hidden = connection.isApi || !children.length; searchSection.hidden = false; renderSection.hidden = false; clearSearchResults(false); diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 20688b88a2..c7f03a89ba 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -18,6 +18,7 @@ export interface StacIndexCatalog { export interface StacLink { rel: string; href: string; + title?: string; type?: string; method?: string; body?: Record; @@ -56,9 +57,27 @@ export interface StacConnection { isApi: boolean; searchUrl?: string; collections: StacCollection[]; + /** A static catalog's top-level children, to be opened on demand. Empty for an API. */ + children?: StacCatalogNode[]; root: Record; } +export interface StacCatalogNode { + href: string; + title: string; + /** A collection can be searched; a container is opened to see what is inside. Only the link + * says which, so a container may turn out to be a collection once it is read. */ + kind: "collection" | "container"; +} + +/** What a node turned out to be, and what it holds. */ +export interface StacOpenedNode { + kind: "collection" | "container"; + children: StacCatalogNode[]; + /** A collection's own extent, so the map can be sent to it without reading any item. */ + bbox?: [number, number, number, number]; +} + /** A walk in progress; hand it back to continue. Mutated in place rather than copied. */ export interface StacSearchCursor { items: Unread[]; @@ -68,12 +87,16 @@ export interface StacSearchCursor { offset: number; /** Documents given up on; with any of these the catalog was not fully read. */ dropped: number; + /** The filters this walk began with, so later pages filter the way page one did. */ + filters: Pick; } export interface StacSearchOptions { bbox?: [number, number, number, number]; datetime?: string; collections?: string[]; + /** Documents to search instead of the whole catalog, from the tree's selection. */ + entries?: string[]; cursor?: StacSearchCursor; /** Additional STAC API Item Search members such as query, filter, sortby, or fields. */ additional?: Record; @@ -185,6 +208,63 @@ function normalizeItem(item: StacItem, base: string): StacItem { return { ...item, assets, links: linksOf(item.links, base) }; } +/** Names an untitled node after the folder it sits in. */ +function folderName(href: string): string { + const segments = new URL(href).pathname.split("/").filter(Boolean); + const last = segments.at(-1); + const name = (/\.json$/i.test(last ?? "") ? segments.at(-2) : last) ?? href; + try { + return decodeURIComponent(name); + } catch { + // A bare % is legal in a path and fatal to decodeURIComponent; a raw name beats no catalog. + return name; + } +} + +/** The `child` links of an already-read document, as tree nodes. */ +function catalogChildren(document: Record, base: string): StacCatalogNode[] { + return linksOf(document.links, base) + .filter((link) => link.rel === "child") + .map( + (link): StacCatalogNode => ({ + href: link.href, + title: link.title || folderName(link.href), + kind: /\/collection\.json($|[?#])/i.test(link.href) ? "collection" : "container", + }), + ); +} + +/** The first spatial extent a collection declares, which covers the rest. */ +function collectionBbox( + document: Record, +): [number, number, number, number] | undefined { + const extent = document.extent; + if (typeof extent !== "object" || extent === null || !("spatial" in extent)) return undefined; + const spatial = extent.spatial; + if (typeof spatial !== "object" || spatial === null || !("bbox" in spatial)) return undefined; + const boxes = spatial.bbox; + const box = Array.isArray(boxes) ? boxes[0] : undefined; + if (!Array.isArray(box) || box.length < 4 || !box.every((value) => typeof value === "number")) { + return undefined; + } + return [box[0], box[1], box[box.length / 2], box[box.length / 2 + 1]]; +} + +export async function openCatalogNode( + href: string, + fetcher: FetchLike = fetch, + signal?: AbortSignal, +): Promise { + const document = await fetchJson>(href, { signal }, fetcher); + if (typeof document !== "object" || document === null || Array.isArray(document)) + throw new Error("The link did not return a STAC document"); + return { + kind: document.type === "Collection" ? "collection" : "container", + children: catalogChildren(document, href), + bbox: collectionBbox(document), + }; +} + export async function connectStac( inputUrl: string, fetcher: FetchLike = fetch, @@ -229,6 +309,9 @@ export async function connectStac( searchLink?.href ?? (isApi ? absoluteHref("search", url.endsWith("/") ? url : `${url}/`) : undefined), collections, + // An API is searched through its endpoint, and a branch of one can only be searched through + // the endpoint that branch advertises, so its hierarchy is not a way in from here. + children: isApi ? [] : catalogChildren(root, url), root, }; } @@ -341,12 +424,18 @@ export async function searchStaticStac( options: StacSearchOptions, fetcher: FetchLike = fetch, ): Promise { + // Where a search starts and what it filters by belong to the walk, not to the call: both can + // change between pages, and one accumulated list filtered two ways is worse than either. + const roots: Unread[] = options.entries?.length + ? options.entries.map((url) => ({ url })) + : [{ url: connection.url, document: connection.root }]; const walk = options.cursor ?? { items: [], - folders: [{ url: connection.url, document: connection.root }], + folders: roots, visited: new Set(), offset: 0, dropped: 0, + filters: { bbox: options.bbox, collections: options.collections, datetime: options.datetime }, }; const found: StacItem[] = []; const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); @@ -355,11 +444,12 @@ export async function searchStaticStac( const accepts = (item: StacItem): boolean => { // itemBbox flattens 3D (6-element) bboxes; item.bbox[2]/[3] would be minZ/maxX there. const bbox = itemBbox(item); - if (options.collections?.length && !options.collections.includes(item.collection ?? "")) { + const filters = walk.filters; + if (filters.collections?.length && !filters.collections.includes(item.collection ?? "")) { return false; } - if (options.bbox && !(bbox && intersects(bbox, options.bbox))) return false; - return inTime(item, options.datetime); + if (filters.bbox && !(bbox && intersects(bbox, filters.bbox))) return false; + return inTime(item, filters.datetime); }; const takeBatch = (): Pending[] => { diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts new file mode 100644 index 0000000000..7ed3c20e15 --- /dev/null +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -0,0 +1,283 @@ +import { openCatalogNode, type StacCatalogNode } from "./stac-api"; +import { el } from "../panel-dom"; + +const ROW_CLASS = "geolibre-stac-tree-row"; +const STYLE_ID = "geolibre-stac-tree-style"; + +/** + * Selection is one attribute and one rule: `aria-selected` says what is chosen and the stylesheet + * decides what that looks like. Painting a row by hand would hold the same fact in two places, + * free to disagree, and a highlight left on a row nobody picked misreports the search's scope. + */ +const CSS = ` +.${ROW_CLASS} { + display: flex; + gap: 4px; + align-items: center; + width: 100%; + padding-block: 2px; + padding-inline-end: 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; +} +.${ROW_CLASS}[aria-selected="true"] { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} +`; + +const style = { + tree: + "min-height:170px;max-height:340px;overflow:auto;resize:vertical;padding:4px;border-radius:5px;" + + "border:1px solid hsl(var(--border));background:hsl(var(--background));", + glyph: "width:10px;flex:0 0 auto;color:hsl(var(--muted-foreground));", + empty: "font-size:10px;color:hsl(var(--muted-foreground));", +} as const; + +const GLYPH = { open: "▾", leaf: "•", busy: "…" } as const; + +/** A closed folder points the way the text runs, so it mirrors with the rest of the UI. */ +function closedGlyph(): string { + return typeof document !== "undefined" && document.documentElement.dir === "rtl" ? "◂" : "▸"; +} + +/** Adds the tree's one stylesheet, once per document. */ +function ensureStyle(): void { + if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return; + const sheet = el("style"); + sheet.id = STYLE_ID; + sheet.textContent = CSS; + document.head.append(sheet); +} + +export interface CatalogTree { + element: HTMLElement; + /** Replaces the tree with a new catalog's top-level children. */ + reset: (nodes: StacCatalogNode[]) => void; + /** Documents of the collections the user has picked, as search entry points. */ + selection: () => string[]; +} + +/** One row of the tree, and the branch hanging off it. */ +interface Row { + element: HTMLButtonElement; + box: HTMLDivElement; + parent?: Row; + children: Row[]; + open: boolean; +} + +export interface CatalogTreeOptions { + labels: { empty: string; openFailed: string }; + onError: (message: string) => void; + /** A collection was double-clicked: search it, and go to it if its extent is known. */ + onActivate?: (href: string, bbox?: [number, number, number, number]) => void; + signal?: AbortSignal; + /** Reads one node. Injected so the tree can be driven without a network. */ + read?: typeof openCatalogNode; +} + +/** A catalog rendered as a tree, reading each node's children only when it is opened. */ +export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { + const { labels, onError, onActivate, signal, read = openCatalogNode } = options; + ensureStyle(); + const element = el("div"); + element.style.cssText = style.tree; + element.setAttribute("role", "tree"); + element.setAttribute("aria-multiselectable", "true"); + // Keyed by row, not by document: the same collection is often linked from two branches, and + // keying by document would let a click on one row cancel the other. + const selected = new Map(); + // A catalog the user has left must not keep writing into the tree that replaced it. + let generation = 0; + + // The tree built every row, so it keeps its own shape rather than reading it back out of the + // DOM — and the arrows can then move by parent and child instead of by selector. + const roots: Row[] = []; + + const everyRow = (within: Row[] = roots): Row[] => + within.flatMap((row) => [row, ...everyRow(row.children)]); + + /** The rows a reader can reach: a closed folder hides everything under it. */ + const reachable = (within: Row[] = roots): Row[] => + within.flatMap((row) => (row.open ? [row, ...reachable(row.children)] : [row])); + + /** One tab stop for the whole tree: a catalog of hundreds of rows is not hundreds of stops. */ + const focusRow = (row: Row | undefined): void => { + if (!row) return; + for (const other of everyRow()) other.element.tabIndex = other === row ? 0 : -1; + row.element.focus(); + }; + + const mark = (row: HTMLElement, on: boolean): void => { + row.setAttribute("aria-selected", String(on)); + }; + + /** Drops the choices inside a subtree being hidden: nothing on screen would show them. */ + const forget = (box: HTMLElement): void => { + for (const [row] of selected) { + if (!box.contains(row)) continue; + selected.delete(row); + mark(row, false); + } + }; + + const select = (href: string, row: HTMLElement, additive: boolean): void => { + // Ctrl/Cmd-click toggles, and so does clicking the one row already chosen — without it a + // touch user could never undo a choice. Clicking one of several chosen rows narrows to it. + const toggles = additive || (selected.has(row) && selected.size === 1); + if (toggles && selected.delete(row)) return mark(row, false); + if (!additive) { + for (const [other] of selected) mark(other, false); + selected.clear(); + } + selected.set(row, href); + mark(row, true); + }; + + const addNode = (node: StacCatalogNode, parent: Row | undefined, depth: number): void => { + const mine = generation; + const row = el("button"); + row.type = "button"; + row.className = ROW_CLASS; + row.style.paddingInlineStart = `${4 + depth * 12}px`; + row.setAttribute("role", "treeitem"); + row.setAttribute("aria-selected", "false"); + row.tabIndex = roots.length ? -1 : 0; + const glyph = el("span", node.kind === "collection" ? GLYPH.leaf : closedGlyph()); + glyph.style.cssText = style.glyph; + row.append(glyph, el("span", node.title)); + const childrenBox = el("div"); + childrenBox.hidden = true; + childrenBox.setAttribute("role", "group"); + (parent?.box ?? element).append(row, childrenBox); + + const self: Row = { element: row, box: childrenBox, parent, children: [], open: false }; + (parent?.children ?? roots).push(self); + + let kind = node.kind; + let loaded = false; + let busy = false; + let bbox: [number, number, number, number] | undefined; + if (kind !== "collection") row.setAttribute("aria-expanded", "false"); + + const expand = (wanted: boolean): void => { + if (!wanted) forget(childrenBox); + self.open = wanted; + childrenBox.hidden = !wanted; + row.setAttribute("aria-expanded", String(wanted)); + glyph.textContent = wanted ? GLYPH.open : closedGlyph(); + }; + + /** + * Reads what is inside the node, and chooses it if it turns out to be a collection after all. + * A link ending in `collection.json` is taken at its word and never read for children: every + * collection in the catalogs this was built against holds items, not more collections, and a + * read per row to prove that is a cost with nothing to show for it. A collection that does + * nest is still searched whole — only its shape stays out of the tree. + */ + const reveal = async (additive: boolean): Promise => { + if (busy || loaded) return; + busy = true; + glyph.textContent = GLYPH.busy; + try { + const opened = await read(node.href, fetch, signal); + // The catalog this row belongs to may have been replaced while the read was in flight. + if (mine !== generation) return; + kind = opened.kind; + loaded = true; + bbox = opened.bbox; + for (const child of opened.children) addNode(child, self, depth + 1); + if (kind === "collection") select(node.href, row, additive); + if (opened.children.length) return expand(true); + if (kind === "collection") { + glyph.textContent = GLYPH.leaf; + row.removeAttribute("aria-expanded"); + return; + } + const empty = el("div", labels.empty); + empty.style.cssText = `${style.empty}padding-inline-start:${16 + depth * 12}px;`; + childrenBox.append(empty); + expand(true); + } catch (error) { + if (mine !== generation || signal?.aborted) return; + glyph.textContent = closedGlyph(); + // The translated sentence carries the meaning; the raw text says which failure it was. + const detail = error instanceof Error ? error.message : String(error); + onError(`${labels.openFailed}: ${detail}`); + } finally { + busy = false; + } + }; + + /** What a click or Space means: choose a collection, or open a folder. */ + const activate = (additive: boolean): void => { + // Choosing a collection costs no read; only a container has to be opened to be useful. + if (kind === "collection") return select(node.href, row, additive); + if (loaded) return expand(!self.open); + void reveal(additive); + }; + + row.addEventListener("click", (event) => { + focusRow(self); + activate(event.ctrlKey || event.metaKey); + }); + + /** "Show me this one": pick the collection if it is not picked, then ask for its items. */ + const show = (): void => { + if (kind !== "collection") return; + if (!selected.has(row)) select(node.href, row, false); + onActivate?.(node.href, bbox); + }; + + // The second click of a double-click would otherwise toggle the choice back off, so the + // selection is restored before the search is asked for. + row.addEventListener("dblclick", show); + + // The arrows walk the tree and work its folders. Enter and Space are left to the button the + // row is written on, which already chooses; asking for the items takes the modifier. + row.addEventListener("keydown", (event) => { + const step = (by: number): void => { + const list = reachable(); + focusRow(list[list.indexOf(self) + by]); + }; + const steps: Record void> = { + ArrowDown: () => step(1), + ArrowUp: () => step(-1), + ArrowRight: () => { + if (kind === "collection") return; + if (!self.open) return activate(false); + focusRow(self.children[0]); + }, + ArrowLeft: () => { + if (self.open) return expand(false); + focusRow(self.parent); + }, + "Ctrl+Enter": () => (kind === "collection" ? show() : activate(false)), + Home: () => focusRow(reachable()[0]), + End: () => focusRow(reachable().at(-1)), + }; + const take = steps[event.ctrlKey || event.metaKey ? `Ctrl+${event.key}` : event.key]; + if (!take) return; + event.preventDefault(); + take(); + }); + }; + + return { + element, + reset(nodes) { + generation += 1; + element.innerHTML = ""; + selected.clear(); + roots.length = 0; + for (const node of nodes) addNode(node, undefined, 0); + }, + selection: () => [...new Set(selected.values())], + }; +} diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index 9b78e2ee6a..8a3ce5dcb6 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -5,6 +5,7 @@ import { connectStac, isVisualizableAsset, itemBbox, + openCatalogNode, searchStacApi, searchStaticStac, } from "../packages/plugins/src/plugins/stac-api"; @@ -56,6 +57,433 @@ test("connectStac discovers relative API links and collections", async () => { assert.deepEqual(calls, ["https://example.com/stac/", "https://example.com/stac/collections"]); }); +test("connectStac reads only the root of a static catalog", async () => { + const fetched: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + fetched.push(String(input)); + return jsonResponse({ + type: "Catalog", + id: "warehouse", + links: [ + { rel: "child", href: "./topics/catalog.json", title: "Serving Topics" }, + { rel: "child", href: "./maps/collection.json", title: "Geologic Maps" }, + { rel: "child", href: "./unlabelled/thing.json" }, + ], + }); + }) as typeof fetch; + + const connection = await connectStac("https://example.com/stac/catalog.json", fetcher); + assert.equal(connection.isApi, false); + assert.deepEqual(fetched, ["https://example.com/stac/catalog.json"]); + assert.deepEqual( + connection.children?.map((node) => [node.title, node.kind]), + [ + ["Serving Topics", "container"], + ["Geologic Maps", "collection"], + // No title, so the folder it sits in has to name it. + ["unlabelled", "container"], + ], + ); +}); + +test("openCatalogNode reports what a node turned out to be and what is inside it", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("collection.json")) { + return jsonResponse({ type: "Collection", id: "hazards", links: [] }); + } + return jsonResponse({ + type: "Catalog", + id: "topics", + links: [{ rel: "child", href: "./hazards/collection.json", title: "Hazards" }], + }); + }) as typeof fetch; + + const catalog = await openCatalogNode("https://example.com/stac/topics/catalog.json", fetcher); + assert.equal(catalog.kind, "container"); + assert.deepEqual( + catalog.children.map((node) => [node.title, node.href]), + [["Hazards", "https://example.com/stac/topics/hazards/collection.json"]], + ); + + const collection = await openCatalogNode("https://example.com/stac/x/collection.json", fetcher); + assert.equal(collection.kind, "collection"); + assert.deepEqual(collection.children, []); +}); + +test("searchStaticStac starts at the chosen collection instead of walking from the root", async () => { + // The root's other branch is large enough to exhaust the visit cap on its own. Walking from + // the root would spend the search there and return nothing for the collection asked for. + const bulk = Array.from({ length: 40 }, (_value, index) => ({ + rel: "child" as const, + href: `bulk/${index}.json`, + })); + const docs: Record = { + "https://example.com/stac/catalog.json": { + type: "Catalog", + id: "root", + links: [ + { rel: "child", href: "./bulk/catalog.json" }, + { rel: "child", href: "./wanted.json" }, + ], + }, + "https://example.com/stac/bulk/catalog.json": { + type: "Catalog", + id: "bulk", + links: bulk, + }, + "https://example.com/stac/wanted.json": { + type: "Collection", + id: "wanted", + links: [{ rel: "item", href: "item.json" }], + }, + "https://example.com/stac/item.json": { + type: "Feature", + id: "wanted-item", + collection: "wanted", + bbox: [0, 0, 1, 1], + geometry: null, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + }, + }; + for (const link of bulk) { + docs[`https://example.com/stac/bulk/${link.href.split("/")[1]}`] = { + type: "Catalog", + id: link.href, + links: [], + }; + } + const calls: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + return jsonResponse(docs[url]); + }) as typeof fetch; + + const result = await searchStaticStac( + { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + children: [], + root: docs["https://example.com/stac/catalog.json"] as Record, + }, + { entries: ["https://example.com/stac/wanted.json"], limit: 20 }, + fetcher, + ); + + assert.deepEqual( + result.items.map((item) => item.id), + ["wanted-item"], + ); + assert.equal( + calls.some((url) => url.includes("/bulk/")), + false, + "the unselected branch is never visited", + ); +}); + +test("connectStac reads child links only, and names them when the link does not", async () => { + const fetcher = (async () => + jsonResponse({ + type: "Catalog", + id: "warehouse", + links: [ + { rel: "self", href: "./catalog.json" }, + { rel: "root", href: "./catalog.json" }, + { rel: "parent", href: "../catalog.json" }, + { rel: "item", href: "./scene.json", title: "A scene, not a folder" }, + { rel: "child", href: "./maps/collection.json", title: "Geologic Maps" }, + // A bare % is legal in a path and fatal to decodeURIComponent. + { rel: "child", href: "./100%_coverage/catalog.json" }, + { rel: "child", href: "./UPPER/CATALOG.JSON" }, + { rel: "child", href: "./quads/" }, + ], + })) as typeof fetch; + + const connection = await connectStac("https://example.com/stac/catalog.json", fetcher); + assert.deepEqual( + connection.children?.map((node) => [node.title, node.kind]), + [ + ["Geologic Maps", "collection"], + ["100%_coverage", "container"], + ["UPPER", "container"], + ["quads", "container"], + ], + ); +}); + +test("connectStac offers no tree for an API, which is searched through its endpoint", async () => { + // NASA's CMR is an API that also lists a sub-catalog per provider, but its root search endpoint + // 404s — only each provider's own answers — so those branches cannot be searched from here. + const fetcher = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/collections")) return jsonResponse({ collections: [] }); + return jsonResponse({ + type: "Catalog", + id: "api", + conformsTo: ["https://api.stacspec.org/v1.0.0/item-search"], + links: [ + { rel: "data", href: "./collections" }, + { rel: "child", href: "./LPCLOUD/catalog.json", title: "LPCLOUD" }, + ], + }); + }) as typeof fetch; + + const connection = await connectStac("https://example.com/stac/", fetcher); + assert.equal(connection.isApi, true); + assert.deepEqual(connection.children, []); +}); + +test("openCatalogNode refuses a document that is not an object", async () => { + const fetcher = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("missing.json")) return jsonResponse(null); + if (String(input).endsWith("list.json")) return jsonResponse([1, 2]); + return jsonResponse({ status: 404 }, 404); + }) as typeof fetch; + + for (const href of ["https://example.com/missing.json", "https://example.com/list.json"]) { + await assert.rejects( + () => openCatalogNode(href, fetcher), + /did not return a STAC document/, + `${href} must not read as an empty catalog`, + ); + } + await assert.rejects(() => openCatalogNode("https://example.com/gone.json", fetcher), /404/); +}); + +test("a search keeps the collection filter it began with as later pages arrive", async () => { + // The tree's selection can change between Load more clicks; the filter must not follow it, or + // one accumulated list ends up filtered two ways. + const docs: Record = { + "https://example.com/stac/catalog.json": { + type: "Catalog", + links: Array.from({ length: 4 }, (_value, index) => ({ + rel: "item", + href: `./item-${index}.json`, + })), + }, + }; + for (let index = 0; index < 4; index += 1) { + docs[`https://example.com/stac/item-${index}.json`] = { + type: "Feature", + id: `i${index}`, + collection: index % 2 === 0 ? "a" : "b", + bbox: [0, 0, 1, 1], + geometry: null, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + }; + } + const fetcher = (async (input: RequestInfo | URL) => + jsonResponse(docs[String(input)])) as typeof fetch; + const connection = { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + root: docs["https://example.com/stac/catalog.json"] as Record, + }; + + const first = await searchStaticStac(connection, { limit: 1, collections: ["a"] }, fetcher); + assert.deepEqual( + first.items.map((item) => item.id), + ["i0"], + ); + assert.ok(first.cursor); + // The user picks a tree entry and drops the collection filter before asking for more. + const second = await searchStaticStac( + connection, + { limit: 5, cursor: first.cursor, entries: ["https://example.com/stac/other.json"] }, + fetcher, + ); + assert.deepEqual( + second.items.map((item) => item.id), + ["i2"], + "page two filters the way page one did", + ); +}); + +test("a search keeps the extent and dates it began with as later pages arrive", async () => { + // The panel re-reads its form on every Load more, so a filter typed mid-walk must not apply to + // half a list: page one set the terms. + const docs: Record = { + "https://example.com/stac/catalog.json": { + type: "Catalog", + links: Array.from({ length: 4 }, (_value, index) => ({ + rel: "item", + href: `./item-${index}.json`, + })), + }, + }; + for (let index = 0; index < 4; index += 1) { + docs[`https://example.com/stac/item-${index}.json`] = { + type: "Feature", + id: `i${index}`, + collection: "c", + bbox: index < 2 ? [0, 0, 1, 1] : [100, 40, 101, 41], + geometry: null, + properties: { datetime: index < 2 ? "2024-05-01T00:00:00Z" : "1999-01-01T00:00:00Z" }, + assets: {}, + }; + } + const fetcher = (async (input: RequestInfo | URL) => + jsonResponse(docs[String(input)])) as typeof fetch; + const connection = { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + root: docs["https://example.com/stac/catalog.json"] as Record, + }; + + const first = await searchStaticStac(connection, { limit: 1 }, fetcher); + assert.deepEqual( + first.items.map((item) => item.id), + ["i0"], + ); + const second = await searchStaticStac( + connection, + { + limit: 5, + cursor: first.cursor, + bbox: [-1, -1, 2, 2], + datetime: "2024-01-01T00:00:00Z/..", + }, + fetcher, + ); + assert.deepEqual( + second.items.map((item) => item.id), + ["i1", "i2", "i3"], + "an extent and a date range typed mid-walk do not apply to the rest of a started search", + ); +}); + +test("an untouched tree leaves the search walking the whole catalog", async () => { + // The panel always passes `entries`, empty when nothing in the tree is chosen. Reading that as + // "search nothing" would break every default search on a static catalog. + const docs: Record = { + "https://example.com/stac/catalog.json": { + type: "Catalog", + links: [{ rel: "item", href: "./only.json" }], + }, + "https://example.com/stac/only.json": { + type: "Feature", + id: "only", + collection: "c", + bbox: [0, 0, 1, 1], + geometry: null, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + }, + }; + const fetcher = (async (input: RequestInfo | URL) => + jsonResponse(docs[String(input)])) as typeof fetch; + const connection = { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + // A catalog one level deep: items at the root, nothing to put in a tree. + children: [], + root: docs["https://example.com/stac/catalog.json"] as Record, + }; + + const result = await searchStaticStac(connection, { entries: [], limit: 20 }, fetcher); + assert.deepEqual( + result.items.map((item) => item.id), + ["only"], + ); + assert.equal(result.matched, 1); +}); + +test("a link is read as a collection however its query or fragment is written", async () => { + const fetcher = (async () => + jsonResponse({ + type: "Catalog", + links: [ + { rel: "child", href: "./a/collection.json?version=2" }, + { rel: "child", href: "./b/collection.json#section" }, + { rel: "child", href: "./c/COLLECTION.JSON" }, + { rel: "child", href: "./d/catalog.json" }, + ], + })) as typeof fetch; + + const connection = await connectStac("https://example.com/stac/catalog.json", fetcher); + assert.deepEqual( + connection.children?.map((node) => node.kind), + ["collection", "collection", "collection", "container"], + ); +}); + +test("a tree selection narrows where the search starts without voiding the collection filter", async () => { + const docs: Record = { + "https://example.com/stac/landsat.json": { + type: "Collection", + id: "landsat", + links: [ + { rel: "child", href: "./l8/collection.json" }, + { rel: "child", href: "./l9/collection.json" }, + ], + }, + "https://example.com/stac/l8/collection.json": { + type: "Collection", + id: "landsat-8", + links: [{ rel: "item", href: "./scene.json" }], + }, + "https://example.com/stac/l9/collection.json": { + type: "Collection", + id: "landsat-9", + links: [{ rel: "item", href: "./scene.json" }], + }, + }; + for (const [id, path] of [ + ["L8", "l8"], + ["L9", "l9"], + ]) { + docs[`https://example.com/stac/${path}/scene.json`] = { + type: "Feature", + id, + collection: `landsat-${id === "L8" ? 8 : 9}`, + bbox: [0, 0, 1, 1], + geometry: null, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + }; + } + const fetcher = (async (input: RequestInfo | URL) => + jsonResponse(docs[String(input)])) as typeof fetch; + const connection = { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + root: { type: "Catalog", links: [] } as Record, + }; + + const both = await searchStaticStac( + connection, + { entries: ["https://example.com/stac/landsat.json"], limit: 20 }, + fetcher, + ); + assert.deepEqual(both.items.map((item) => item.id).sort(), ["L8", "L9"]); + + const narrowed = await searchStaticStac( + connection, + { + entries: ["https://example.com/stac/landsat.json"], + collections: ["landsat-9"], + limit: 20, + }, + fetcher, + ); + assert.deepEqual( + narrowed.items.map((item) => item.id), + ["L9"], + "both filters apply; neither is silently dropped", + ); +}); + test("searchStacApi sends spatial, temporal, and collection filters and follows next", async () => { let body: Record | undefined; const fetcher = (async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -270,6 +698,7 @@ test("searchStaticStac pages through a catalog holding more items than one page title: "Static", isApi: false, collections: [], + children: [], root: docs["https://example.com/stac/catalog.json"] as Record, }; @@ -420,6 +849,7 @@ test("a read that fails once is retried rather than dropped from the search", as title: "Static", isApi: false, collections: [], + children: [], root: docs["https://example.com/stac/catalog.json"] as Record, }; @@ -513,6 +943,7 @@ test("a document that never reads leaves the search without a total", async () = title: "Static", isApi: false, collections: [], + children: [], root: docs["https://example.com/stac/catalog.json"] as Record, }; diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts new file mode 100644 index 0000000000..5c52355ee9 --- /dev/null +++ b/tests/stac-catalog-tree.test.ts @@ -0,0 +1,561 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseHTML } from "linkedom"; +import { buildCatalogTree } from "../packages/plugins/src/plugins/stac-catalog-tree"; +import type { StacCatalogNode, StacOpenedNode } from "../packages/plugins/src/plugins/stac-api"; + +const LABELS = { empty: "«empty»", openFailed: "«open failed»" }; + +/** + * The tree writes styles and reads them back, so a hand-written double would only ever prove that + * the double round-trips. linkedom parses and re-serializes declarations the way a browser does. + */ +async function withDom(body: () => Promise | void): Promise { + const { document, window } = parseHTML(""); + const globals = globalThis as Record; + const saved = { document: globals.document, Event: globals.Event }; + globals.document = document; + globals.Event = window.Event; + try { + await body(); + } finally { + globals.document = saved.document; + globals.Event = saved.Event; + } +} + +function node(title: string, kind: StacCatalogNode["kind"] = "container"): StacCatalogNode { + return { href: `https://example.com/${title}.json`, title, kind }; +} + +/** Every row in document order, however deeply nested. */ +function rowsOf(tree: { element: HTMLElement }): HTMLElement[] { + return [...tree.element.querySelectorAll("[role=treeitem]")] as HTMLElement[]; +} + +/** + * Dispatches the way a browser does — fire and forget, so a second click can land while the first + * read is still outstanding. linkedom has no MouseEvent, and the tree reads only the modifiers. + */ +function click(row: HTMLElement, additive = false): void { + const event = new (globalThis as { Event: typeof Event }).Event("click", { bubbles: true }); + Object.assign(event, { ctrlKey: additive, metaKey: false }); + row.dispatchEvent(event); +} + +/** A key press the tree's own handler will see. */ +function press(row: HTMLElement, key: string, ctrlKey = false): void { + const event = new (globalThis as { Event: typeof Event }).Event("keydown", { bubbles: true }); + Object.assign(event, { key, ctrlKey, metaKey: false }); + row.dispatchEvent(event); +} + +/** Lets the handler's awaits run to completion. */ +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +/** + * What the row says it is. The stylesheet turns that into a highlight, which only a real browser + * can resolve — `e2e/stac-catalog-tree.spec.ts` is what checks the colour. + */ +function isChosen(row: HTMLElement): boolean { + return row.getAttribute("aria-selected") === "true"; +} + +test("a row that leaves the selection stops being painted as selected", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ + labels: LABELS, + onError: (message) => assert.fail(`unexpected error: ${message}`), + read: async () => assert.fail("a collection must not be opened"), + }); + tree.reset([node("Hazards", "collection"), node("Geology", "collection")]); + const [hazards, geology] = rowsOf(tree); + + click(hazards); + await settle(); + assert.equal(isChosen(hazards), true); + + // The bug this pins: the previous row kept its highlight, so several rows looked chosen + // while the search used one of them. + click(geology); + await settle(); + assert.deepEqual(tree.selection(), ["https://example.com/Geology.json"]); + assert.equal(isChosen(hazards), false, "the replaced row must lose its highlight"); + assert.equal(hazards.getAttribute("aria-selected"), "false"); + assert.equal(isChosen(geology), true); + }); +}); + +test("clicking a chosen row again clears it, which is the only way to do so by touch", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); + tree.reset([node("Hazards", "collection")]); + const [row] = rowsOf(tree); + + click(row); + await settle(); + assert.deepEqual(tree.selection(), ["https://example.com/Hazards.json"]); + + click(row); + await settle(); + assert.deepEqual(tree.selection(), []); + assert.equal(isChosen(row), false); + }); +}); + +test("Ctrl-click adds a second collection and leaves the first chosen", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); + tree.reset([node("Hazards", "collection"), node("Geology", "collection")]); + const [hazards, geology] = rowsOf(tree); + + click(hazards); + click(geology, true); + await settle(); + assert.deepEqual(tree.selection().sort(), [ + "https://example.com/Geology.json", + "https://example.com/Hazards.json", + ]); + assert.equal(isChosen(hazards), true); + assert.equal(isChosen(geology), true); + }); +}); + +test("the same collection reached down two branches is chosen once and cleared once", async () => { + await withDom(async () => { + // A shared collection is normal in STAC: two themes both link it. Keying the selection by + // document rather than by row made the second click cancel the first. + const shared: StacCatalogNode = { + href: "https://example.com/shared/collection.json", + title: "Shared", + kind: "collection", + }; + const read = async (href: string): Promise => ({ + kind: "container", + children: [shared], + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes"), node("Topics")]); + const [themes, topics] = rowsOf(tree); + + click(themes); + await settle(); + click(topics); + await settle(); + + const shares = rowsOf(tree).filter((row) => row.textContent?.includes("Shared")); + assert.equal(shares.length, 2); + + click(shares[0], true); + click(shares[1], true); + await settle(); + assert.deepEqual(tree.selection(), ["https://example.com/shared/collection.json"]); + assert.equal(isChosen(shares[0]), true); + assert.equal(isChosen(shares[1]), true); + }); +}); + +test("clicking one of several chosen rows narrows to it instead of dropping it", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); + tree.reset([node("Hazards", "collection"), node("Geology", "collection")]); + const [hazards, geology] = rowsOf(tree); + + click(hazards); + click(geology, true); + await settle(); + assert.equal(tree.selection().length, 2); + + // The row is already chosen, but so is another: a plain click means "just this one". + click(hazards); + await settle(); + assert.deepEqual(tree.selection(), ["https://example.com/Hazards.json"]); + assert.equal(isChosen(geology), false); + }); +}); + +test("collapsing a folder gives up the choices hidden inside it", async () => { + await withDom(async () => { + const read = async (): Promise => ({ + kind: "container", + children: [node("Hazards", "collection")], + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes")]); + const [themes] = rowsOf(tree); + + click(themes); + await settle(); + const [, hazards] = rowsOf(tree); + click(hazards); + await settle(); + assert.equal(tree.selection().length, 1); + + // Nothing on screen could show this row as chosen once it is hidden, and a search the user + // cannot see the scope of is worse than one that lost it. + click(themes); + await settle(); + assert.equal((themes.nextElementSibling as HTMLElement).hidden, true); + assert.deepEqual(tree.selection(), []); + assert.equal(isChosen(hazards), false); + }); +}); + +test("a catalog opened after a reset cannot take the previous catalog's selection", async () => { + await withDom(async () => { + let release: (value: StacOpenedNode) => void = () => {}; + const read = async (): Promise => + new Promise((resolve) => { + release = resolve; + }); + const errors: string[] = []; + const tree = buildCatalogTree({ + labels: LABELS, + onError: (message) => errors.push(message), + read, + }); + + tree.reset([node("Old")]); + click(rowsOf(tree)[0]); + // The user connects to a different catalog while that read is still in flight. + tree.reset([node("New", "collection")]); + release({ kind: "collection", children: [] }); + await settle(); + + assert.deepEqual(tree.selection(), [], "a stale read must not select into the new catalog"); + assert.deepEqual(errors, []); + assert.deepEqual( + rowsOf(tree).map((row) => row.textContent), + ["•New"], + ); + }); +}); + +test("a container is read once, and collapsing it hides its children without re-reading", async () => { + await withDom(async () => { + let opens = 0; + const read = async (): Promise => { + opens += 1; + return { kind: "container", children: [node("Hazards", "collection")] }; + }; + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes")]); + const [themes] = rowsOf(tree); + + click(themes); + await settle(); + const box = themes.nextElementSibling as HTMLElement; + assert.equal(opens, 1); + assert.equal(box.hidden, false); + assert.equal(themes.getAttribute("aria-expanded"), "true"); + + click(themes); + await settle(); + assert.equal(box.hidden, true, "a second click collapses"); + assert.equal(themes.getAttribute("aria-expanded"), "false"); + + click(themes); + await settle(); + assert.equal(box.hidden, false); + assert.equal(opens, 1, "the children are not read again"); + }); +}); + +test("a second click while a node is being read does not read it twice", async () => { + await withDom(async () => { + let opens = 0; + let release: (value: StacOpenedNode) => void = () => {}; + const read = async (): Promise => { + opens += 1; + return new Promise((resolve) => { + release = resolve; + }); + }; + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes")]); + const [themes] = rowsOf(tree); + + // Real dispatch is fire-and-forget, so the second click lands mid-read. + click(themes); + click(themes); + release({ kind: "container", children: [] }); + await settle(); + assert.equal(opens, 1); + }); +}); + +test("a node that cannot be read says so in the translated wording and stays openable", async () => { + await withDom(async () => { + let attempts = 0; + const errors: string[] = []; + const read = async (): Promise => { + attempts += 1; + if (attempts === 1) throw new Error("503 Service Unavailable"); + return { kind: "container", children: [node("Hazards", "collection")] }; + }; + const tree = buildCatalogTree({ labels: LABELS, onError: (m) => errors.push(m), read }); + tree.reset([node("Themes")]); + const [themes] = rowsOf(tree); + + click(themes); + await settle(); + assert.deepEqual(errors, [`${LABELS.openFailed}: 503 Service Unavailable`]); + assert.equal(themes.getAttribute("aria-expanded"), "false"); + + click(themes); + await settle(); + assert.equal(attempts, 2, "a failed read leaves the node openable"); + assert.equal(rowsOf(tree).length, 2); + }); +}); + +test("an aborted read is not reported as a failure", async () => { + await withDom(async () => { + const controller = new AbortController(); + const errors: string[] = []; + const read = async (): Promise => { + controller.abort(); + throw new DOMException("signal is aborted without reason", "AbortError"); + }; + const tree = buildCatalogTree({ + labels: LABELS, + onError: (message) => errors.push(message), + signal: controller.signal, + read, + }); + tree.reset([node("Themes")]); + + click(rowsOf(tree)[0]); + await settle(); + assert.deepEqual(errors, []); + }); +}); + +test("an empty container says so instead of looking unread", async () => { + await withDom(async () => { + const read = async (): Promise => ({ kind: "container", children: [] }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes")]); + const [themes] = rowsOf(tree); + + click(themes); + await settle(); + const box = themes.nextElementSibling as HTMLElement; + assert.equal(box.textContent, LABELS.empty); + assert.equal(box.hidden, false); + assert.equal(box.getAttribute("role"), "group"); + }); +}); + +test("a container that turns out to be a collection is selected by the same click", async () => { + await withDom(async () => { + const read = async (): Promise => ({ kind: "collection", children: [] }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Maps")]); + const [maps] = rowsOf(tree); + + click(maps); + await settle(); + assert.deepEqual(tree.selection(), ["https://example.com/Maps.json"]); + assert.equal(isChosen(maps), true); + assert.equal(maps.hasAttribute("aria-expanded"), false, "a leaf is not expandable"); + }); +}); + +test("Ctrl-click drops one collection from a multiple selection", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); + tree.reset([node("Hazards", "collection"), node("Geology", "collection")]); + const [hazards, geology] = rowsOf(tree); + + click(hazards); + click(geology, true); + await settle(); + click(geology, true); + await settle(); + assert.deepEqual(tree.selection(), ["https://example.com/Hazards.json"]); + assert.equal(isChosen(geology), false); + }); +}); + +test("double-clicking a collection asks for its items and keeps it chosen", async () => { + await withDom(async () => { + const activated: Array<[string, unknown]> = []; + const read = async (): Promise => ({ + kind: "collection", + children: [], + bbox: [-114, 37, -109, 42], + }); + const tree = buildCatalogTree({ + labels: LABELS, + onError: () => {}, + onActivate: (href, bbox) => activated.push([href, bbox]), + read, + }); + tree.reset([node("Maps")]); + const [maps] = rowsOf(tree); + + // The read that turns a container into a collection also learns its extent. + click(maps); + await settle(); + // The second click of a double-click would toggle the choice off on its own. + click(maps); + maps.dispatchEvent( + new (globalThis as { Event: typeof Event }).Event("dblclick", { bubbles: true }), + ); + await settle(); + assert.deepEqual(activated, [["https://example.com/Maps.json", [-114, 37, -109, 42]]]); + assert.deepEqual(tree.selection(), ["https://example.com/Maps.json"]); + assert.equal(isChosen(maps), true); + }); +}); + +test("a folder says when it is reading, and points the way the text runs", async () => { + await withDom(async () => { + let release: (value: StacOpenedNode) => void = () => {}; + const read = async (): Promise => + new Promise((resolve) => { + release = resolve; + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes")]); + const [themes] = rowsOf(tree); + const glyph = (): string => (themes.firstElementChild as HTMLElement).textContent ?? ""; + assert.equal(glyph(), "▸"); + + click(themes); + await settle(); + assert.equal(glyph(), "…", "a read in flight says so"); + release({ kind: "container", children: [node("Hazards", "collection")] }); + await settle(); + assert.equal(glyph(), "▾"); + + click(themes); + await settle(); + assert.equal(glyph(), "▸"); + + // Right-to-left locales mirror the whole UI, so a closed folder must point the other way. + document.documentElement.dir = "rtl"; + const mirrored = buildCatalogTree({ labels: LABELS, onError: () => {} }); + mirrored.reset([node("Themes")]); + assert.equal((rowsOf(mirrored)[0].firstElementChild as HTMLElement).textContent, "◂"); + document.documentElement.dir = ""; + }); +}); + +test("the arrows move the tree's single tab stop, and work its folders", async () => { + await withDom(async () => { + const read = async (): Promise => ({ + kind: "container", + children: [node("Hazards", "collection"), node("Water", "collection")], + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes"), node("Topics")]); + const [themes, topics] = rowsOf(tree); + // linkedom reports every `tabIndex` as -1 and has no `activeElement`, so the attribute is what + // can be judged here; `e2e/stac-catalog-tree.spec.ts` checks that focus really follows. + const stops = (): Array => + rowsOf(tree).map((row) => row.getAttribute("tabindex")); + + // A catalog can hold hundreds of rows; tabbing past each one is not navigation. + assert.deepEqual(stops(), ["0", "-1"]); + + press(themes, "ArrowDown"); + assert.deepEqual(stops(), ["-1", "0"]); + press(topics, "ArrowUp"); + assert.deepEqual(stops(), ["0", "-1"]); + + // Right opens a closed folder, then steps into what it revealed; left closes it again. + press(themes, "ArrowRight"); + await settle(); + assert.equal(themes.getAttribute("aria-expanded"), "true"); + press(themes, "ArrowRight"); + assert.deepEqual(stops(), ["-1", "0", "-1", "-1"], "the first child takes the tab stop"); + + press(themes, "ArrowLeft"); + assert.equal(themes.getAttribute("aria-expanded"), "false"); + press(themes, "End"); + assert.deepEqual( + rowsOf(tree) + .filter((row) => row.getAttribute("tabindex") === "0") + .map((row) => row.textContent), + ["▸Topics"], + "End lands on the last row that is not inside a closed folder", + ); + }); +}); + +test("Ctrl+Enter asks for a collection's items, the way a double-click does", async () => { + await withDom(async () => { + const activated: string[] = []; + const tree = buildCatalogTree({ + labels: LABELS, + onError: () => {}, + onActivate: (href) => activated.push(href), + }); + tree.reset([node("Hazards", "collection"), node("Themes")]); + const [hazards, themes] = rowsOf(tree); + + // Enter and Space are the button's own, and choose the row; the browser turns them into the + // click this tree already handles, so the tree must leave them alone. + press(hazards, "Enter"); + await settle(); + assert.deepEqual(activated, []); + + // Nothing chosen yet: Ctrl+Enter chooses and asks in one press, so a keyboard user is not + // left with double-click as the only way to search a collection. + press(hazards, "Enter", true); + await settle(); + assert.deepEqual(activated, ["https://example.com/Hazards.json"]); + assert.deepEqual(tree.selection(), ["https://example.com/Hazards.json"]); + + press(hazards, "Enter", true); + await settle(); + assert.equal(activated.length, 2, "asking twice is asking again, not undoing"); + assert.deepEqual(tree.selection(), ["https://example.com/Hazards.json"]); + + // A folder has no items of its own, so it opens instead. + press(themes, "Enter", true); + await settle(); + assert.equal(activated.length, 2); + }); +}); + +test("reset drops the previous catalog's rows and selection", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); + tree.reset([node("Hazards", "collection")]); + click(rowsOf(tree)[0]); + await settle(); + assert.equal(tree.selection().length, 1); + + tree.reset([node("Water", "collection")]); + assert.deepEqual(tree.selection(), []); + assert.deepEqual( + rowsOf(tree).map((row) => row.textContent), + ["•Water"], + ); + }); +}); + +test("the tree carries the roles and indentation a nested list needs", async () => { + await withDom(async () => { + const read = async (): Promise => ({ + kind: "container", + children: [node("Hazards", "collection")], + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Themes")]); + assert.equal(tree.element.getAttribute("role"), "tree"); + assert.equal(tree.element.getAttribute("aria-multiselectable"), "true"); + + const [themes] = rowsOf(tree); + click(themes); + await settle(); + const [, hazards] = rowsOf(tree); + // Depth has to read as depth without a physical direction, so right-to-left locales mirror. + assert.match(hazards.style.cssText, /padding-inline-start/); + assert.doesNotMatch(hazards.style.cssText, /padding-left/); + assert.equal((themes.nextElementSibling as HTMLElement).getAttribute("role"), "group"); + }); +}); From 1592b273aab8aafa7d652647ee49004f4584b8f4 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 14:09:18 -0600 Subject: [PATCH 02/19] test(stac): cover the panel wiring, the extent lookup and the entries a search starts from --- e2e/stac-api-panel.spec.ts | 158 +++++++++++++++++++++++ e2e/stac-catalog-tree.spec.ts | 52 ++++++++ packages/plugins/src/plugins/stac-api.ts | 7 +- tests/stac-api.test.ts | 109 ++++++++++++++++ tests/stac-catalog-tree.test.ts | 17 +++ 5 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 e2e/stac-api-panel.spec.ts diff --git a/e2e/stac-api-panel.spec.ts b/e2e/stac-api-panel.spec.ts new file mode 100644 index 0000000000..a7c940059a --- /dev/null +++ b/e2e/stac-api-panel.spec.ts @@ -0,0 +1,158 @@ +import { expect, test, type Page } from "@playwright/test"; +import { waitForMap } from "./helpers"; + +// `maplibre-stac.ts` builds its panel by hand and exports nothing to call, so the wiring between +// the catalog tree, the collection list and the search only exists here. An API is the half that +// has no tree at all: it answers item search itself, and offering a tree of its hierarchy would +// promise a way in that its endpoint does not honour. +const API = "https://api.stac.test/v1"; + +const COLLECTIONS = [ + { id: "sentinel-2", title: "Sentinel-2 L2A", extent: { spatial: { bbox: [[4, 50, 6, 52]] } } }, + { id: "landsat-9", title: "Landsat 9", extent: { spatial: { bbox: [[-114, 37, -109, 42]] } } }, +]; + +function item(id: string, collection: string): Record { + return { + type: "Feature", + stac_version: "1.0.0", + id, + collection, + bbox: [4, 50, 6, 52], + geometry: { + type: "Polygon", + coordinates: [ + [ + [4, 50], + [6, 50], + [6, 52], + [4, 52], + [4, 50], + ], + ], + }, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + links: [], + }; +} + +/** A STAC API that answers item search, and a hierarchy underneath it that must not be offered. */ +async function serveApi(page: Page, searches: string[]): Promise { + await page.route("https://api.stac.test/**", async (route) => { + const request = route.request(); + const url = request.url(); + const json = (body: unknown) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); + + if (url.endsWith("/collections")) return json({ collections: COLLECTIONS }); + if (url.includes("/search")) { + const asked = request.postDataJSON() ?? {}; + searches.push(JSON.stringify(asked.collections ?? [])); + const collection = asked.collections?.[0] ?? "sentinel-2"; + return json({ + type: "FeatureCollection", + features: [item(`${collection}-1`, collection), item(`${collection}-2`, collection)], + numberMatched: 2, + links: [], + }); + } + return json({ + type: "Catalog", + id: "api", + title: "E2E STAC API", + conformsTo: [ + "https://api.stacspec.org/v1.0.0/core", + "https://api.stacspec.org/v1.0.0/item-search", + ], + links: [ + { rel: "data", href: `${API}/collections` }, + { rel: "search", href: `${API}/search`, method: "POST" }, + // An API may also advertise a hierarchy. It is not a way in: only the endpoint is. + { rel: "child", href: `${API}/providers/ESA`, title: "ESA" }, + ], + }); + }); +} + +async function connect(page: Page, url: string): Promise { + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + await page.getByRole("menuitem", { name: "Web Services" }).click(); + await page.getByRole("menuitem", { name: "STAC Catalogs" }).click(); + await page.getByPlaceholder("https://example.org/stac/").fill(url); + await page.getByRole("button", { name: "Connect", exact: true }).click(); +} + +test("an API is offered as a collection list, never as a tree", async ({ page }) => { + const searches: string[] = []; + await serveApi(page, searches); + await waitForMap(page); + await connect(page, API); + + await expect(page.getByText("E2E STAC API")).toBeVisible(); + const list = page.getByRole("listbox"); + await expect(list).toBeVisible(); + await expect(list.getByRole("option", { name: "Sentinel-2 L2A" })).toBeVisible(); + + // The catalog advertises a child link; offering it would hand the user a branch this panel + // cannot search, because the branch answers on its own endpoint rather than this one. + await expect(page.getByRole("tree")).toBeHidden(); + await expect(page.getByRole("treeitem")).toHaveCount(0); +}); + +test("double-clicking a collection in the list searches it and moves the map", async ({ page }) => { + const searches: string[] = []; + await serveApi(page, searches); + await waitForMap(page); + await connect(page, API); + + await page.getByLabel("Limit search to the current map extent").uncheck(); + // The status bar's own reading of where the map is, so this asserts the view moved rather than + // that some text somewhere changed. + const view = async (): Promise => { + const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; + return /BBox:[^A-Z]*/.exec(text)?.[0] ?? ""; + }; + const before = await view(); + const landsat = page.getByRole("option", { name: "Landsat 9" }); + await landsat.click(); + await landsat.dblclick(); + + // The same gesture as in the tree, and it must reach the search on its own rather than leaving + // the user to find the button. + await expect(page.getByText(/Showing \d+ of \d+ items\./)).toBeVisible(); + expect(searches.at(-1)).toBe(JSON.stringify(["landsat-9"])); + + await expect.poll(async () => await view(), { timeout: 10_000 }).not.toBe(before); +}); + +test("connecting to an API after a static catalog clears the tree", async ({ page }) => { + const searches: string[] = []; + await serveApi(page, searches); + await page.route("https://static.stac.test/**", async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + type: "Catalog", + id: "static", + title: "E2E Static", + links: [{ rel: "child", href: "./hazards/collection.json", title: "Hazards" }], + }), + }), + ); + await waitForMap(page); + await connect(page, "https://static.stac.test/catalog.json"); + + const hazards = page.getByRole("treeitem", { name: "Hazards" }); + await expect(hazards).toBeVisible(); + await hazards.click(); + await expect(hazards).toHaveAttribute("aria-selected", "true"); + + // A catalog the user has left must not leave its rows, or its selection, behind. + await page.getByPlaceholder("https://example.org/stac/").fill(API); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByText("E2E STAC API")).toBeVisible(); + await expect(page.getByRole("tree")).toBeHidden(); + await expect(page.getByRole("treeitem")).toHaveCount(0); +}); diff --git a/e2e/stac-catalog-tree.spec.ts b/e2e/stac-catalog-tree.spec.ts index ffc0d6e2bb..5bf3f9665c 100644 --- a/e2e/stac-catalog-tree.spec.ts +++ b/e2e/stac-catalog-tree.spec.ts @@ -24,6 +24,34 @@ const DOCUMENTS: Record = { id: "topics", links: [{ rel: "child", href: "./water/collection.json", title: "Water" }], }, + "https://stac.test/hazards/collection.json": { + type: "Collection", + id: "hazards", + extent: { spatial: { bbox: [[-114, 37, -109, 42]] } }, + links: [{ rel: "item", href: "./slide.json" }], + }, + "https://stac.test/hazards/slide.json": { + type: "Feature", + stac_version: "1.0.0", + id: "landslide", + collection: "hazards", + bbox: [-113, 38, -112, 39], + geometry: { + type: "Polygon", + coordinates: [ + [ + [-113, 38], + [-112, 38], + [-112, 39], + [-113, 39], + [-113, 38], + ], + ], + }, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + links: [], + }, }; /** Serves the fixture catalog, so the suite needs no network and no third-party catalog. */ @@ -177,3 +205,27 @@ test("the tree is one tab stop, and the arrows move within it", async ({ page }) await page.keyboard.press("Enter"); await expect(water).toHaveAttribute("aria-selected", "true"); }); + +test("Ctrl+Enter on a collection searches it and takes the map to it", async ({ page }) => { + await serveCatalog(page); + await waitForMap(page); + await openStacPanel(page); + await page.getByLabel("Limit search to the current map extent").uncheck(); + + const view = async (): Promise => { + const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; + return /BBox:[^A-Z]*/.exec(text)?.[0] ?? ""; + }; + const before = await view(); + expect(before).toMatch(/BBox:/); + + // The collection was only guessed from its link, so its extent is not known until the search + // asks for it — the map still has to end up there. + const hazards = page.getByRole("treeitem", { name: "Hazards" }); + await hazards.focus(); + await page.keyboard.press("Control+Enter"); + + await expect(page.getByText("Showing 1 of 1 items.")).toBeVisible({ timeout: 15_000 }); + await expect(hazards).toHaveAttribute("aria-selected", "true"); + await expect.poll(view, { timeout: 15_000 }).not.toBe(before); +}); diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index c7f03a89ba..1f37124ec7 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -426,9 +426,10 @@ export async function searchStaticStac( ): Promise { // Where a search starts and what it filters by belong to the walk, not to the call: both can // change between pages, and one accumulated list filtered two ways is worse than either. - const roots: Unread[] = options.entries?.length - ? options.entries.map((url) => ({ url })) - : [{ url: connection.url, document: connection.root }]; + const roots: Unread[] = (options.entries?.length ? options.entries : [connection.url]).map( + // The root is already in hand, so a chosen entry that is the root costs no read. + (url) => (url === connection.url ? { url, document: connection.root } : { url }), + ); const walk = options.cursor ?? { items: [], folders: roots, diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index 8a3ce5dcb6..05b4e7adec 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -252,6 +252,115 @@ test("openCatalogNode refuses a document that is not an object", async () => { await assert.rejects(() => openCatalogNode("https://example.com/gone.json", fetcher), /404/); }); +test("several chosen collections are searched together, and the root is not read twice", async () => { + const docs: Record = { + "https://example.com/stac/catalog.json": { + type: "Catalog", + links: [{ rel: "item", href: "./root-item.json" }], + }, + "https://example.com/stac/a.json": { + type: "Collection", + id: "a", + links: [{ rel: "item", href: "./a-item.json" }], + }, + "https://example.com/stac/b.json": { + type: "Collection", + id: "b", + links: [{ rel: "item", href: "./b-item.json" }], + }, + }; + for (const id of ["root-item", "a-item", "b-item"]) { + docs[`https://example.com/stac/${id}.json`] = { + type: "Feature", + id, + collection: "c", + bbox: [0, 0, 1, 1], + geometry: null, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + }; + } + const reads: string[] = []; + const fetcher = (async (input: RequestInfo | URL) => { + reads.push(String(input)); + return jsonResponse(docs[String(input)]); + }) as typeof fetch; + const connection = { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + root: docs["https://example.com/stac/catalog.json"] as Record, + }; + + const result = await searchStaticStac( + connection, + { + // The root is one of the chosen entries: the caller already has that document, so asking + // the network for it again would be a read spent on something already in hand. + entries: [ + "https://example.com/stac/a.json", + "https://example.com/stac/b.json", + connection.url, + ], + limit: 20, + }, + fetcher, + ); + + assert.deepEqual(result.items.map((item) => item.id).sort(), ["a-item", "b-item", "root-item"]); + assert.equal( + reads.filter((url) => url === connection.url).length, + 0, + "the root came from the connection, not from a second read", + ); +}); + +test("openCatalogNode reports a collection's extent, and ignores a malformed one", async () => { + const extents: Record = { + good: { spatial: { bbox: [[-114, 37, -109, 42]] } }, + // A 3D extent carries six numbers; the map only wants the four that are horizontal. + deep: { spatial: { bbox: [[-114, 37, 0, -109, 42, 2000]] } }, + empty: { spatial: { bbox: [] } }, + words: { spatial: { bbox: [["west", "south", "east", "north"]] } }, + short: { spatial: { bbox: [[-114, 37]] } }, + temporalOnly: { temporal: { interval: [["2024-01-01T00:00:00Z", null]] } }, + notAnObject: "everywhere", + }; + const fetcher = (async (input: RequestInfo | URL) => { + const key = new URL(String(input)).pathname.slice(1).replace(".json", ""); + return jsonResponse({ type: "Collection", id: key, links: [], extent: extents[key] }); + }) as typeof fetch; + + const bboxOf = async (key: string) => + (await openCatalogNode(`https://example.com/${key}.json`, fetcher)).bbox; + + assert.deepEqual(await bboxOf("good"), [-114, 37, -109, 42]); + assert.deepEqual(await bboxOf("deep"), [-114, 37, -109, 42]); + for (const key of ["empty", "words", "short", "temporalOnly", "notAnObject"]) { + assert.equal(await bboxOf(key), undefined, `${key} is not an extent the map can be sent to`); + } +}); + +test("openCatalogNode gives up when the search that asked for it is called off", async () => { + const controller = new AbortController(); + let seen: AbortSignal | undefined; + const fetcher = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seen = init?.signal ?? undefined; + if (seen?.aborted) throw new DOMException("aborted", "AbortError"); + return jsonResponse({ type: "Catalog", links: [] }); + }) as typeof fetch; + + await openCatalogNode("https://example.com/stac/catalog.json", fetcher, controller.signal); + assert.equal(seen, controller.signal, "the caller's signal reaches the request"); + + controller.abort(); + await assert.rejects( + () => openCatalogNode("https://example.com/stac/catalog.json", fetcher, controller.signal), + /abort/i, + ); +}); + test("a search keeps the collection filter it began with as later pages arrive", async () => { // The tree's selection can change between Load more clicks; the filter must not follow it, or // one accumulated list ends up filtered two ways. diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index 5c52355ee9..aab27f90f9 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -309,6 +309,23 @@ test("a node that cannot be read says so in the translated wording and stays ope }); }); +test("a read that fails with something other than an Error still says what happened", async () => { + await withDom(async () => { + const errors: string[] = []; + // Not everything a fetch layer throws is an Error: a rejected string or a plain object would + // otherwise reach the panel as an empty reason. + const read = async (): Promise => { + throw "gateway said no"; + }; + const tree = buildCatalogTree({ labels: LABELS, onError: (m) => errors.push(m), read }); + tree.reset([node("Themes")]); + + click(rowsOf(tree)[0]); + await settle(); + assert.deepEqual(errors, [`${LABELS.openFailed}: gateway said no`]); + }); +}); + test("an aborted read is not reported as a failure", async () => { await withDom(async () => { const controller = new AbortController(); From 5f709b92acd71dbe7e32dab5b056f4f58b3a8dc1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:42:12 +0000 Subject: [PATCH 03/19] style: auto-format (ruff + oxfmt) [pre-commit.ci] --- packages/plugins/src/plugins/maplibre-stac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index 9b3d45f2de..7c545aecb9 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -942,7 +942,7 @@ function buildPanel(container: HTMLElement): () => void { loadMore.disabled = false; } } - }; + } catalogSearch.input.addEventListener("input", renderCatalogs); catalogSelect.addEventListener("change", () => { From 015b3eef73242fc1d00b4fd64d64177b5fdd3584 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 15:24:15 -0600 Subject: [PATCH 04/19] fix(stac): refuse a bounding box with no middle, and flatten every one in one place --- packages/plugins/src/plugins/maplibre-stac.ts | 7 ++--- packages/plugins/src/plugins/stac-api.ts | 28 +++++++++---------- tests/stac-api.test.ts | 5 +++- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index 7c545aecb9..aa67c4e863 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -5,6 +5,7 @@ import type { GeoJSONSource, MapMouseEvent, Map as MapLibreMap } from "maplibre- import type { GeoLibreAppAPI, GeoLibreCogLayerOptions, GeoLibrePlugin } from "../types"; import { connectStac, + horizontalBbox, isVisualizableAsset, itemBbox, loadStacIndex, @@ -878,11 +879,9 @@ function buildPanel(container: HTMLElement): () => void { collectionSelect.addEventListener("dblclick", () => { const chosen = collectionSelect.selectedOptions[0]?.value; const extent = connection?.collections.find((collection) => collection.id === chosen)?.extent; - const box = extent?.spatial?.bbox?.[0]; + const box = horizontalBbox(extent?.spatial?.bbox?.[0]); void runSearch(false); - if (box && box.length >= 4) { - appRef?.fitBounds?.([box[0], box[1], box[box.length / 2], box[box.length / 2 + 1]]); - } + if (box) appRef?.fitBounds?.(box); }); /** The tree asked for a collection: search it, and send the map to it. */ diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 1f37124ec7..88150c4568 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -234,6 +234,18 @@ function catalogChildren(document: Record, base: string): StacC ); } +/** + * The horizontal corners of a STAC bounding box, which carries elevation in its middle when it + * has any: four numbers or six, never an odd count — half of five is not an index. + */ +export function horizontalBbox(values: unknown): [number, number, number, number] | undefined { + if (!Array.isArray(values) || values.length < 4 || values.length % 2 !== 0) return undefined; + if (!values.every((value) => typeof value === "number" && Number.isFinite(value))) + return undefined; + const half = values.length / 2; + return [values[0], values[1], values[half], values[half + 1]]; +} + /** The first spatial extent a collection declares, which covers the rest. */ function collectionBbox( document: Record, @@ -243,11 +255,7 @@ function collectionBbox( const spatial = extent.spatial; if (typeof spatial !== "object" || spatial === null || !("bbox" in spatial)) return undefined; const boxes = spatial.bbox; - const box = Array.isArray(boxes) ? boxes[0] : undefined; - if (!Array.isArray(box) || box.length < 4 || !box.every((value) => typeof value === "number")) { - return undefined; - } - return [box[0], box[1], box[box.length / 2], box[box.length / 2 + 1]]; + return horizontalBbox(Array.isArray(boxes) ? boxes[0] : undefined); } export async function openCatalogNode( @@ -526,15 +534,7 @@ export async function searchStaticStac( } export function itemBbox(item: StacItem): [number, number, number, number] | undefined { - if (item.bbox?.length && item.bbox.length >= 4) { - return [ - item.bbox[0], - item.bbox[1], - item.bbox[item.bbox.length / 2], - item.bbox[item.bbox.length / 2 + 1], - ]; - } - return undefined; + return horizontalBbox(item.bbox); } export function isVisualizableAsset(asset: StacAsset): boolean { diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index 05b4e7adec..e43c58556b 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -324,6 +324,9 @@ test("openCatalogNode reports a collection's extent, and ignores a malformed one empty: { spatial: { bbox: [] } }, words: { spatial: { bbox: [["west", "south", "east", "north"]] } }, short: { spatial: { bbox: [[-114, 37]] } }, + // Half of five is not an index: the middle of an odd box is a coordinate that does not exist. + odd: { spatial: { bbox: [[-114, 37, 0, -109, 42]] } }, + infinite: { spatial: { bbox: [[-114, 37, Number.POSITIVE_INFINITY, 42]] } }, temporalOnly: { temporal: { interval: [["2024-01-01T00:00:00Z", null]] } }, notAnObject: "everywhere", }; @@ -337,7 +340,7 @@ test("openCatalogNode reports a collection's extent, and ignores a malformed one assert.deepEqual(await bboxOf("good"), [-114, 37, -109, 42]); assert.deepEqual(await bboxOf("deep"), [-114, 37, -109, 42]); - for (const key of ["empty", "words", "short", "temporalOnly", "notAnObject"]) { + for (const key of ["empty", "words", "short", "odd", "infinite", "temporalOnly", "notAnObject"]) { assert.equal(await bboxOf(key), undefined, `${key} is not an extent the map can be sent to`); } }); From 22f8bae4eddd0fc93d2ddb190d2d4795aee18c2f Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 15:24:15 -0600 Subject: [PATCH 05/19] fix(stac): name the group a tree row opens and how deep the row sits --- packages/plugins/src/plugins/stac-catalog-tree.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index 7ed3c20e15..cd6d9f504b 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -95,6 +95,8 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const selected = new Map(); // A catalog the user has left must not keep writing into the tree that replaced it. let generation = 0; + // Ties each row to the group it opens, which the markup cannot: the group is its sibling. + let rowCount = 0; // The tree built every row, so it keeps its own shape rather than reading it back out of the // DOM — and the arrows can then move by parent and child instead of by selector. @@ -148,6 +150,7 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { row.style.paddingInlineStart = `${4 + depth * 12}px`; row.setAttribute("role", "treeitem"); row.setAttribute("aria-selected", "false"); + row.setAttribute("aria-level", String(depth + 1)); row.tabIndex = roots.length ? -1 : 0; const glyph = el("span", node.kind === "collection" ? GLYPH.leaf : closedGlyph()); glyph.style.cssText = style.glyph; @@ -155,6 +158,9 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const childrenBox = el("div"); childrenBox.hidden = true; childrenBox.setAttribute("role", "group"); + rowCount += 1; + childrenBox.id = `${ROW_CLASS}-group-${rowCount}`; + row.setAttribute("aria-owns", childrenBox.id); (parent?.box ?? element).append(row, childrenBox); const self: Row = { element: row, box: childrenBox, parent, children: [], open: false }; From 41c0cdd8122667950469021ba0cffc733e630f88 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 15:24:15 -0600 Subject: [PATCH 06/19] test(stac): pin the extent a search flies to, modifier clicks, and folders read only when opened --- e2e/stac-api-panel.spec.ts | 14 ++++++ e2e/stac-catalog-tree.spec.ts | 86 ++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/e2e/stac-api-panel.spec.ts b/e2e/stac-api-panel.spec.ts index a7c940059a..62acf55dbf 100644 --- a/e2e/stac-api-panel.spec.ts +++ b/e2e/stac-api-panel.spec.ts @@ -113,6 +113,11 @@ test("double-clicking a collection in the list searches it and moves the map", a const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; return /BBox:[^A-Z]*/.exec(text)?.[0] ?? ""; }; + const bounds = async (): Promise => { + const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; + const found = /BBox: (-?[\d.]+), (-?[\d.]+), (-?[\d.]+), (-?[\d.]+)/.exec(text); + return found ? found.slice(1, 5).map(Number) : []; + }; const before = await view(); const landsat = page.getByRole("option", { name: "Landsat 9" }); await landsat.click(); @@ -124,6 +129,15 @@ test("double-clicking a collection in the list searches it and moves the map", a expect(searches.at(-1)).toBe(JSON.stringify(["landsat-9"])); await expect.poll(async () => await view(), { timeout: 10_000 }).not.toBe(before); + + // Landsat's extent, not the items': the fixture returns items over Belgium precisely so a fit + // to the results would fail this. + const [west, south, east, north] = await bounds(); + expect(west).toBeLessThanOrEqual(-114); + expect(east).toBeGreaterThanOrEqual(-109); + expect(south).toBeLessThanOrEqual(37); + expect(north).toBeGreaterThanOrEqual(42); + expect(east - west).toBeLessThan(60); }); test("connecting to an API after a static catalog clears the tree", async ({ page }) => { diff --git a/e2e/stac-catalog-tree.spec.ts b/e2e/stac-catalog-tree.spec.ts index 5bf3f9665c..4bf53e70b4 100644 --- a/e2e/stac-catalog-tree.spec.ts +++ b/e2e/stac-catalog-tree.spec.ts @@ -24,6 +24,34 @@ const DOCUMENTS: Record = { id: "topics", links: [{ rel: "child", href: "./water/collection.json", title: "Water" }], }, + "https://stac.test/geology/collection.json": { + type: "Collection", + id: "geology", + extent: { spatial: { bbox: [[-112, 39, -111, 40]] } }, + links: [{ rel: "item", href: "./outcrop.json" }], + }, + "https://stac.test/geology/outcrop.json": { + type: "Feature", + stac_version: "1.0.0", + id: "outcrop", + collection: "geology", + bbox: [-112, 39, -111, 40], + geometry: { + type: "Polygon", + coordinates: [ + [ + [-112, 39], + [-111, 39], + [-111, 40], + [-112, 40], + [-112, 39], + ], + ], + }, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + links: [], + }, "https://stac.test/hazards/collection.json": { type: "Collection", id: "hazards", @@ -55,8 +83,9 @@ const DOCUMENTS: Record = { }; /** Serves the fixture catalog, so the suite needs no network and no third-party catalog. */ -async function serveCatalog(page: Page): Promise { +async function serveCatalog(page: Page, asked: string[] = []): Promise { await page.route("https://stac.test/**", async (route) => { + asked.push(route.request().url()); const document = DOCUMENTS[route.request().url()]; if (!document) { await route.fulfill({ status: 404, body: "not found" }); @@ -81,6 +110,13 @@ async function openStacPanel(page: Page): Promise { const backgroundOf = (page: Page, name: string) => page.getByRole("treeitem", { name }).evaluate((row) => getComputedStyle(row).backgroundColor); +/** The map's own bounds, as the status bar reports them: west, south, east, north. */ +async function mapBounds(page: Page): Promise { + const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; + const found = /BBox: (-?[\d.]+), (-?[\d.]+), (-?[\d.]+), (-?[\d.]+)/.exec(text); + return found ? found.slice(1, 5).map(Number) : []; +} + test("the tree paints the selection, and lets go of it", async ({ page }) => { await serveCatalog(page); await waitForMap(page); @@ -228,4 +264,52 @@ test("Ctrl+Enter on a collection searches it and takes the map to it", async ({ await expect(page.getByText("Showing 1 of 1 items.")).toBeVisible({ timeout: 15_000 }); await expect(hazards).toHaveAttribute("aria-selected", "true"); await expect.poll(view, { timeout: 15_000 }).not.toBe(before); + + // The collection's extent, not the item's: the item sits at -113..-112, so a fit to the item + // would pass a "the view moved" check while missing what was asked for. + const [west, south, east, north] = await mapBounds(page); + expect(west).toBeLessThanOrEqual(-114); + expect(east).toBeGreaterThanOrEqual(-109); + expect(south).toBeLessThanOrEqual(37); + expect(north).toBeGreaterThanOrEqual(42); + expect(east - west).toBeLessThan(30); +}); + +test("Ctrl-click adds a second collection, and Meta+Enter searches like Ctrl does", async ({ + page, +}) => { + await serveCatalog(page); + await waitForMap(page); + await openStacPanel(page); + await page.getByLabel("Limit search to the current map extent").uncheck(); + + const hazards = page.getByRole("treeitem", { name: "Hazards" }); + const geology = page.getByRole("treeitem", { name: "Geology" }); + await hazards.click(); + await geology.click({ modifiers: ["ControlOrMeta"] }); + + // Both stay chosen: the modifier adds rather than replaces. + await expect(hazards).toHaveAttribute("aria-selected", "true"); + await expect(geology).toHaveAttribute("aria-selected", "true"); + + // Ctrl/Cmd+Enter asks for both, since both are chosen. + await hazards.focus(); + await page.keyboard.press("ControlOrMeta+Enter"); + await expect(page.getByText("Showing 2 of 2 items.")).toBeVisible({ timeout: 15_000 }); +}); + +test("a folder is not read until it is opened", async ({ page }) => { + const asked: string[] = []; + await serveCatalog(page, asked); + await waitForMap(page); + await openStacPanel(page); + + const topics = page.getByRole("treeitem", { name: "Topics" }); + await expect(topics).toBeVisible(); + // Connecting reads the root and nothing else; an eager walk would have this already. + expect(asked.filter((url) => url.includes("topics/catalog.json"))).toHaveLength(0); + + await topics.click(); + await expect(page.getByRole("treeitem", { name: "Water" })).toBeVisible(); + expect(asked.filter((url) => url.includes("topics/catalog.json"))).toHaveLength(1); }); From 31eaf2c435203af251943ceacbabb475d4286165 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 15:44:17 -0600 Subject: [PATCH 07/19] fix(stac): drop a collection extent that lands after the user has asked for another --- e2e/stac-catalog-tree.spec.ts | 29 +++++++++++++++++-- packages/plugins/src/plugins/maplibre-stac.ts | 7 ++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/e2e/stac-catalog-tree.spec.ts b/e2e/stac-catalog-tree.spec.ts index 4bf53e70b4..e81fc5dcae 100644 --- a/e2e/stac-catalog-tree.spec.ts +++ b/e2e/stac-catalog-tree.spec.ts @@ -83,9 +83,12 @@ const DOCUMENTS: Record = { }; /** Serves the fixture catalog, so the suite needs no network and no third-party catalog. */ -async function serveCatalog(page: Page, asked: string[] = []): Promise { +async function serveCatalog(page: Page, asked: string[] = [], slow?: string): Promise { await page.route("https://stac.test/**", async (route) => { - asked.push(route.request().url()); + const url = route.request().url(); + asked.push(url); + // A document that answers late, so a race can be staged rather than hoped for. + if (slow && url.includes(slow)) await new Promise((resolve) => setTimeout(resolve, 2500)); const document = DOCUMENTS[route.request().url()]; if (!document) { await route.fulfill({ status: 404, body: "not found" }); @@ -313,3 +316,25 @@ test("a folder is not read until it is opened", async ({ page }) => { await expect(page.getByRole("treeitem", { name: "Water" })).toBeVisible(); expect(asked.filter((url) => url.includes("topics/catalog.json"))).toHaveLength(1); }); + +test("asking for a second collection wins, however slowly the first one answers", async ({ + page, +}) => { + await serveCatalog(page, [], "hazards/collection.json"); + await waitForMap(page); + await openStacPanel(page); + await page.getByLabel("Limit search to the current map extent").uncheck(); + + // Hazards spans -114..-109; Geology sits inside it at -112..-111. Hazards' extent arrives late, + // so a fit that ignores which search it belongs to would drag the map back out to the wider box. + await page.getByRole("treeitem", { name: "Hazards" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + await page.getByRole("treeitem", { name: "Geology" }).click(); + await page.keyboard.press("ControlOrMeta+Enter"); + + await expect(page.getByText("Showing 1 of 1 items.")).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(4000); + const [west, , east] = await mapBounds(page); + expect(east - west).toBeLessThan(4); + expect(west).toBeGreaterThan(-114); +}); diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index aa67c4e863..d1fd022be6 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -887,10 +887,15 @@ function buildPanel(container: HTMLElement): () => void { /** The tree asked for a collection: search it, and send the map to it. */ function showCollection(href: string, bbox?: [number, number, number, number]): void { void runSearch(false); + // The search this belongs to. Asking for a second collection while the first extent is still + // in flight would otherwise send the map where the user no longer is. + const generation = searchGeneration; if (bbox) return void appRef?.fitBounds?.(bbox); // A collection guessed from its link has never been read, so its extent has to be fetched. void openCatalogNode(href, fetch, controller.signal) - .then((node) => node.bbox && appRef?.fitBounds?.(node.bbox)) + .then((node) => { + if (generation === searchGeneration && node.bbox) appRef?.fitBounds?.(node.bbox); + }) .catch(() => undefined); } From d11f86075c7e6ff1e04a72c1c02c89b2918222d5 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 15:44:17 -0600 Subject: [PATCH 08/19] fix(stac): keep the arrows working while a modifier is held --- .../plugins/src/plugins/stac-catalog-tree.ts | 16 ++++++++++------ tests/stac-catalog-tree.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index cd6d9f504b..72650be370 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -181,11 +181,12 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { }; /** - * Reads what is inside the node, and chooses it if it turns out to be a collection after all. - * A link ending in `collection.json` is taken at its word and never read for children: every - * collection in the catalogs this was built against holds items, not more collections, and a - * read per row to prove that is a cost with nothing to show for it. A collection that does - * nest is still searched whole — only its shape stays out of the tree. + * Reads what is inside the node, and chooses it if it turns out to be a collection after all + * — one that nests still shows what it holds, since the read has already been paid for. + * A link ending in `collection.json` is taken at its word and never read: every collection in + * the catalogs this was built against holds items rather than more collections, so a read per + * row to prove it would cost a request each and show nothing. Such a collection is still + * searched whole; only its shape stays out of the tree. */ const reveal = async (additive: boolean): Promise => { if (busy || loaded) return; @@ -268,7 +269,10 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { Home: () => focusRow(reachable()[0]), End: () => focusRow(reachable().at(-1)), }; - const take = steps[event.ctrlKey || event.metaKey ? `Ctrl+${event.key}` : event.key]; + // A modifier only changes what a key means when there is something for it to mean; holding + // Ctrl while arrowing should still walk the tree rather than swallow the press. + const held = event.ctrlKey || event.metaKey; + const take = (held ? steps[`Ctrl+${event.key}`] : undefined) ?? steps[event.key]; if (!take) return; event.preventDefault(); take(); diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index aab27f90f9..86ceeb751e 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -538,6 +538,23 @@ test("Ctrl+Enter asks for a collection's items, the way a double-click does", as }); }); +test("holding a modifier does not stop the arrows walking the tree", async () => { + await withDom(async () => { + const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); + tree.reset([node("Hazards", "collection"), node("Geology", "collection")]); + const [hazards] = rowsOf(tree); + const stops = (): Array => + rowsOf(tree).map((row) => row.getAttribute("tabindex")); + + // Ctrl+Enter is the one combination that means something else; every other key keeps its + // meaning, rather than being swallowed by a lookup that only knows about Enter. + press(hazards, "ArrowDown", true); + assert.deepEqual(stops(), ["-1", "0"]); + press(rowsOf(tree)[1], "Home", true); + assert.deepEqual(stops(), ["0", "-1"]); + }); +}); + test("reset drops the previous catalog's rows and selection", async () => { await withDom(async () => { const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); From c520115b28c70b8c5b3f25c2dc434410da7cfd35 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 16:01:14 -0600 Subject: [PATCH 09/19] fix(stac): let a collection that holds collections be closed and opened again --- packages/plugins/src/plugins/stac-api.ts | 4 ++- .../plugins/src/plugins/stac-catalog-tree.ts | 10 ++++-- tests/stac-catalog-tree.test.ts | 31 +++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 88150c4568..37976c96b2 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -435,7 +435,9 @@ export async function searchStaticStac( // Where a search starts and what it filters by belong to the walk, not to the call: both can // change between pages, and one accumulated list filtered two ways is worse than either. const roots: Unread[] = (options.entries?.length ? options.entries : [connection.url]).map( - // The root is already in hand, so a chosen entry that is the root costs no read. + // The root is already in hand, so a chosen entry that is the root costs no read. Any other + // entry is read here even when the tree read it to classify it: passing that document along + // would mean the tree holding every document it has opened, to save one request per search. (url) => (url === connection.url ? { url, document: connection.root } : { url }), ); const walk = options.cursor ?? { diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index 72650be370..3bde929970 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -225,7 +225,13 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { /** What a click or Space means: choose a collection, or open a folder. */ const activate = (additive: boolean): void => { // Choosing a collection costs no read; only a container has to be opened to be useful. - if (kind === "collection") return select(node.href, row, additive); + // A collection that turned out to hold collections is both, so it does both — otherwise a + // row could be closed and never opened again, its children out of reach. + if (kind === "collection") { + select(node.href, row, additive); + if (self.children.length) expand(!self.open); + return; + } if (loaded) return expand(!self.open); void reveal(additive); }; @@ -257,7 +263,7 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { ArrowDown: () => step(1), ArrowUp: () => step(-1), ArrowRight: () => { - if (kind === "collection") return; + if (kind === "collection" && !self.children.length) return; if (!self.open) return activate(false); focusRow(self.children[0]); }, diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index 86ceeb751e..8f4375b5ba 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -555,6 +555,37 @@ test("holding a modifier does not stop the arrows walking the tree", async () => }); }); +test("a collection that holds collections can be closed and opened again", async () => { + await withDom(async () => { + const read = async (): Promise => ({ + kind: "collection", + children: [node("Landsat 9", "collection")], + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Landsat")]); + const [landsat] = rowsOf(tree); + + click(landsat); + await settle(); + const box = landsat.nextElementSibling as HTMLElement; + assert.equal(box.hidden, false); + assert.deepEqual(tree.selection(), ["https://example.com/Landsat.json"]); + + // Left closes it; the row is a collection, so nothing else used to be willing to open it and + // its children were gone for good. + press(landsat, "ArrowLeft"); + assert.equal(box.hidden, true); + + press(landsat, "ArrowRight"); + await settle(); + assert.equal(box.hidden, false, "the arrows can reopen what they closed"); + + click(landsat); + await settle(); + assert.equal(box.hidden, true, "and so can a click"); + }); +}); + test("reset drops the previous catalog's rows and selection", async () => { await withDom(async () => { const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); From 73b0e4c13f1e1c4211eada94bab25d57c86f8925 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 16:29:03 -0600 Subject: [PATCH 10/19] fix(stac): search a catalog that carries its own items, and name a file that has no folder --- packages/plugins/src/plugins/stac-api.ts | 10 +++- .../plugins/src/plugins/stac-catalog-tree.ts | 16 ++++-- tests/stac-api.test.ts | 21 +++++++ tests/stac-catalog-tree.test.ts | 57 +++++++++++++++++++ 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 37976c96b2..69e74a293e 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -74,6 +74,8 @@ export interface StacCatalogNode { export interface StacOpenedNode { kind: "collection" | "container"; children: StacCatalogNode[]; + /** Items linked from the node itself, which a catalog is allowed to carry without a collection. */ + items?: number; /** A collection's own extent, so the map can be sent to it without reading any item. */ bbox?: [number, number, number, number]; } @@ -212,7 +214,11 @@ function normalizeItem(item: StacItem, base: string): StacItem { function folderName(href: string): string { const segments = new URL(href).pathname.split("/").filter(Boolean); const last = segments.at(-1); - const name = (/\.json$/i.test(last ?? "") ? segments.at(-2) : last) ?? href; + // A file at the root has no folder to be named after, so its own name will have to do. + const named = /\.json$/i.test(last ?? "") + ? (segments.at(-2) ?? last?.replace(/\.json$/i, "")) + : last; + const name = named ?? href; try { return decodeURIComponent(name); } catch { @@ -266,9 +272,11 @@ export async function openCatalogNode( const document = await fetchJson>(href, { signal }, fetcher); if (typeof document !== "object" || document === null || Array.isArray(document)) throw new Error("The link did not return a STAC document"); + const links = linksOf(document.links, href); return { kind: document.type === "Collection" ? "collection" : "container", children: catalogChildren(document, href), + items: links.filter((link) => link.rel === "item").length, bbox: collectionBbox(document), }; } diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index 3bde929970..d5cf90dd79 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -188,7 +188,7 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { * row to prove it would cost a request each and show nothing. Such a collection is still * searched whole; only its shape stays out of the tree. */ - const reveal = async (additive: boolean): Promise => { + const reveal = async (choose: boolean, additive: boolean): Promise => { if (busy || loaded) return; busy = true; glyph.textContent = GLYPH.busy; @@ -200,7 +200,10 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { loaded = true; bbox = opened.bbox; for (const child of opened.children) addNode(child, self, depth + 1); - if (kind === "collection") select(node.href, row, additive); + // A catalog may link its items directly, with no collection in between. Such a node holds + // data and has nothing to open, so it is a leaf to search rather than an empty folder. + if (!opened.children.length && opened.items) kind = "collection"; + if (kind === "collection" && choose) select(node.href, row, additive); if (opened.children.length) return expand(true); if (kind === "collection") { glyph.textContent = GLYPH.leaf; @@ -233,7 +236,7 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { return; } if (loaded) return expand(!self.open); - void reveal(additive); + void reveal(true, additive); }; row.addEventListener("click", (event) => { @@ -262,10 +265,11 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const steps: Record void> = { ArrowDown: () => step(1), ArrowUp: () => step(-1), + // Opening a folder is navigation, not a choice: it must not disturb what is chosen. ArrowRight: () => { - if (kind === "collection" && !self.children.length) return; - if (!self.open) return activate(false); - focusRow(self.children[0]); + if (self.open) return focusRow(self.children[0]); + if (!loaded) return void reveal(false, false); + if (self.children.length) expand(true); }, ArrowLeft: () => { if (self.open) return expand(false); diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index e43c58556b..1e24db10ac 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -91,6 +91,18 @@ test("openCatalogNode reports what a node turned out to be and what is inside it if (String(input).endsWith("collection.json")) { return jsonResponse({ type: "Collection", id: "hazards", links: [] }); } + if (String(input).endsWith("scenes.json")) { + // A catalog is allowed to link items with no collection in between. + return jsonResponse({ + type: "Catalog", + id: "scenes", + links: [ + { rel: "item", href: "./a.json" }, + { rel: "item", href: "./b.json" }, + { rel: "self", href: "./scenes.json" }, + ], + }); + } return jsonResponse({ type: "Catalog", id: "topics", @@ -99,6 +111,7 @@ test("openCatalogNode reports what a node turned out to be and what is inside it }) as typeof fetch; const catalog = await openCatalogNode("https://example.com/stac/topics/catalog.json", fetcher); + assert.equal(catalog.items, 0); assert.equal(catalog.kind, "container"); assert.deepEqual( catalog.children.map((node) => [node.title, node.href]), @@ -108,6 +121,11 @@ test("openCatalogNode reports what a node turned out to be and what is inside it const collection = await openCatalogNode("https://example.com/stac/x/collection.json", fetcher); assert.equal(collection.kind, "collection"); assert.deepEqual(collection.children, []); + + // Items the node carries itself are counted, and only those: `self` is not one of them. + const scenes = await openCatalogNode("https://example.com/stac/scenes.json", fetcher); + assert.equal(scenes.items, 2); + assert.deepEqual(scenes.children, []); }); test("searchStaticStac starts at the chosen collection instead of walking from the root", async () => { @@ -199,6 +217,8 @@ test("connectStac reads child links only, and names them when the link does not" { rel: "child", href: "./100%_coverage/catalog.json" }, { rel: "child", href: "./UPPER/CATALOG.JSON" }, { rel: "child", href: "./quads/" }, + // At the root there is no folder to borrow a name from. + { rel: "child", href: "/standalone.json" }, ], })) as typeof fetch; @@ -210,6 +230,7 @@ test("connectStac reads child links only, and names them when the link does not" ["100%_coverage", "container"], ["UPPER", "container"], ["quads", "container"], + ["standalone", "container"], ], ); }); diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index 8f4375b5ba..fea66a1fcd 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -348,6 +348,32 @@ test("an aborted read is not reported as a failure", async () => { }); }); +test("a catalog that carries its own items is a leaf to search, not an empty folder", async () => { + await withDom(async () => { + // STAC lets a catalog link items with no collection in between, and the spec's own examples + // do it. Such a node has nothing to open, but it does have data to search. + const read = async (): Promise => ({ + kind: "container", + children: [], + items: 4, + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Scenes")]); + const [scenes] = rowsOf(tree); + + click(scenes); + await settle(); + const box = scenes.nextElementSibling as HTMLElement; + assert.notEqual(box.textContent, LABELS.empty, "it is not empty, so it must not say so"); + assert.equal(scenes.textContent, "•Scenes"); + assert.deepEqual( + tree.selection(), + ["https://example.com/Scenes.json"], + "and the search can be scoped to it", + ); + }); +}); + test("an empty container says so instead of looking unread", async () => { await withDom(async () => { const read = async (): Promise => ({ kind: "container", children: [] }); @@ -586,6 +612,37 @@ test("a collection that holds collections can be closed and opened again", async }); }); +test("opening a folder with the arrows leaves the selection alone", async () => { + await withDom(async () => { + // A row that turns out to be a collection holding collections: opening it used to choose it, + // and choosing without a modifier clears everything else. + const read = async (): Promise => ({ + kind: "collection", + children: [node("Water", "collection")], + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Hazards", "collection"), node("Geology", "collection"), node("Topics")]); + const [hazards, geology, topics] = rowsOf(tree); + + click(hazards); + click(geology, true); + await settle(); + assert.equal(tree.selection().length, 2); + + // Right opens the folder. A choice made elsewhere is not the folder's business. + press(topics, "ArrowRight"); + await settle(); + assert.equal(topics.getAttribute("aria-expanded"), "true"); + assert.equal(tree.selection().length, 2, "navigating did not clear what was chosen"); + + press(topics, "ArrowLeft"); + press(topics, "ArrowRight"); + await settle(); + assert.equal(topics.getAttribute("aria-expanded"), "true"); + assert.equal(tree.selection().length, 2); + }); +}); + test("reset drops the previous catalog's rows and selection", async () => { await withDom(async () => { const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); From 6d2da7096a1b64bce033f6b88e5f166578a32105 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 16:29:03 -0600 Subject: [PATCH 11/19] fix(stac): open a folder with the arrows without changing what is chosen --- e2e/stac-catalog-tree.spec.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/e2e/stac-catalog-tree.spec.ts b/e2e/stac-catalog-tree.spec.ts index e81fc5dcae..6a04a02f02 100644 --- a/e2e/stac-catalog-tree.spec.ts +++ b/e2e/stac-catalog-tree.spec.ts @@ -333,8 +333,21 @@ test("asking for a second collection wins, however slowly the first one answers" await page.keyboard.press("ControlOrMeta+Enter"); await expect(page.getByText("Showing 1 of 1 items.")).toBeVisible({ timeout: 15_000 }); - await page.waitForTimeout(4000); - const [west, , east] = await mapBounds(page); + + // Watch until the view stops changing, and never before the late answer has had its chance: a + // fixed sleep would either race the delay or pad every run to hide it. + const settled = async (): Promise => { + const deadline = Date.now() + 4000; + let previous = await mapBounds(page); + for (let attempt = 0; attempt < 30; attempt += 1) { + await page.waitForTimeout(400); + const next = await mapBounds(page); + if (Date.now() > deadline && next.join() === previous.join()) return next; + previous = next; + } + return previous; + }; + const [west, , east] = await settled(); expect(east - west).toBeLessThan(4); expect(west).toBeGreaterThan(-114); }); From 8117eb57428342ddf51be61f53e19834a43ac0fd Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 16:46:59 -0600 Subject: [PATCH 12/19] fix(stac): search a catalog that holds sub-catalogs and items of its own --- .../plugins/src/plugins/stac-catalog-tree.ts | 25 +++++------ tests/stac-catalog-tree.test.ts | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index d5cf90dd79..2ea7fa1d5b 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -41,6 +41,10 @@ const style = { const GLYPH = { open: "▾", leaf: "•", busy: "…" } as const; +// Ties each row to the group it opens, which the markup cannot: the group is its sibling. Counted +// per document rather than per tree, so two trees cannot mint the same id. +let groupCount = 0; + /** A closed folder points the way the text runs, so it mirrors with the rest of the UI. */ function closedGlyph(): string { return typeof document !== "undefined" && document.documentElement.dir === "rtl" ? "◂" : "▸"; @@ -95,8 +99,6 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const selected = new Map(); // A catalog the user has left must not keep writing into the tree that replaced it. let generation = 0; - // Ties each row to the group it opens, which the markup cannot: the group is its sibling. - let rowCount = 0; // The tree built every row, so it keeps its own shape rather than reading it back out of the // DOM — and the arrows can then move by parent and child instead of by selector. @@ -158,8 +160,8 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const childrenBox = el("div"); childrenBox.hidden = true; childrenBox.setAttribute("role", "group"); - rowCount += 1; - childrenBox.id = `${ROW_CLASS}-group-${rowCount}`; + groupCount += 1; + childrenBox.id = `${ROW_CLASS}-group-${groupCount}`; row.setAttribute("aria-owns", childrenBox.id); (parent?.box ?? element).append(row, childrenBox); @@ -181,12 +183,11 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { }; /** - * Reads what is inside the node, and chooses it if it turns out to be a collection after all - * — one that nests still shows what it holds, since the read has already been paid for. - * A link ending in `collection.json` is taken at its word and never read: every collection in - * the catalogs this was built against holds items rather than more collections, so a read per - * row to prove it would cost a request each and show nothing. Such a collection is still - * searched whole; only its shape stays out of the tree. + * Reads what is inside the node: what it turned out to be, what it holds, and where it is. + * `choose` marks the read a click asked for, since opening a folder with the arrows must not + * change what is chosen. A link ending in `collection.json` is believed without reading, so a + * click on one costs nothing — every collection in the catalogs this was built against holds + * items rather than more collections. The arrows still read it, for the rare one that nests. */ const reveal = async (choose: boolean, additive: boolean): Promise => { if (busy || loaded) return; @@ -201,8 +202,8 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { bbox = opened.bbox; for (const child of opened.children) addNode(child, self, depth + 1); // A catalog may link its items directly, with no collection in between. Such a node holds - // data and has nothing to open, so it is a leaf to search rather than an empty folder. - if (!opened.children.length && opened.items) kind = "collection"; + // data of its own, so it can be searched — whether or not it also holds sub-catalogs. + if (opened.items) kind = "collection"; if (kind === "collection" && choose) select(node.href, row, additive); if (opened.children.length) return expand(true); if (kind === "collection") { diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index fea66a1fcd..feb6563db0 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -374,6 +374,34 @@ test("a catalog that carries its own items is a leaf to search, not an empty fol }); }); +test("a catalog holding both sub-catalogs and its own items opens and is searchable", async () => { + await withDom(async () => { + // Neither half cancels the other: its children are worth browsing, and its own items are + // worth searching, so scoping a search here must not quietly leave them out. + const read = async (): Promise => ({ + kind: "container", + children: [node("Quads", "collection")], + items: 3, + }); + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Mapping")]); + const [mapping] = rowsOf(tree); + + click(mapping); + await settle(); + assert.equal((mapping.nextElementSibling as HTMLElement).hidden, false, "its children show"); + assert.deepEqual( + tree.selection(), + ["https://example.com/Mapping.json"], + "and it can be searched itself", + ); + assert.deepEqual( + rowsOf(tree).map((row) => row.textContent), + ["▾Mapping", "•Quads"], + ); + }); +}); + test("an empty container says so instead of looking unread", async () => { await withDom(async () => { const read = async (): Promise => ({ kind: "container", children: [] }); @@ -643,6 +671,21 @@ test("opening a folder with the arrows leaves the selection alone", async () => }); }); +test("two trees in one document do not claim the same group", async () => { + await withDom(async () => { + // `aria-owns` points at an id; if two trees mint the same one, it points at either. + const first = buildCatalogTree({ labels: LABELS, onError: () => {} }); + const second = buildCatalogTree({ labels: LABELS, onError: () => {} }); + first.reset([node("Themes"), node("Topics")]); + second.reset([node("Themes"), node("Topics")]); + + const owned = [first, second].flatMap((tree) => + rowsOf(tree).map((row) => row.getAttribute("aria-owns")), + ); + assert.equal(new Set(owned).size, owned.length, "every row owns a group of its own"); + }); +}); + test("reset drops the previous catalog's rows and selection", async () => { await withDom(async () => { const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); From dd2b3b1d8ba3e7cf47a760be6c474de4ef993494 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 17:48:44 -0600 Subject: [PATCH 13/19] fix(stac): read a collection when it is chosen, and let a node with nothing inside be chosen too --- e2e/stac-api-panel.spec.ts | 27 ++++--- .../plugins/src/plugins/stac-catalog-tree.ts | 80 ++++++++++++------- tests/stac-catalog-tree.test.ts | 71 +++++++++++++++- 3 files changed, 137 insertions(+), 41 deletions(-) diff --git a/e2e/stac-api-panel.spec.ts b/e2e/stac-api-panel.spec.ts index 62acf55dbf..ed6b2689a3 100644 --- a/e2e/stac-api-panel.spec.ts +++ b/e2e/stac-api-panel.spec.ts @@ -143,18 +143,25 @@ test("double-clicking a collection in the list searches it and moves the map", a test("connecting to an API after a static catalog clears the tree", async ({ page }) => { const searches: string[] = []; await serveApi(page, searches); - await page.route("https://static.stac.test/**", async (route) => - route.fulfill({ + await page.route("https://static.stac.test/**", async (route) => { + // Clicking a collection reads it, so the child needs a document of its own — answering every + // path with the catalog would nest a second copy of the same row under the first. + const collection = route.request().url().includes("hazards"); + await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ - type: "Catalog", - id: "static", - title: "E2E Static", - links: [{ rel: "child", href: "./hazards/collection.json", title: "Hazards" }], - }), - }), - ); + body: JSON.stringify( + collection + ? { type: "Collection", id: "hazards", links: [] } + : { + type: "Catalog", + id: "static", + title: "E2E Static", + links: [{ rel: "child", href: "./hazards/collection.json", title: "Hazards" }], + }, + ), + }); + }); await waitForMap(page); await connect(page, "https://static.stac.test/catalog.json"); diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index 2ea7fa1d5b..9d28e50c20 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -99,6 +99,9 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const selected = new Map(); // A catalog the user has left must not keep writing into the tree that replaced it. let generation = 0; + // Asking for a row is asking for the newest one: a request that waited on a slow read must not + // land after the user has asked for something else. + let asked = 0; // The tree built every row, so it keeps its own shape rather than reading it back out of the // DOM — and the arrows can then move by parent and child instead of by selector. @@ -170,7 +173,8 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { let kind = node.kind; let loaded = false; - let busy = false; + /** The read in flight, if any: it stands in for a busy flag and can be waited on. */ + let reading: Promise | undefined; let bbox: [number, number, number, number] | undefined; if (kind !== "collection") row.setAttribute("aria-expanded", "false"); @@ -185,13 +189,18 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { /** * Reads what is inside the node: what it turned out to be, what it holds, and where it is. * `choose` marks the read a click asked for, since opening a folder with the arrows must not - * change what is chosen. A link ending in `collection.json` is believed without reading, so a - * click on one costs nothing — every collection in the catalogs this was built against holds - * items rather than more collections. The arrows still read it, for the rare one that nests. + * change what is chosen. A collection is read too, once, after it has been chosen: its link + * says it is a leaf, and a link cannot see the sub-collections a Maxar event turns out to + * hold. */ - const reveal = async (choose: boolean, additive: boolean): Promise => { - if (busy || loaded) return; - busy = true; + const reveal = (choose: boolean, additive: boolean): Promise => { + if (loaded) return Promise.resolve(); + // A second gesture joins the read already running rather than starting another. + reading ??= readNode(choose, additive); + return reading; + }; + + const readNode = async (choose: boolean, additive: boolean): Promise => { glyph.textContent = GLYPH.busy; try { const opened = await read(node.href, fetch, signal); @@ -201,20 +210,19 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { loaded = true; bbox = opened.bbox; for (const child of opened.children) addNode(child, self, depth + 1); - // A catalog may link its items directly, with no collection in between. Such a node holds - // data of its own, so it can be searched — whether or not it also holds sub-catalogs. - if (opened.items) kind = "collection"; + // A row a search can be pointed at: one carrying its own items, with or without + // sub-catalogs, and one with nothing to open at all — including the empty node, which + // can then be chosen like any other and simply searches to nothing. + if (opened.items || !opened.children.length) kind = "collection"; if (kind === "collection" && choose) select(node.href, row, additive); if (opened.children.length) return expand(true); - if (kind === "collection") { - glyph.textContent = GLYPH.leaf; - row.removeAttribute("aria-expanded"); - return; - } + glyph.textContent = GLYPH.leaf; + row.removeAttribute("aria-expanded"); + if (opened.items) return; const empty = el("div", labels.empty); empty.style.cssText = `${style.empty}padding-inline-start:${16 + depth * 12}px;`; childrenBox.append(empty); - expand(true); + childrenBox.hidden = false; } catch (error) { if (mine !== generation || signal?.aborted) return; glyph.textContent = closedGlyph(); @@ -222,18 +230,19 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const detail = error instanceof Error ? error.message : String(error); onError(`${labels.openFailed}: ${detail}`); } finally { - busy = false; + reading = undefined; } }; /** What a click or Space means: choose a collection, or open a folder. */ const activate = (additive: boolean): void => { - // Choosing a collection costs no read; only a container has to be opened to be useful. - // A collection that turned out to hold collections is both, so it does both — otherwise a - // row could be closed and never opened again, its children out of reach. + // A collection is chosen at once, without waiting on the network, and read once so that + // whatever it holds can be reached: Maxar's events are collections of collections, and a + // link alone cannot say so. The search reads the same document moments later. if (kind === "collection") { select(node.href, row, additive); - if (self.children.length) expand(!self.open); + if (self.children.length) return expand(!self.open); + void reveal(false, additive); return; } if (loaded) return expand(!self.open); @@ -245,16 +254,22 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { activate(event.ctrlKey || event.metaKey); }); - /** "Show me this one": pick the collection if it is not picked, then ask for its items. */ - const show = (): void => { - if (kind !== "collection") return; + /** + * "Show me this one": pick the collection if it is not picked, then ask for its items. The + * clicks of a double-click land before it, so a row still being read is waited for — without + * that, double-clicking a folder that turns out to be a collection searches nothing. + */ + const show = async (): Promise => { + const mine = ++asked; + await reading; + if (mine !== asked || kind !== "collection") return; if (!selected.has(row)) select(node.href, row, false); onActivate?.(node.href, bbox); }; // The second click of a double-click would otherwise toggle the choice back off, so the // selection is restored before the search is asked for. - row.addEventListener("dblclick", show); + row.addEventListener("dblclick", () => void show()); // The arrows walk the tree and work its folders. Enter and Space are left to the button the // row is written on, which already chooses; asking for the items takes the modifier. @@ -266,17 +281,24 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const steps: Record void> = { ArrowDown: () => step(1), ArrowUp: () => step(-1), - // Opening a folder is navigation, not a choice: it must not disturb what is chosen. + // Opening a folder is navigation, not a choice: it must not disturb what is chosen, and + // it believes a `collection.json` link exactly as a click does rather than reading a row + // per arrow press. ArrowRight: () => { if (self.open) return focusRow(self.children[0]); - if (!loaded) return void reveal(false, false); - if (self.children.length) expand(true); + if (loaded) return void (self.children.length && expand(true)); + void reveal(false, false); }, ArrowLeft: () => { if (self.open) return expand(false); focusRow(self.parent); }, - "Ctrl+Enter": () => (kind === "collection" ? show() : activate(false)), + // Ctrl+Enter means "show me this one" whatever the row turns out to be: a container is + // read first, and searched if that read reveals a collection. + "Ctrl+Enter": () => { + if (kind !== "collection" && !loaded) activate(false); + void show(); + }, Home: () => focusRow(reachable()[0]), End: () => focusRow(reachable().at(-1)), }; diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index feb6563db0..3c8964007b 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -66,7 +66,7 @@ test("a row that leaves the selection stops being painted as selected", async () const tree = buildCatalogTree({ labels: LABELS, onError: (message) => assert.fail(`unexpected error: ${message}`), - read: async () => assert.fail("a collection must not be opened"), + read: async () => ({ kind: "collection", children: [] }), }); tree.reset([node("Hazards", "collection"), node("Geology", "collection")]); const [hazards, geology] = rowsOf(tree); @@ -402,7 +402,7 @@ test("a catalog holding both sub-catalogs and its own items opens and is searcha }); }); -test("an empty container says so instead of looking unread", async () => { +test("an empty node says so, and can still be chosen", async () => { await withDom(async () => { const read = async (): Promise => ({ kind: "container", children: [] }); const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); @@ -415,6 +415,11 @@ test("an empty container says so instead of looking unread", async () => { assert.equal(box.textContent, LABELS.empty); assert.equal(box.hidden, false); assert.equal(box.getAttribute("role"), "group"); + + // Nothing to open is not the same as nothing to do: the click that read it also chose it, + // so a search can be pointed here. + assert.deepEqual(tree.selection(), ["https://example.com/Themes.json"]); + assert.equal(themes.getAttribute("aria-selected"), "true"); }); }); @@ -686,6 +691,68 @@ test("two trees in one document do not claim the same group", async () => { }); }); +test("choosing a collection is immediate, and reads it once to see what it holds", async () => { + await withDom(async () => { + // Maxar's events are collections of collections, and the link says only "collection.json". + let reads = 0; + const read = async (): Promise => { + reads += 1; + return { kind: "collection", children: [node("Acquisition", "collection")] }; + }; + const tree = buildCatalogTree({ labels: LABELS, onError: () => {}, read }); + tree.reset([node("Cyclone", "collection")]); + const [cyclone] = rowsOf(tree); + + click(cyclone); + // Chosen before the read can answer: the network must not stand between a click and its row. + assert.deepEqual(tree.selection(), ["https://example.com/Cyclone.json"]); + + await settle(); + assert.equal(reads, 1); + assert.deepEqual( + rowsOf(tree).map((row) => row.textContent), + ["▾Cyclone", "•Acquisition"], + "what it holds is reachable", + ); + + click(cyclone); + await settle(); + assert.equal(reads, 1, "and it is not read again"); + }); +}); + +test("double-clicking a folder that turns out to be a collection still searches it", async () => { + await withDom(async () => { + const activated: string[] = []; + let release: (value: StacOpenedNode) => void = () => {}; + const read = async (): Promise => + new Promise((resolve) => { + release = resolve; + }); + const tree = buildCatalogTree({ + labels: LABELS, + onError: () => {}, + onActivate: (href) => activated.push(href), + read, + }); + // The link says nothing, so the row starts as a folder and only the read can settle it. + tree.reset([{ href: "https://example.com/maps", title: "Maps", kind: "container" }]); + const [maps] = rowsOf(tree); + + // A real double-click is click, click, dblclick — all before a network read can answer. + click(maps); + click(maps); + maps.dispatchEvent( + new (globalThis as { Event: typeof Event }).Event("dblclick", { bubbles: true }), + ); + release({ kind: "collection", children: [], items: 2 }); + await settle(); + + assert.deepEqual(activated, ["https://example.com/maps"], "the search was not dropped"); + assert.deepEqual(tree.selection(), ["https://example.com/maps"]); + }); +}); + test("reset drops the previous catalog's rows and selection", async () => { await withDom(async () => { const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); From 8b2ff052f6666626b9d3074b383255faec921549 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 17:48:44 -0600 Subject: [PATCH 14/19] fix(stac): drop a request for items the user has since asked to leave --- packages/plugins/src/plugins/maplibre-stac.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index d1fd022be6..f75b1cd177 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -886,10 +886,10 @@ function buildPanel(container: HTMLElement): () => void { /** The tree asked for a collection: search it, and send the map to it. */ function showCollection(href: string, bbox?: [number, number, number, number]): void { - void runSearch(false); - // The search this belongs to. Asking for a second collection while the first extent is still - // in flight would otherwise send the map where the user no longer is. - const generation = searchGeneration; + // The search this belongs to, taken before it starts rather than read back afterwards: asking + // for a second collection while the first extent is in flight must not move the map back. + const generation = ++searchGeneration; + void runSearch(false, generation); if (bbox) return void appRef?.fitBounds?.(bbox); // A collection guessed from its link has never been read, so its extent has to be fetched. void openCatalogNode(href, fetch, controller.signal) @@ -899,9 +899,9 @@ function buildPanel(container: HTMLElement): () => void { .catch(() => undefined); } - async function runSearch(append: boolean): Promise { + async function runSearch(append: boolean, generation = ++searchGeneration): Promise { if (!connection) return; - const generation = ++searchGeneration; + searchButton.disabled = true; loadMore.disabled = true; setStatus(append ? labels.loadingMore : labels.searching); From 2854f2da0c38fe55675ce8f36ac51257824c0b43 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 18:08:12 -0600 Subject: [PATCH 15/19] fix(stac): collapse a chosen collection without letting go of it --- packages/plugins/src/plugins/stac-catalog-tree.ts | 7 +++++-- tests/stac-catalog-tree.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index 9d28e50c20..cd73149d62 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -240,9 +240,12 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { // whatever it holds can be reached: Maxar's events are collections of collections, and a // link alone cannot say so. The search reads the same document moments later. if (kind === "collection") { + // Once such a row is chosen, a click browses what it holds rather than un-choosing it — + // collapsing a folder must not quietly widen the next search back to the whole catalog. + // Ctrl/Cmd-click still lets go of it, as it does anywhere else. + if (!additive && self.children.length && selected.has(row)) return expand(!self.open); select(node.href, row, additive); - if (self.children.length) return expand(!self.open); - void reveal(false, additive); + if (!loaded) void reveal(false, additive); return; } if (loaded) return expand(!self.open); diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index 3c8964007b..efcbe089cf 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -642,6 +642,14 @@ test("a collection that holds collections can be closed and opened again", async click(landsat); await settle(); assert.equal(box.hidden, true, "and so can a click"); + // Collapsing is browsing, not un-choosing: a search must not silently widen to the whole + // catalog because the user tidied the tree. + assert.deepEqual(tree.selection(), ["https://example.com/Landsat.json"]); + + // Ctrl-click is still how a row is let go of. + click(landsat, true); + await settle(); + assert.deepEqual(tree.selection(), []); }); }); From 0236bb3fae22ba3f6f469046547c734e3005e5de Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 18:20:38 -0600 Subject: [PATCH 16/19] fix(stac): only take a search generation when a search actually runs --- packages/plugins/src/plugins/maplibre-stac.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index f75b1cd177..ae8209cd5d 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -899,8 +899,14 @@ function buildPanel(container: HTMLElement): () => void { .catch(() => undefined); } - async function runSearch(append: boolean, generation = ++searchGeneration): Promise { + /** + * `generation` says which search this is, so a late answer can tell whether it is still wanted. + * The caller may take it first, when it has its own late answer to check; taking it here would + * mean it moves on a call that does nothing. + */ + async function runSearch(append: boolean, generation?: number): Promise { if (!connection) return; + const search = generation ?? ++searchGeneration; searchButton.disabled = true; loadMore.disabled = true; @@ -926,7 +932,7 @@ function buildPanel(container: HTMLElement): () => void { const response = connection.isApi ? await searchStacApi(connection, options) : await searchStaticStac(connection, options); - if (generation !== searchGeneration) return; + if (search !== searchGeneration) return; allItems = append ? [...allItems, ...response.items] : response.items; nextPage = response.next; searchCursor = response.cursor; @@ -941,7 +947,7 @@ function buildPanel(container: HTMLElement): () => void { } catch (error) { setStatus(error instanceof Error ? error.message : labels.searchFailed, true); } finally { - if (generation === searchGeneration) { + if (search === searchGeneration) { searchButton.disabled = false; loadMore.disabled = false; } From c7c14f961dc9a94156f81395db6f8a1b7da3bf8b Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 20:01:38 -0600 Subject: [PATCH 17/19] fix(stac): keep a stale search from reporting its failure over the current one --- packages/plugins/src/plugins/maplibre-stac.ts | 19 +++++++---- tests/stac-catalog-tree.test.ts | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index ae8209cd5d..f19e66b2a5 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -676,6 +676,8 @@ function buildPanel(container: HTMLElement): () => void { let searchCursor: StacSearchCursor | undefined; let allItems: StacItem[] = []; let searchGeneration = 0; + /** The extent read a collection asked for, cancelled when another collection is asked for. */ + let extentRead: AbortController | null = null; let cancelDraw: (() => void) | null = null; let selectedItemId: string | null = null; const cardsByItemId = new Map(); @@ -886,15 +888,18 @@ function buildPanel(container: HTMLElement): () => void { /** The tree asked for a collection: search it, and send the map to it. */ function showCollection(href: string, bbox?: [number, number, number, number]): void { - // The search this belongs to, taken before it starts rather than read back afterwards: asking - // for a second collection while the first extent is in flight must not move the map back. - const generation = ++searchGeneration; - void runSearch(false, generation); + void runSearch(false, ++searchGeneration); if (bbox) return void appRef?.fitBounds?.(bbox); // A collection guessed from its link has never been read, so its extent has to be fetched. - void openCatalogNode(href, fetch, controller.signal) + // Asking for a second collection cancels that read rather than letting it finish and be + // thrown away, so the map cannot be sent where the user no longer is. + extentRead?.abort(); + const reading = new AbortController(); + extentRead = reading; + const scope = AbortSignal.any([reading.signal, controller.signal]); + void openCatalogNode(href, fetch, scope) .then((node) => { - if (generation === searchGeneration && node.bbox) appRef?.fitBounds?.(node.bbox); + if (!scope.aborted && node.bbox) appRef?.fitBounds?.(node.bbox); }) .catch(() => undefined); } @@ -945,6 +950,8 @@ function buildPanel(container: HTMLElement): () => void { clearResultsButton.disabled = allItems.length === 0; setStatus(searchStatus(response)); } catch (error) { + // A search the user has moved on from must not report its failure over the current one. + if (search !== searchGeneration) return; setStatus(error instanceof Error ? error.message : labels.searchFailed, true); } finally { if (search === searchGeneration) { diff --git a/tests/stac-catalog-tree.test.ts b/tests/stac-catalog-tree.test.ts index efcbe089cf..4d5e9b3419 100644 --- a/tests/stac-catalog-tree.test.ts +++ b/tests/stac-catalog-tree.test.ts @@ -761,6 +761,39 @@ test("double-clicking a folder that turns out to be a collection still searches }); }); +test("a double-click answered after the catalog changes asks for nothing", async () => { + await withDom(async () => { + const activated: string[] = []; + let release: (value: StacOpenedNode) => void = () => {}; + const read = async (): Promise => + new Promise((resolve) => { + release = resolve; + }); + const tree = buildCatalogTree({ + labels: LABELS, + onError: () => {}, + onActivate: (href) => activated.push(href), + read, + }); + // A row the link already calls a collection: its kind survives a stale read, so nothing else + // would stop the activation landing after the catalog changed. + tree.reset([node("Old", "collection")]); + const [old] = rowsOf(tree); + + click(old); + old.dispatchEvent( + new (globalThis as { Event: typeof Event }).Event("dblclick", { bubbles: true }), + ); + // The user connects elsewhere before the read comes back. + tree.reset([node("New", "collection")]); + release({ kind: "collection", children: [], items: 3 }); + await settle(); + + assert.deepEqual(activated, [], "a row from a catalog the user has left asks for nothing"); + assert.deepEqual(tree.selection(), []); + }); +}); + test("reset drops the previous catalog's rows and selection", async () => { await withDom(async () => { const tree = buildCatalogTree({ labels: LABELS, onError: () => {} }); From 1c459f3ce682b2acec4d1f91b71ee97b788e61d2 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 20:01:38 -0600 Subject: [PATCH 18/19] refactor(stac): cancel the reading a catalog switch makes stale instead of ignoring it --- e2e/stac-catalog-tree.spec.ts | 43 +++++++++++-------- .../plugins/src/plugins/stac-catalog-tree.ts | 37 +++++++++++----- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/e2e/stac-catalog-tree.spec.ts b/e2e/stac-catalog-tree.spec.ts index 6a04a02f02..271deffe31 100644 --- a/e2e/stac-catalog-tree.spec.ts +++ b/e2e/stac-catalog-tree.spec.ts @@ -83,12 +83,25 @@ const DOCUMENTS: Record = { }; /** Serves the fixture catalog, so the suite needs no network and no third-party catalog. */ -async function serveCatalog(page: Page, asked: string[] = [], slow?: string): Promise { +/** + * Serves the fixture catalog. `slow` names a document that answers late, so a race can be staged + * rather than hoped for, and the returned promise says when that late answer has landed: waiting + * on it beats sleeping for longer than the delay and hoping. + */ +async function serveCatalog( + page: Page, + asked: string[] = [], + slow?: string, +): Promise<{ slowAnswered: Promise }> { + let answered: () => void = () => {}; + const slowAnswered = new Promise((resolve) => { + answered = resolve; + }); await page.route("https://stac.test/**", async (route) => { const url = route.request().url(); asked.push(url); - // A document that answers late, so a race can be staged rather than hoped for. - if (slow && url.includes(slow)) await new Promise((resolve) => setTimeout(resolve, 2500)); + const late = Boolean(slow && url.includes(slow)); + if (late) await new Promise((resolve) => setTimeout(resolve, 2500)); const document = DOCUMENTS[route.request().url()]; if (!document) { await route.fulfill({ status: 404, body: "not found" }); @@ -99,7 +112,9 @@ async function serveCatalog(page: Page, asked: string[] = [], slow?: string): Pr contentType: "application/json", body: JSON.stringify(document), }); + if (late) answered(); }); + return { slowAnswered }; } async function openStacPanel(page: Page): Promise { @@ -320,7 +335,7 @@ test("a folder is not read until it is opened", async ({ page }) => { test("asking for a second collection wins, however slowly the first one answers", async ({ page, }) => { - await serveCatalog(page, [], "hazards/collection.json"); + const { slowAnswered } = await serveCatalog(page, [], "hazards/collection.json"); await waitForMap(page); await openStacPanel(page); await page.getByLabel("Limit search to the current map extent").uncheck(); @@ -334,20 +349,12 @@ test("asking for a second collection wins, however slowly the first one answers" await expect(page.getByText("Showing 1 of 1 items.")).toBeVisible({ timeout: 15_000 }); - // Watch until the view stops changing, and never before the late answer has had its chance: a - // fixed sleep would either race the delay or pad every run to hide it. - const settled = async (): Promise => { - const deadline = Date.now() + 4000; - let previous = await mapBounds(page); - for (let attempt = 0; attempt < 30; attempt += 1) { - await page.waitForTimeout(400); - const next = await mapBounds(page); - if (Date.now() > deadline && next.join() === previous.join()) return next; - previous = next; - } - return previous; - }; - const [west, , east] = await settled(); + // The stale answer has landed, so whatever the map does next is the answer to the second ask. + await slowAnswered; + await expect + .poll(async () => (await mapBounds(page))[0], { timeout: 10_000 }) + .toBeGreaterThan(-114); + const [west, , east] = await mapBounds(page); expect(east - west).toBeLessThan(4); expect(west).toBeGreaterThan(-114); }); diff --git a/packages/plugins/src/plugins/stac-catalog-tree.ts b/packages/plugins/src/plugins/stac-catalog-tree.ts index cd73149d62..c4cfb75896 100644 --- a/packages/plugins/src/plugins/stac-catalog-tree.ts +++ b/packages/plugins/src/plugins/stac-catalog-tree.ts @@ -89,6 +89,9 @@ export interface CatalogTreeOptions { /** A catalog rendered as a tree, reading each node's children only when it is opened. */ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { const { labels, onError, onActivate, signal, read = openCatalogNode } = options; + /** A read stops when its connection is replaced, or when the panel itself goes away. */ + const reads = (connection: AbortSignal): AbortSignal => + signal ? AbortSignal.any([connection, signal]) : connection; ensureStyle(); const element = el("div"); element.style.cssText = style.tree; @@ -97,11 +100,15 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { // Keyed by row, not by document: the same collection is often linked from two branches, and // keying by document would let a click on one row cancel the other. const selected = new Map(); - // A catalog the user has left must not keep writing into the tree that replaced it. - let generation = 0; + /** + * One connection's worth of reading. Replacing the catalog aborts it, so reads in flight stop + * rather than finishing into a tree that has moved on, and every path that resumes after an + * `await` has the same one thing to check. + */ + let session = new AbortController(); // Asking for a row is asking for the newest one: a request that waited on a slow read must not // land after the user has asked for something else. - let asked = 0; + let asking = new AbortController(); // The tree built every row, so it keeps its own shape rather than reading it back out of the // DOM — and the arrows can then move by parent and child instead of by selector. @@ -148,7 +155,8 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { }; const addNode = (node: StacCatalogNode, parent: Row | undefined, depth: number): void => { - const mine = generation; + // The connection this row belongs to; it is aborted when the catalog is replaced. + const mine = session.signal; const row = el("button"); row.type = "button"; row.className = ROW_CLASS; @@ -201,11 +209,13 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { }; const readNode = async (choose: boolean, additive: boolean): Promise => { + const scope = reads(mine); glyph.textContent = GLYPH.busy; try { - const opened = await read(node.href, fetch, signal); - // The catalog this row belongs to may have been replaced while the read was in flight. - if (mine !== generation) return; + const opened = await read(node.href, fetch, scope); + // The catalog this row belongs to may have been replaced while the read was in flight, + // or the panel itself closed. + if (scope.aborted) return; kind = opened.kind; loaded = true; bbox = opened.bbox; @@ -224,7 +234,7 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { childrenBox.append(empty); childrenBox.hidden = false; } catch (error) { - if (mine !== generation || signal?.aborted) return; + if (scope.aborted) return; glyph.textContent = closedGlyph(); // The translated sentence carries the meaning; the raw text says which failure it was. const detail = error instanceof Error ? error.message : String(error); @@ -263,9 +273,13 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { * that, double-clicking a folder that turns out to be a collection searches nothing. */ const show = async (): Promise => { - const mine = ++asked; + asking.abort(); + asking = new AbortController(); + const request = asking.signal; await reading; - if (mine !== asked || kind !== "collection") return; + // Neither a newer request nor a catalog the user has left: either would search one thing + // and send the map to another. + if (request.aborted || mine.aborted || kind !== "collection") return; if (!selected.has(row)) select(node.href, row, false); onActivate?.(node.href, bbox); }; @@ -318,7 +332,8 @@ export function buildCatalogTree(options: CatalogTreeOptions): CatalogTree { return { element, reset(nodes) { - generation += 1; + session.abort(); + session = new AbortController(); element.innerHTML = ""; selected.clear(); roots.length = 0; From 89625f9c129e25c82c23ef30620d3abf961c8865 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Sat, 15 Aug 2026 20:09:43 -0600 Subject: [PATCH 19/19] perf(stac): stop a search's walk when another search replaces it --- packages/plugins/src/plugins/maplibre-stac.ts | 7 ++- packages/plugins/src/plugins/stac-api.ts | 2 + tests/stac-api.test.ts | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/plugins/src/plugins/maplibre-stac.ts b/packages/plugins/src/plugins/maplibre-stac.ts index f19e66b2a5..a4df35c492 100644 --- a/packages/plugins/src/plugins/maplibre-stac.ts +++ b/packages/plugins/src/plugins/maplibre-stac.ts @@ -676,6 +676,8 @@ function buildPanel(container: HTMLElement): () => void { let searchCursor: StacSearchCursor | undefined; let allItems: StacItem[] = []; let searchGeneration = 0; + /** The walk a search is doing; starting another stops it, since a static walk reads to find. */ + let walking: AbortController | null = null; /** The extent read a collection asked for, cancelled when another collection is asked for. */ let extentRead: AbortController | null = null; let cancelDraw: (() => void) | null = null; @@ -923,6 +925,9 @@ function buildPanel(container: HTMLElement): () => void { const start = startField.input.value; const end = endField.input.value; const datetime = start || end ? `${start || ".."}/${end || ".."}` : undefined; + walking?.abort(); + walking = new AbortController(); + const reading = AbortSignal.any([walking.signal, controller.signal]); const options = { bbox: parseBbox(), datetime, @@ -932,7 +937,7 @@ function buildPanel(container: HTMLElement): () => void { limit: 20, next: append ? nextPage : undefined, cursor: append ? searchCursor : undefined, - signal: controller.signal, + signal: reading, }; const response = connection.isApi ? await searchStacApi(connection, options) diff --git a/packages/plugins/src/plugins/stac-api.ts b/packages/plugins/src/plugins/stac-api.ts index 69e74a293e..d846de18b1 100644 --- a/packages/plugins/src/plugins/stac-api.ts +++ b/packages/plugins/src/plugins/stac-api.ts @@ -525,6 +525,8 @@ export async function searchStaticStac( }; while (found.length < limit && reads < STATIC_SEARCH_READS_PER_PAGE) { + // Abandoned: stop, rather than read to the budget and count each cancelled read as a failure. + if (options.signal?.aborted) break; const batch = takeBatch(); if (!batch.length) break; reads += batch.length; diff --git a/tests/stac-api.test.ts b/tests/stac-api.test.ts index 1e24db10ac..b52183ea84 100644 --- a/tests/stac-api.test.ts +++ b/tests/stac-api.test.ts @@ -385,6 +385,54 @@ test("openCatalogNode gives up when the search that asked for it is called off", ); }); +test("a search the caller abandons stops reading", async () => { + // A static catalog has no index, so a walk opens documents to answer a filter. One nobody is + // waiting for should stop rather than read its way to the page budget. + const controller = new AbortController(); + let reads = 0; + const docs: Record = { + "https://example.com/stac/catalog.json": { + type: "Catalog", + links: Array.from({ length: 60 }, (_value, index) => ({ + rel: "item", + href: `./item-${index}.json`, + })), + }, + }; + for (let index = 0; index < 60; index += 1) { + docs[`https://example.com/stac/item-${index}.json`] = { + type: "Feature", + id: `i${index}`, + collection: "c", + // Nothing matches, so the walk would otherwise read every one of them. + bbox: [100, 40, 101, 41], + geometry: null, + properties: { datetime: "2024-05-01T00:00:00Z" }, + assets: {}, + }; + } + const fetcher = (async (input: RequestInfo | URL) => { + reads += 1; + if (reads > 12) controller.abort(); + return jsonResponse(docs[String(input)]); + }) as typeof fetch; + + const result = await searchStaticStac( + { + url: "https://example.com/stac/catalog.json", + title: "Static", + isApi: false, + collections: [], + root: docs["https://example.com/stac/catalog.json"] as Record, + }, + { limit: 20, bbox: [-1, -1, 1, 1], signal: controller.signal }, + fetcher, + ); + + assert.deepEqual(result.items, []); + assert.ok(reads < 40, `stopped early, having read ${reads} of 60`); +}); + test("a search keeps the collection filter it began with as later pages arrive", async () => { // The tree's selection can change between Load more clicks; the filter must not follow it, or // one accumulated list ends up filtered two ways.