@walkthru-earth/objex-utils 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/lru.md ADDED
@@ -0,0 +1,76 @@
1
+ # LruCache
2
+
3
+ Tiny insertion-order LRU built on top of `Map`, with move-to-end on read and an optional eviction callback.
4
+
5
+ Source: `packages/objex-utils/src/lru.ts`.
6
+
7
+ ## Types
8
+
9
+ ### `LruCacheOptions<K, V>`
10
+
11
+ ```ts
12
+ interface LruCacheOptions<K, V> {
13
+ max: number;
14
+ onEvict?: (key: K, value: V) => void;
15
+ }
16
+ ```
17
+
18
+ - `max` -- maximum number of entries. Must be `> 0`. The constructor throws `Error('LruCache: max must be > 0')` for any non-positive value.
19
+ - `onEvict` -- optional callback invoked whenever an entry leaves the cache, whether through LRU overflow, an explicit `delete()`, or `clear()`. Use it to release the cached resource (revoke a blob URL, null a GeoTIFF header, etc.).
20
+
21
+ ## Class
22
+
23
+ ### `LruCache<K, V>`
24
+
25
+ ```ts
26
+ class LruCache<K, V> {
27
+ constructor(opts: LruCacheOptions<K, V>);
28
+ readonly max: number;
29
+ get size(): number;
30
+ has(key: K): boolean;
31
+ get(key: K): V | undefined;
32
+ set(key: K, value: V): void;
33
+ delete(key: K): boolean;
34
+ clear(): void;
35
+ }
36
+ ```
37
+
38
+ A small bounded cache keyed on any value `Map` accepts. Recency is tracked by insertion order in the backing `Map`, so the oldest key is always the first one returned by `keys()`.
39
+
40
+ | Member | Semantics |
41
+ |--------|-----------|
42
+ | `max` | The configured cap, exposed read-only so callers can match a deck.gl `maxCacheSize` to it. |
43
+ | `size` | Current number of entries. |
44
+ | `has(key)` | `true` when the key is present. Does NOT change recency. |
45
+ | `get(key)` | Returns the value or `undefined`. On a hit the entry is deleted and re-inserted so it becomes the most-recent slot (move-to-end). |
46
+ | `set(key, value)` | Inserts or updates. An existing key is deleted first so the new write lands as most-recent. After insertion the cache evicts the oldest entries one at a time, calling `onEvict(key, value)` for each, until `size <= max`. |
47
+ | `delete(key)` | Removes a single entry. Returns `false` if the key was absent (and `onEvict` is not called), `true` after removal (and `onEvict` runs). |
48
+ | `clear()` | Removes every entry. When `onEvict` is set it fires once per entry before the backing `Map` is emptied. |
49
+
50
+ **Edge cases**
51
+
52
+ - A `get` on a missing key returns `undefined` and leaves recency untouched.
53
+ - A value of `undefined` is indistinguishable from a miss in `get`, since both return `undefined`. Use `has()` first if you need to store `undefined` values.
54
+ - `set` only evicts after the write, so writing into a full cache momentarily holds `max + 1` entries before trimming back to `max`.
55
+ - `onEvict` runs synchronously inside `set` / `delete` / `clear`, so keep it cheap and non-throwing.
56
+
57
+ ## Example
58
+
59
+ ```ts
60
+ import { LruCache } from '@walkthru-earth/objex-utils';
61
+
62
+ // Bound a per-source presigned-URL cache so panning does not leak.
63
+ const presignCache = new LruCache<string, string>({
64
+ max: 64,
65
+ onEvict: (href) => console.debug('evicted presign for', href)
66
+ });
67
+
68
+ presignCache.set('s3://bucket/a.tif', 'https://signed-a');
69
+ presignCache.set('s3://bucket/b.tif', 'https://signed-b');
70
+
71
+ // A read promotes the entry to most-recent.
72
+ const signed = presignCache.get('s3://bucket/a.tif');
73
+
74
+ // Explicit eviction when deck.gl unloads the matching tile.
75
+ presignCache.delete('s3://bucket/b.tif');
76
+ ```
@@ -0,0 +1,132 @@
1
+ # map-pixel-inspect
2
+
3
+ Framework-agnostic click-to-inspect helper that wires a map `click` event to an async probe, with per-click abort coordination.
4
+
5
+ Source: `packages/objex-utils/src/map-pixel-inspect.ts`.
6
+
7
+ This module has no dependency on Svelte, MapLibre, or deck.gl. The `MapLike` shape captures only the on/off surface the helper needs, so the flow can be unit-tested against a tiny stub. It factors out the "subscribe to click, mark inspecting, await the probe, surface the payload, abort the previous probe if a new click arrives mid-flight, tear down on cleanup" boilerplate shared by the COG-style viewers (`CogViewer`, `StacMosaicViewer`, `MultiCogViewer`).
8
+
9
+ ## Types
10
+
11
+ ### `PixelInspectClickEvent` / `PixelInspectClickHandler`
12
+
13
+ ```ts
14
+ interface PixelInspectClickEvent {
15
+ lngLat: { lng: number; lat: number };
16
+ }
17
+
18
+ type PixelInspectClickHandler = (event: PixelInspectClickEvent) => void;
19
+ ```
20
+
21
+ The minimal click payload the helper reads. Only `lngLat.lng` and `lngLat.lat` are consumed.
22
+
23
+ ### `MapLike`
24
+
25
+ ```ts
26
+ interface MapLike {
27
+ on(type: 'click', handler: PixelInspectClickHandler): unknown;
28
+ off(type: 'click', handler: PixelInspectClickHandler): unknown;
29
+ }
30
+ ```
31
+
32
+ Minimal subset of MapLibre's map API. Anything that dispatches a `click` event carrying `{ lngLat }` and supports symmetric `on` / `off` registration plugs in. Return values of `on` / `off` are ignored (`unknown`).
33
+
34
+ ### `PixelInspectProbeRequest` / `PixelInspectProbe<T>`
35
+
36
+ ```ts
37
+ interface PixelInspectProbeRequest {
38
+ lng: number;
39
+ lat: number;
40
+ signal: AbortSignal;
41
+ }
42
+
43
+ type PixelInspectProbe<T> = (req: PixelInspectProbeRequest) => Promise<T | null>;
44
+ ```
45
+
46
+ Your probe receives the clicked coordinate plus a per-click `AbortSignal`. Thread the `signal` into every `fetch()` / range read the probe issues so a superseding click cancels the in-flight work. Resolve to the inspect payload, or to `null` when there is nothing to report at that location.
47
+
48
+ ### `PixelInspectCallbacks<T>`
49
+
50
+ ```ts
51
+ interface PixelInspectCallbacks<T> {
52
+ onStart(): void;
53
+ onResult(result: T | null): void;
54
+ }
55
+ ```
56
+
57
+ - `onStart()` -- called synchronously when a click is accepted, before the probe is awaited. Use it to set an "inspecting" flag.
58
+ - `onResult(result)` -- called once per accepted click after the probe settles. Receives `null` when the probe returned `null` or threw a non-helper-driven error, including an `AbortError` that did NOT originate from this helper's own controller (for example a viewer-teardown or upstream cancel). It is NOT called when a newer click superseded this one via the helper's own controller.
59
+
60
+ ### `AttachPixelInspectorOptions<T>`
61
+
62
+ ```ts
63
+ interface AttachPixelInspectorOptions<T> {
64
+ probe: PixelInspectProbe<T>;
65
+ onStart: PixelInspectCallbacks<T>['onStart'];
66
+ onResult: PixelInspectCallbacks<T>['onResult'];
67
+ }
68
+ ```
69
+
70
+ The options bag for `attachPixelInspector`. Flattens the callbacks so the call site reads as a single object.
71
+
72
+ ## Functions
73
+
74
+ ### `attachPixelInspector<T>(map, options)`
75
+
76
+ ```ts
77
+ function attachPixelInspector<T>(
78
+ map: MapLike,
79
+ { probe, onStart, onResult }: AttachPixelInspectorOptions<T>
80
+ ): () => void
81
+ ```
82
+
83
+ Wire a click-to-inspect probe onto `map`. Returns a `detach()` function.
84
+
85
+ On each accepted click the helper:
86
+
87
+ 1. Aborts the previous click's controller, if a probe is still in flight.
88
+ 2. Creates a fresh `AbortController` for this click and calls `onStart()` synchronously.
89
+ 3. Awaits `probe({ lng, lat, signal })`.
90
+ 4. Calls `onResult(payload)` exactly once, unless this click was itself superseded by a newer click.
91
+
92
+ **Abort semantics**
93
+
94
+ - A fast second click cancels the first probe via the first probe's own controller. That helper-owned abort is swallowed (no `onResult`), so a stale result never lands after the click that replaced it.
95
+ - Any other `AbortError` (viewer teardown, an upstream signal) still flows through to `onResult(null)`, matching the pre-refactor behavior of each viewer's catch block.
96
+ - The distinction is made by `isHelperAbort()`, an internal guard that checks whether the helper's own signal aborted and whether the error is a `DOMException` named `AbortError` or any object with `name === 'AbortError'`. `isHelperAbort` is not exported.
97
+
98
+ **`detach()`**
99
+
100
+ The returned function removes the `click` listener AND aborts any in-flight probe. It is idempotent (a second call is a no-op) and after detach all further clicks are ignored.
101
+
102
+ ## Example
103
+
104
+ ```ts
105
+ import { attachPixelInspector } from '@walkthru-earth/objex-utils';
106
+
107
+ interface PixelSample {
108
+ band: number;
109
+ value: number;
110
+ }
111
+
112
+ let inspecting = false;
113
+ let sample: PixelSample | null = null;
114
+
115
+ const detach = attachPixelInspector<PixelSample>(map, {
116
+ probe: async ({ lng, lat, signal }) => {
117
+ const px = await readPixelAtLngLat(geotiff, lng, lat, { signal });
118
+ if (!px) return null;
119
+ return { band: 0, value: px.value };
120
+ },
121
+ onStart: () => {
122
+ inspecting = true;
123
+ },
124
+ onResult: (result) => {
125
+ inspecting = false;
126
+ sample = result; // null when nothing was found at that point
127
+ }
128
+ });
129
+
130
+ // On viewer cleanup: removes the listener and aborts the in-flight probe.
131
+ detach();
132
+ ```
@@ -2,7 +2,7 @@
2
2
 
3
3
  Parse markdown documents with YAML frontmatter and Evidence-style SQL code blocks, run interpolation, and stub blocks for server-side rendering.
4
4
 
5
- Source: `src/lib/utils/markdown-sql.ts`.
5
+ Source: `packages/objex-utils/src/markdown-sql.ts`.
6
6
 
7
7
  ## Peer dependency
8
8
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lightweight GeoParquet-aware metadata reader for remote Parquet files. Uses [`hyparquet`](https://github.com/hyparam/hyparquet) via HTTP range requests (~512 KB) so you can inspect schemas and geo metadata **before** DuckDB-WASM finishes booting.
4
4
 
5
- Source: `src/lib/utils/parquet-metadata.ts`.
5
+ Source: `packages/objex-utils/src/parquet-metadata.ts`.
6
6
 
7
7
  ## Peer dependencies
8
8
 
@@ -0,0 +1,198 @@
1
+ # stac-facets
2
+
3
+ Auto-faceted client-side filter / sort / projection helpers for STAC item collections. Pure TypeScript, framework-agnostic. Useful when you want a STAC viewer (or any UI) to show a filter panel that adapts to the loaded dataset rather than hardcoding controls that may not have variance.
4
+
5
+ ```ts
6
+ import {
7
+ type StacItemView,
8
+ type Facet,
9
+ type NumericFacet,
10
+ type EnumFacet,
11
+ type DatetimeFacet,
12
+ type FacetSet,
13
+ type FacetState,
14
+ type FacetSort,
15
+ type NumericFacetField,
16
+ type EnumFacetField,
17
+ extractItemView,
18
+ buildFacets,
19
+ applyFacets,
20
+ sortViews,
21
+ hasActiveFilters,
22
+ emptyFacetState,
23
+ DATETIME_HISTOGRAM_BINS,
24
+ } from '@walkthru-earth/objex-utils';
25
+ ```
26
+
27
+ ## Concept
28
+
29
+ A typical STAC viewer pipeline is:
30
+
31
+ 1. Hydrate items into a flat `StacItem[]`.
32
+ 2. Project each item into a slim `StacItemView` (id, bbox, datetime, cloud cover, platform, etc.) once, and never re-walk the heavy `properties` / `assets` again at filter time.
33
+ 3. Build a `FacetSet` from the projected views. The builder *auto-detects* which controls have variance: numeric facets only emit when there are >=2 distinct finite values, enum facets only when >=2 distinct values, datetime histogram with `DATETIME_HISTOGRAM_BINS = 32` fixed-width bins.
34
+ 4. Maintain a `FacetState` (filter selection) in component state.
35
+ 5. Filter via `applyFacets(views, state)` (pure, never mutates), sort via `sortViews(views, sort)`, render.
36
+
37
+ This module covers steps 2-5 and the type surface the UI consumes.
38
+
39
+ ## Types
40
+
41
+ ### `StacItemView`
42
+
43
+ ```ts
44
+ interface StacItemView {
45
+ id: string;
46
+ collection?: string;
47
+ bbox?: [number, number, number, number];
48
+ datetime?: string;
49
+ endDatetime?: string;
50
+ cloudCover?: number;
51
+ gsd?: number;
52
+ platform?: string;
53
+ constellation?: string;
54
+ instruments?: string[];
55
+ epsg?: number;
56
+ thumbnailHref?: string;
57
+ assetRoles?: string[];
58
+ raw: StacItem;
59
+ }
60
+ ```
61
+
62
+ The slim projection a faceting / strip / inspector layer reads. `raw` is kept as an escape hatch (the inspector's raw-JSON view, the `flyTo` bbox fallback when the projection lacks one).
63
+
64
+ ### `Facet`, `NumericFacet`, `EnumFacet`, `DatetimeFacet`
65
+
66
+ Discriminated union with `kind: 'numeric' | 'enum' | 'datetime'`. Numeric facets carry `min`/`max`/`step`/`histogram?`; enum facets carry `values: { value, count }[]`; datetime facets carry an ISO `min`/`max` and a 32-bin histogram.
67
+
68
+ ### `FacetSet`
69
+
70
+ ```ts
71
+ interface FacetSet {
72
+ numeric: Partial<Record<NumericFacetField, NumericFacet>>;
73
+ enum: Partial<Record<EnumFacetField, EnumFacet>>;
74
+ datetime?: DatetimeFacet;
75
+ }
76
+ ```
77
+
78
+ The shape `buildFacets()` returns. Only fields with variance appear, so a UI can iterate the keys and skip rendering controls that would not narrow this dataset.
79
+
80
+ ### `FacetState`
81
+
82
+ ```ts
83
+ interface FacetState {
84
+ cloudCover?: { min?: number; max?: number };
85
+ gsd?: { min?: number; max?: number };
86
+ datetime?: { min?: string; max?: string };
87
+ collection?: string[];
88
+ platform?: string[];
89
+ constellation?: string[];
90
+ instruments?: string[];
91
+ assetRoles?: string[];
92
+ epsg?: number[];
93
+ }
94
+ ```
95
+
96
+ What the UI reads / writes. Open-bound ranges (one of `min` / `max` undefined) are valid. Empty arrays are treated as no constraint.
97
+
98
+ ### `FacetSort`
99
+
100
+ ```ts
101
+ type FacetSort =
102
+ | 'datetime-desc'
103
+ | 'datetime-asc'
104
+ | 'cloud-asc'
105
+ | 'cloud-desc'
106
+ | 'gsd-asc'
107
+ | 'gsd-desc';
108
+ ```
109
+
110
+ Items missing the sort field always sink to the bottom regardless of asc/desc.
111
+
112
+ ## Functions
113
+
114
+ ### `extractItemView(item)`
115
+
116
+ ```ts
117
+ function extractItemView(item: StacItem): StacItemView;
118
+ ```
119
+
120
+ Pure projection. Reads `properties.datetime`, `properties.start_datetime` / `properties.end_datetime`, `properties['eo:cloud_cover']`, `properties.gsd`, `properties.platform`, `properties.constellation`, `properties.instruments`, `properties['proj:epsg']` / `properties['proj:code']`, and the first asset whose `roles` includes `'thumbnail'` / `'overview'` / `'visual'` (in that order). Returns a `StacItemView` with `raw: item`.
121
+
122
+ Non-finite numerics, missing properties, and malformed CRS strings are normalized to `undefined`.
123
+
124
+ ### `buildFacets(views)`
125
+
126
+ ```ts
127
+ function buildFacets(views: readonly StacItemView[]): FacetSet;
128
+ ```
129
+
130
+ Auto-detects which facets have variance. Numeric facets emit when there are >=2 distinct finite values; enum facets emit when there are >=2 distinct values. The datetime facet emits an ISO `min`/`max` plus a 32-bin histogram (`DATETIME_HISTOGRAM_BINS`) when the views span more than one day.
131
+
132
+ ### `applyFacets(views, state)`
133
+
134
+ ```ts
135
+ function applyFacets(views: readonly StacItemView[], state: FacetState): StacItemView[];
136
+ ```
137
+
138
+ Pure filter. Every constraint in `state` must match. Numeric ranges treat absent items as failing the constraint; enum arrays match if the item's value is in the array (or, for `instruments` / `assetRoles`, if any of the item's values is in the array).
139
+
140
+ ### `sortViews(views, sort)`
141
+
142
+ ```ts
143
+ function sortViews(views: readonly StacItemView[], sort: FacetSort): StacItemView[];
144
+ ```
145
+
146
+ Stable sort. Items missing the sort field sink to the bottom regardless of direction.
147
+
148
+ ### `hasActiveFilters(state)`
149
+
150
+ ```ts
151
+ function hasActiveFilters(state: FacetState): boolean;
152
+ ```
153
+
154
+ True if any field constrains. Use to short-circuit filter computation on hot paths (e.g. only filter the source list when active).
155
+
156
+ ### `emptyFacetState()`
157
+
158
+ ```ts
159
+ function emptyFacetState(): FacetState;
160
+ ```
161
+
162
+ Stable empty constant suitable for `$state(emptyFacetState())`.
163
+
164
+ ## Constants
165
+
166
+ ### `DATETIME_HISTOGRAM_BINS`
167
+
168
+ `32`. Fixed bin count for the datetime facet histogram so the consumer UI can size its bars without re-computing.
169
+
170
+ ## Recipe: build a faceted item strip
171
+
172
+ ```ts
173
+ import {
174
+ extractItemView,
175
+ buildFacets,
176
+ applyFacets,
177
+ emptyFacetState,
178
+ hasActiveFilters,
179
+ type FacetState,
180
+ } from '@walkthru-earth/objex-utils';
181
+
182
+ const views = items.map(extractItemView);
183
+ const facets = buildFacets(views);
184
+ let state: FacetState = emptyFacetState();
185
+
186
+ function setFilter(next: FacetState): void {
187
+ state = next;
188
+ const filtered = hasActiveFilters(state) ? applyFacets(views, state) : views;
189
+ renderStrip(filtered);
190
+ renderFootprints(filtered);
191
+ }
192
+ ```
193
+
194
+ ## Caveats
195
+
196
+ - This module is **client-side only**. For server push-down (STAC API native parameters and CQL2-JSON), pair it with [`stac-pushdown`](./stac-pushdown.md): translate the `FacetState` into a native query, send to the server, then `applyFacets(views, residualState(state, caps))` for whatever the server could not honor.
197
+ - `buildFacets` is O(n) over `views`. For very large catalogs (>50 k items) call it once per committed render set, not per filter change.
198
+ - The datetime histogram bins are computed in UTC (`Date.parse` / `Date.UTC`). A consumer rendering local-time tick labels is responsible for the timezone conversion.
@@ -0,0 +1,181 @@
1
+ # stac-hydrate
2
+
3
+ STAC link-following hydrator. Walks a classified STAC payload into a flat `StacItem[]` by following `links[rel=item]` (Collection), `links[rel=child]` then `links[rel=item]` (Catalog), the `rel="items"` endpoint (OGC API Features / STAC API), and `links[rel=next]` pagination (FeatureCollection / STAC API). Pure async TypeScript, zero Svelte / DuckDB / deck.gl dependency. Fetches through a caller-supplied `StorageAdapter` so private buckets can be walked.
4
+
5
+ Source: `packages/objex-utils/src/stac-hydrate.ts`.
6
+
7
+ ```ts
8
+ import {
9
+ type HydrateOptions,
10
+ type StacItemsQuery,
11
+ type HydrateResult,
12
+ hydrateStacItems,
13
+ hasStacItemsEndpoint,
14
+ absolutizeHref,
15
+ } from '@walkthru-earth/objex-utils';
16
+ ```
17
+
18
+ ## Concept
19
+
20
+ `classifyStac` (from [`stac`](./stac.md)) turns parsed JSON into a `StacRoutableKind`. `hydrateStacItems` takes that verdict plus the URL it was fetched from and recursively walks links into a flat item list, emitting batches as it goes for progressive rendering. It branches by variant:
21
+
22
+ - `item`: a single Item, emitted immediately with its asset hrefs absolutized.
23
+ - `item-collection`: a FeatureCollection, items accepted then `rel="next"` pages followed (when `followPagination`).
24
+ - `collection` / `catalog`: walks static `rel="item"` links if present, else the `rel="items"` endpoint (STAC API convention), else recurses into `rel="child"` links with a bounded worker pool.
25
+
26
+ All fetches route through the supplied `StorageAdapter`: absolute URLs that the optional `urlToKey` maps to a bucket key are read via `adapter.read` (so SigV4 presigning applies), foreign origins fall back to a raw `fetch`, and relative hrefs go straight to `adapter.read`.
27
+
28
+ ## Types
29
+
30
+ ### `HydrateOptions`
31
+
32
+ ```ts
33
+ interface HydrateOptions {
34
+ signal: AbortSignal;
35
+ concurrency?: number;
36
+ limit?: number;
37
+ followPagination?: boolean;
38
+ onBatch?: (items: StacItem[]) => void;
39
+ onProgress?: (fetched: number, totalHinted: number | undefined) => void;
40
+ urlToKey?: (absoluteUrl: string) => string | null;
41
+ itemsQuery?: StacItemsQuery;
42
+ }
43
+ ```
44
+
45
+ - `signal` -- required abort signal, threaded into every adapter read and `fetch`.
46
+ - `concurrency` -- max parallel fetches for item links and child walks. Default `12`.
47
+ - `limit` -- hard cap on items, catalogs larger than this are truncated. Default `2000`.
48
+ - `followPagination` -- follow `links[rel=next]` in FeatureCollections. Default `true`.
49
+ - `onBatch` -- called with each newly fetched batch for progressive rendering. Items in a batch already have absolutized asset hrefs.
50
+ - `onProgress` -- called after each emit with the running item count, `totalHinted` is always `undefined` in the current implementation.
51
+ - `urlToKey` -- maps an absolute HTTPS URL to a bucket-relative key when it belongs to the caller's connection. When it returns a non-null string, the fetch routes through `adapter.read` (SigV4) instead of cross-origin `fetch`, so private-bucket catalogs can be walked. Return `null` for foreign origins.
52
+ - `itemsQuery` -- optional native STAC API filters appended to the `rel="items"` endpoint and re-stamped on every `rel="next"` page. See `StacItemsQuery`.
53
+
54
+ ### `StacItemsQuery`
55
+
56
+ ```ts
57
+ interface StacItemsQuery {
58
+ bbox?: [number, number, number, number];
59
+ datetime?: string;
60
+ limit?: number;
61
+ filter?: unknown;
62
+ }
63
+ ```
64
+
65
+ Native filters supported by OGC API Features / STAC API on `/items`.
66
+
67
+ - `bbox` -- WGS84 `[west, south, east, north]`, stamped as `?bbox=w,s,e,n`.
68
+ - `datetime` -- RFC 3339 instant or interval `start/end` (use `..` for open ends).
69
+ - `limit` -- per-page item count hint, floored to an integer, the server may cap it.
70
+ - `filter` -- a CQL2-JSON filter expression (STAC API Filter extension). When set it is appended as `?filter=<json>&filter-lang=cql2-json` and re-stamped onto every `rel="next"` page so cursor URLs cannot strip it.
71
+
72
+ Each param is only stamped when the URL does not already carry it, so a server that echoes the original filter on its cursor links is not double-stamped (which would corrupt the JSON). `filter` is `JSON.stringify`-ed, a stringify failure (cyclic input) is swallowed and hydration continues without the filter.
73
+
74
+ ### `HydrateResult`
75
+
76
+ ```ts
77
+ interface HydrateResult {
78
+ items: StacItem[];
79
+ truncated: boolean;
80
+ rootBaseHref: string;
81
+ }
82
+ ```
83
+
84
+ The aggregate result. `items` is the flat list capped at `limit`. `truncated` is true when the catalog exceeded `limit` or a sub-walk truncated. `rootBaseHref` echoes the `baseHref` argument the walk started from.
85
+
86
+ ## Functions
87
+
88
+ ### `hydrateStacItems(root, baseHref, adapter, opts)`
89
+
90
+ ```ts
91
+ function hydrateStacItems(
92
+ root: StacRoutableKind,
93
+ baseHref: string,
94
+ adapter: StorageAdapter,
95
+ opts: HydrateOptions
96
+ ): Promise<HydrateResult>;
97
+ ```
98
+
99
+ Walk `root` into a flat `StacItem[]`.
100
+
101
+ - `root` -- the classified payload from `classifyStac`. A `kind: 'none'` root yields an empty, non-truncated result.
102
+ - `baseHref` -- the URL `root` was fetched from. All relative hrefs (child links, item links, asset hrefs) resolve against this.
103
+ - `adapter` -- the `StorageAdapter` every fetch routes through (see `urlToKey` for the routing rule).
104
+ - `opts` -- see `HydrateOptions`.
105
+
106
+ Behavior by `root.kind`:
107
+
108
+ - `item` -- emits the single Item (asset hrefs absolutized against `baseHref`) and returns immediately, never truncated.
109
+ - `item-collection` -- accepts every valid feature, then follows `rel="next"` recursively when `followPagination` and the running count is under `limit`. The `itemsQuery` is re-stamped on each next URL.
110
+ - `collection` / `catalog` -- if the payload has static `rel="item"` links it fetches them with the worker pool. Otherwise, when no static item links exist, it tries the single `rel="items"` endpoint (with `itemsQuery` applied) and consumes it as a paginated FeatureCollection. A Catalog (or an item-link-less Collection) then also recurses into `rel="child"` links with up to `concurrency` workers, each child re-entering `hydrateStacItems` with a `limit` reduced by the count already gathered.
111
+
112
+ Aborting via `opts.signal` stops the walk, in-flight reads reject with the adapter's abort behavior. Dead links and unreachable children are skipped, not fatal, so a partial catalog still hydrates. The returned `items` is sliced to `limit`.
113
+
114
+ The `rel="items"` versus `rel="item"` distinction is load-bearing: STAC API endpoints (earth-search, planetary-computer, pgstac) advertise a single `rel="items"` link to a paginated FeatureCollection, while static self-contained catalogs use one `rel="item"` link per item file. The walker only consults the items endpoint when no static item links are present.
115
+
116
+ ### `hasStacItemsEndpoint(payload)`
117
+
118
+ ```ts
119
+ function hasStacItemsEndpoint(payload: StacCollection | StacCatalog): boolean;
120
+ ```
121
+
122
+ True when `payload` exposes a `rel="items"` link, the OGC API Features / STAC API convention. Lets callers detect a server-backed collection up front and switch to viewport-scoped fetching (passing an `itemsQuery` bbox) instead of walking every page of a static catalog.
123
+
124
+ ### `absolutizeHref(href, baseHref)`
125
+
126
+ ```ts
127
+ function absolutizeHref(href: string, baseHref: string): string;
128
+ ```
129
+
130
+ Resolve a possibly-relative href against `baseHref` via `new URL(href, baseHref)`. Hrefs that already carry an `http(s):`, `s3:`, or `azure:` scheme are returned unchanged. A `new URL` failure returns the original `href` unchanged. STAC catalogs commonly use `./child/foo.json` or `../foo.json`, both of which resolve correctly.
131
+
132
+ ## Example
133
+
134
+ ```ts
135
+ import {
136
+ classifyStac,
137
+ hydrateStacItems,
138
+ hasStacItemsEndpoint,
139
+ type StacItem,
140
+ } from '@walkthru-earth/objex-utils';
141
+ import type { StorageAdapter } from '@walkthru-earth/objex-utils';
142
+
143
+ async function loadCatalog(
144
+ url: string,
145
+ adapter: StorageAdapter,
146
+ bbox: [number, number, number, number],
147
+ signal: AbortSignal,
148
+ ) {
149
+ const json = await fetch(url, { signal }).then((r) => r.json());
150
+ const root = classifyStac(json);
151
+ if (root.kind === 'none') throw new Error('Not a STAC payload');
152
+
153
+ // Map our own bucket's HTTPS URLs back to keys so private reads use SigV4.
154
+ const urlToKey = (abs: string): string | null => {
155
+ const prefix = 'https://my-bucket.s3.amazonaws.com/';
156
+ return abs.startsWith(prefix) ? abs.slice(prefix.length) : null;
157
+ };
158
+
159
+ // Viewport-scope when the server supports it; otherwise walk everything.
160
+ const itemsQuery =
161
+ (root.kind === 'collection' || root.kind === 'catalog') &&
162
+ hasStacItemsEndpoint(root.payload)
163
+ ? { bbox, limit: 250 }
164
+ : undefined;
165
+
166
+ const all: StacItem[] = [];
167
+ const { items, truncated } = await hydrateStacItems(root, url, adapter, {
168
+ signal,
169
+ limit: 2000,
170
+ itemsQuery,
171
+ urlToKey,
172
+ onBatch: (batch) => all.push(...batch), // render progressively
173
+ });
174
+
175
+ return { items, truncated };
176
+ }
177
+ ```
178
+
179
+ ## Peer dependencies
180
+
181
+ None bundled. `hydrateStacItems` and `createApiSource` / `createStaticSource` (see [`stac-source`](./stac-source.md)) depend only on the caller-supplied `StorageAdapter` interface (re-exported from this package's host-side surface) and the global `fetch` for foreign origins. No DuckDB, Svelte, maplibre, or deck.gl on the import graph.