diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 38f557d..ddf0b64 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -2,7 +2,7 @@ name: Package checks on: pull_request: - paths: ['crates/**', 'Cargo.toml', 'Cargo.lock', 'scripts/**', '.github/workflows/CI.yml', '.github/workflows/publish.yml'] + paths: ['crates/**', '!crates/wasm/**', 'Cargo.toml', 'Cargo.lock', 'scripts/**', '.github/workflows/CI.yml', '.github/workflows/publish.yml'] workflow_dispatch: workflow_call: @@ -26,6 +26,11 @@ jobs: - run: cargo test -p superstac-cli -p superstac-engine --features geoparquet --locked - run: cargo check -p superstac-python --no-default-features --locked + wasm: + # Pull requests run the standalone WASM workflow, including WASM-only changes. + if: github.event_name != 'pull_request' + uses: ./.github/workflows/wasm.yml + wheels: name: Wheel (${{ matrix.target }}) runs-on: ${{ matrix.runner }} diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 0000000..e3fc7b5 --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,42 @@ +name: WASM checks + +on: + pull_request: + paths: + - 'crates/wasm/**' + - 'crates/core/**' + - 'crates/search/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/wasm.yml' + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + browser: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: '1.88.0' + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: crates/wasm/package-lock.json + - run: cargo install wasm-pack --version 0.13.1 --locked --jobs 2 + - run: npm ci + working-directory: crates/wasm + - run: npm run build + working-directory: crates/wasm + - run: npx playwright install --with-deps chromium + working-directory: crates/wasm + - run: npm run check + working-directory: crates/wasm diff --git a/.gitignore b/.gitignore index 198408c..520d818 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,7 @@ ideas/ .env .env.local .pnpm-store/ +# Browser binding artifacts +/crates/wasm/pkg/ +/crates/wasm/node_modules/ node_modules/ diff --git a/Cargo.lock b/Cargo.lock index e2dd50e..b768127 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,7 +648,7 @@ checksum = "74b72642b4c3243fb843c3beb0ec9d0406eecfaa7e01d172e7cdfa437605c884" dependencies = [ "geo 0.31.0", "geo-types", - "geojson", + "geojson 0.24.2", "geozero", "jiff", "json_dotpath", @@ -1186,6 +1186,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "geojson" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510c094bfc76ea34d02eee00833254945b70491d79a9c0b050abed6eaa799ffb" +dependencies = [ + "geo-types", + "log", + "serde", + "serde_json", + "thiserror 2.0.18", + "tinyvec", +] + [[package]] name = "geoparquet" version = "0.6.2" @@ -1216,7 +1230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5f28f34864745eb2f123c990c6ffd92c1584bd39439b3f27ff2a0f4ea5b309b" dependencies = [ "geo-types", - "geojson", + "geojson 0.24.2", "log", "serde_json", "thiserror 1.0.69", @@ -1250,6 +1264,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "granit-parser" version = "0.0.2" @@ -3138,6 +3164,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3356,7 +3393,7 @@ dependencies = [ "geo-types", "geoarrow-array", "geoarrow-schema", - "geojson", + "geojson 0.24.2", "geoparquet", "indexmap 2.13.0", "log", @@ -3372,6 +3409,28 @@ dependencies = [ "wkb", ] +[[package]] +name = "stac" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a069e4454aec3e4cf9340d2b2b242764086e23c452f1ff3deff29b30b2bf0b60" +dependencies = [ + "bytes", + "chrono", + "cql2", + "geojson 1.0.0", + "indexmap 2.13.0", + "log", + "mime", + "serde", + "serde_json", + "serde_urlencoded", + "stac-derive", + "thiserror 2.0.18", + "tracing", + "url", +] + [[package]] name = "stac-derive" version = "0.3.0" @@ -3395,7 +3454,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "stac", + "stac 0.16.3", "thiserror 2.0.18", "tokio", "tracing", @@ -3434,7 +3493,7 @@ dependencies = [ "clap", "serde", "serde_json", - "stac", + "stac 0.16.3", "superstac-config", "superstac-core", "superstac-engine", @@ -3474,7 +3533,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "stac", + "stac 0.16.3", "stac-io", "superstac-core", "superstac-geoparquet", @@ -3498,7 +3557,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "stac", + "stac 0.16.3", "superstac-core", "superstac-search", "tempfile", @@ -3529,16 +3588,35 @@ name = "superstac-search" version = "0.3.0" dependencies = [ "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "gloo-timers", "reqwest", "serde", "serde_json", - "stac", + "stac 0.16.3", + "stac 0.17.0", "stac-io", "superstac-core", "tokio", "tracing", ] +[[package]] +name = "superstac-wasm" +version = "0.3.0" +dependencies = [ + "futures", + "reqwest", + "serde", + "serde-wasm-bindgen", + "serde_json", + "superstac-core", + "superstac-search", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "syn" version = "2.0.106" @@ -3725,6 +3803,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ + "serde", "tinyvec_macros", ] diff --git a/Cargo.toml b/Cargo.toml index b531f6b..6e79e89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/cli", "crates/python", "crates/geoparquet", + "crates/wasm", ] [workspace.package] diff --git a/README.md b/README.md index 3313d41..202871c 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,15 @@ Run `superstac --help` for commands and options. The [configuration reference](https://spatialnode.com/superstac/docs/reference/configuration) explains aliases, timeouts, and retries. +## Browser / WebAssembly + +Build the browser binding to search live STAC catalogs from JavaScript or TypeScript. +See the [WASM guide](crates/wasm/README.md) for build instructions, API usage, +a browser example, and browser-specific limitations. The docs include a +[live playground](https://spatialnode.com/superstac/docs/wasm/playground) +and a [JavaScript guide](https://spatialnode.com/superstac/docs/wasm/overview). + +## Configuration ## Save metadata for repeated searches GeoParquet inventories let you reuse metadata for an area and date range. Search diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index eebceb4..5ce99ad 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -17,12 +17,21 @@ authors.workspace = true superstac-core = { path = "../core", version = "0.3.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["time"] } futures = "0.3" -stac = "0.16" -stac-io = "0.2" -reqwest = { version = "0.13", default-features = false } +reqwest = { version = "0.13", default-features = false, features = ["json"] } tracing = "0.1" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", features = ["time"] } +stac-io = "0.2" +stac = "0.16" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# 0.17.0 gates filesystem APIs; later releases conflict with native geo dependencies. +stac = { version = "=0.17.0", default-features = false } +getrandom = { version = "0.3", features = ["wasm_js"] } +getrandom02 = { package = "getrandom", version = "0.2", features = ["js"] } +gloo-timers = { version = "0.3", features = ["futures"] } + [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } diff --git a/crates/search/src/backend.rs b/crates/search/src/backend.rs index e8e4fb9..97def11 100644 --- a/crates/search/src/backend.rs +++ b/crates/search/src/backend.rs @@ -1,5 +1,18 @@ //! Search contracts shared by remote and file-backed implementations. -use futures::future::BoxFuture; +/// Futures run on the browser event loop on WASM; native backends remain Send. +#[cfg(not(target_arch = "wasm32"))] +pub type SearchFuture<'a, T> = futures::future::BoxFuture<'a, T>; +#[cfg(target_arch = "wasm32")] +pub type SearchFuture<'a, T> = futures::future::LocalBoxFuture<'a, T>; + +#[cfg(not(target_arch = "wasm32"))] +pub trait BackendBounds: Send + Sync {} +#[cfg(not(target_arch = "wasm32"))] +impl BackendBounds for T {} +#[cfg(target_arch = "wasm32")] +pub trait BackendBounds {} +#[cfg(target_arch = "wasm32")] +impl BackendBounds for T {} use superstac_core::{errors::SuperSTACError, models::catalog::Catalog}; use crate::{query::SearchQuery, response::SearchItem}; @@ -21,13 +34,13 @@ pub struct BackendSearchOptions { /// Dataset locations can be held by the backend; `Catalog::url` is only an API /// endpoint for the STAC backend. This interface does not perform health checks /// or ingestion. Boxed futures allow runtime selection via `dyn SearchBackend`. -pub trait SearchBackend: Send + Sync { +pub trait SearchBackend: BackendBounds { fn search<'a>( &'a self, catalog: &'a Catalog, query: SearchQuery, options: BackendSearchOptions, - ) -> BoxFuture<'a, Result, SuperSTACError>>; + ) -> SearchFuture<'a, Result, SuperSTACError>>; } impl SearchBackend for std::sync::Arc { @@ -36,7 +49,7 @@ impl SearchBackend for std::sync::Arc { catalog: &'a Catalog, query: SearchQuery, options: BackendSearchOptions, - ) -> BoxFuture<'a, Result, SuperSTACError>> { + ) -> SearchFuture<'a, Result, SuperSTACError>> { (**self).search(catalog, query, options) } } diff --git a/crates/search/src/browser.rs b/crates/search/src/browser.rs new file mode 100644 index 0000000..3a0e2e1 --- /dev/null +++ b/crates/search/src/browser.rs @@ -0,0 +1,148 @@ +//! Browser STAC transport. Pagination is polled directly without Tokio tasks. +use reqwest::{Client, Method, Url}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use superstac_core::errors::SuperSTACError; + +fn error(e: impl std::fmt::Display) -> SuperSTACError { + SuperSTACError::SearchFailed(e.to_string()) +} + +#[derive(Deserialize)] +struct Link { + rel: String, + href: String, + method: Option, + body: Option, + #[serde(default)] + headers: std::collections::BTreeMap, + #[serde(default)] + merge: bool, +} + +#[derive(Deserialize)] +struct Page { + #[serde(default)] + links: Vec, + #[serde(flatten)] + fields: Map, +} + +/// Read paginated collections, bounding the result to avoid unbounded allocation. +pub async fn collections( + client: &Client, + base: &str, + cap: usize, +) -> Result, SuperSTACError> { + pages( + client, + endpoint(base, "collections")?, + Method::GET, + None, + "collections", + cap, + ) + .await +} + +pub async fn search( + client: &Client, + base: &str, + search: &stac::api::Search, + cap: usize, +) -> Result, SuperSTACError> { + pages( + client, + endpoint(base, "search")?, + Method::POST, + Some(serde_json::to_value(search).map_err(error)?), + "features", + cap, + ) + .await? + .into_iter() + .map(|item| serde_json::from_value(item).map_err(error)) + .collect() +} + +fn endpoint(base: &str, path: &str) -> Result { + Url::parse(&format!("{}/{}", base.trim_end_matches('/'), path)).map_err(error) +} + +async fn pages( + client: &Client, + mut url: Url, + mut method: Method, + mut body: Option, + field: &str, + cap: usize, +) -> Result, SuperSTACError> { + let mut items = Vec::new(); + let mut headers = std::collections::BTreeMap::::new(); + let mut visited = std::collections::HashSet::new(); + while items.len() < cap { + let signature = format!("{method} {url} {body:?} {headers:?}"); + if !visited.insert(signature) || visited.len() > 10_000 { + return Err(error("pagination did not terminate")); + } + let mut request = client.request(method.clone(), url.clone()); + if method == Method::POST { + if let Some(value) = &body { + request = request.json(value); + } + } + for (key, value) in &headers { + request = request.header(key, value); + } + let response = request + .send() + .await + .map_err(error)? + .error_for_status() + .map_err(error)?; + let page_url = response.url().clone(); + let mut page: Page = response.json().await.map_err(error)?; + let values = page + .fields + .remove(field) + .ok_or_else(|| error(format!("missing {field}")))?; + let values = values + .as_array() + .ok_or_else(|| error(format!("{field} must be an array")))?; + items.extend(values.iter().take(cap - items.len()).cloned()); + if items.len() == cap { + break; + } + let Some(next) = page.links.into_iter().find(|link| link.rel == "next") else { + break; + }; + url = page_url.join(&next.href).map_err(error)?; + if !matches!(url.scheme(), "http" | "https") { + return Err(error("pagination URL must use HTTP or HTTPS")); + } + method = Method::from_bytes(next.method.as_deref().unwrap_or("GET").as_bytes()) + .map_err(error)?; + if method != Method::GET && method != Method::POST { + return Err(error("pagination supports only GET and POST")); + } + if next.merge && method == Method::POST { + let mut merged = body.take().unwrap_or_else(|| Value::Object(Map::new())); + let target = merged + .as_object_mut() + .ok_or_else(|| error("pagination body must be an object"))?; + if let Some(extra) = next.body { + target.extend( + extra + .as_object() + .ok_or_else(|| error("pagination body must be an object"))? + .clone(), + ); + } + body = Some(merged); + } else { + body = next.body; + } + headers = next.headers; + } + Ok(items) +} diff --git a/crates/search/src/executor.rs b/crates/search/src/executor.rs index aaf9721..2195fdf 100644 --- a/crates/search/src/executor.rs +++ b/crates/search/src/executor.rs @@ -1,6 +1,6 @@ use futures::StreamExt; use superstac_core::{errors::SuperSTACError, models::catalog::Catalog}; -use tokio::time::{sleep, timeout}; +use crate::runtime::{sleep, timeout}; use crate::{ aggregator::SearchAggregator, diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs index 7d91525..0c0b593 100644 --- a/crates/search/src/lib.rs +++ b/crates/search/src/lib.rs @@ -20,3 +20,7 @@ pub mod unifier; pub mod backend; pub mod stac_api; + +pub mod runtime; +#[cfg(target_arch = "wasm32")] +pub mod browser; diff --git a/crates/search/src/runtime.rs b/crates/search/src/runtime.rs new file mode 100644 index 0000000..7c563fa --- /dev/null +++ b/crates/search/src/runtime.rs @@ -0,0 +1,29 @@ +//! Target-specific timers shared by federation and browser discovery. +#[cfg(not(target_arch = "wasm32"))] +pub use tokio::time::{sleep, timeout}; + +#[cfg(target_arch = "wasm32")] +pub async fn sleep(duration: std::time::Duration) { + // Browser timers use signed 32-bit milliseconds. Split larger durations. + let mut remaining = duration.as_millis(); + loop { + let millis = remaining.min(i32::MAX as u128) as u32; + gloo_timers::future::TimeoutFuture::new(millis).await; + remaining -= u128::from(millis); + if remaining == 0 { + break; + } + } +} + +#[cfg(target_arch = "wasm32")] +pub async fn timeout( + duration: std::time::Duration, + future: F, +) -> Result { + use futures::future::{select, Either}; + match select(Box::pin(future), Box::pin(sleep(duration))).await { + Either::Left((output, _)) => Ok(output), + Either::Right(_) => Err("request timed out"), + } +} diff --git a/crates/search/src/stac_api.rs b/crates/search/src/stac_api.rs index 1cf1528..60aa313 100644 --- a/crates/search/src/stac_api.rs +++ b/crates/search/src/stac_api.rs @@ -1,10 +1,11 @@ //! Item retrieval from a STAC API, including pagination. -use futures::{future::BoxFuture, StreamExt, TryStreamExt}; +#[cfg(not(target_arch = "wasm32"))] +use futures::{StreamExt, TryStreamExt}; use stac::Item; use superstac_core::{errors::SuperSTACError, models::catalog::Catalog}; use crate::{ - backend::{BackendSearchOptions, SearchBackend}, + backend::{BackendSearchOptions, SearchBackend, SearchFuture}, query::SearchQuery, response::SearchItem, translator::to_stac_search, @@ -44,22 +45,32 @@ impl StacApiBackend { let search = to_stac_search(query); - let stac_client = stac_io::api::Client::with_client(self.client.clone(), &catalog.url) - .map_err(|e| SuperSTACError::SearchFailed(format!("stac client init: {}", e)))?; + if cap == 0 { + return Ok(Vec::new()); + } + #[cfg(not(target_arch = "wasm32"))] + let raw_items: Vec = { + let stac_client = stac_io::api::Client::with_client(self.client.clone(), &catalog.url) + .map_err(|e| SuperSTACError::SearchFailed(format!("stac client init: {}", e)))?; - let stream = stac_client - .search(search) - .await - .map_err(|e| SuperSTACError::SearchFailed(format!("search request: {}", e)))?; + let stream = stac_client + .search(search) + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("search request: {}", e)))?; - // Stream of `stac::api::Item` (= `serde_json::Map`), - // paginated internally by stac-io. `take(cap)` bounds the total; - // `try_collect` short-circuits on first per-item stream error. - let raw_items: Vec = stream - .take(cap) - .try_collect() - .await - .map_err(|e| SuperSTACError::SearchFailed(format!("stream item: {}", e)))?; + // Stream of `stac::api::Item` (= `serde_json::Map`), + // paginated internally by stac-io. `take(cap)` bounds the total; + // `try_collect` short-circuits on first per-item stream error. + let raw_items: Vec = stream + .take(cap) + .try_collect() + .await + .map_err(|e| SuperSTACError::SearchFailed(format!("stream item: {}", e)))?; + + raw_items + }; + #[cfg(target_arch = "wasm32")] + let raw_items = crate::browser::search(&self.client, &catalog.url, &search, cap).await?; let items: Vec = raw_items .into_iter() @@ -89,7 +100,7 @@ impl SearchBackend for StacApiBackend { catalog: &'a Catalog, query: SearchQuery, options: BackendSearchOptions, - ) -> BoxFuture<'a, Result, SuperSTACError>> { + ) -> SearchFuture<'a, Result, SuperSTACError>> { Box::pin(self.search_catalog(catalog, query, options)) } } diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..cdad0eb --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "superstac-wasm" +description = "Browser WebAssembly bindings for SuperSTAC federated search" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" + +[lib] +crate-type = ["cdylib", "rlib"] + +[target.'cfg(target_arch = "wasm32")'.dependencies] +superstac-core = { path = "../core", version = "0.3.0" } +superstac-search = { path = "../search", version = "0.3.0" } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde-wasm-bindgen = "0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.13", default-features = false, features = ["json"] } +futures = "0.3" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/crates/wasm/README.md b/crates/wasm/README.md new file mode 100644 index 0000000..977c716 --- /dev/null +++ b/crates/wasm/README.md @@ -0,0 +1,119 @@ +# SuperSTAC WebAssembly + +Run live federated STAC searches in a browser using the shared Rust search logic. +This package supports catalog configuration, collection discovery, pagination, +collection/asset aliases, deduplication, provenance, concurrency limits, retries, +per-attempt timeouts, and per-catalog failures. + +## Build and try + +From the repository root, using Rust 1.88 or newer: + +```sh +rustup target add wasm32-unknown-unknown +cargo install wasm-pack --version 0.13.1 --locked +wasm-pack build crates/wasm --target web --release --locked --jobs 1 +python3 -m http.server 8000 --directory crates/wasm +``` + +Open http://localhost:8000/examples/ to try a live Earth Search query. +`crates/wasm/pkg` contains the generated JavaScript module, WebAssembly binary, +TypeScript declarations, and package metadata. Build artifacts are ignored by Git. +The package has not been published to npm. + +The docs also include a [browser guide](../../docs/content/docs/wasm/overview.md) +and [live playground](../../docs/content/docs/wasm/playground.mdx). For local docs +work, `node docs/scripts/prepare-wasm.mjs` from the repository root copies this +build into the docs assets without compiling Rust. + +## JavaScript / TypeScript + +```ts +import init, { SuperSTAC } from './pkg/superstac_wasm.js'; + +await init(); +const client = new SuperSTAC({ + catalogs: [{ + id: 'earth-search', + url: 'https://earth-search.aws.element84.com/v1', + // Optional maps: canonical name -> provider-local name. + collection_aliases: {}, + asset_aliases: {}, + }], + settings: { + max_concurrent_catalogs: 8, + per_catalog_timeout_seconds: 30, + max_retry_attempts: 2, // Total attempts, including the first request. + max_items_per_catalog: 1000, + deduplicate_items: true, + unify_response: true, + }, +}); + +const result = await client.search({ + collections: ['sentinel-2-l2a'], + bbox: [6, 49, 7, 50], + limit: 20, +}); +console.log(result.items, result.metadata.failures); +console.log(await client.listCollections()); +client.free(); // Once all outstanding calls have completed. +``` + +`search()` returns a Promise containing `{ items, metadata }`. Each result entry +has `item` (the STAC Feature), `catalog_id`, and `seen_in` provenance. It is not +itself a GeoJSON FeatureCollection. Missing `collections` means no collection +filter. `limit` defaults to 10 and applies **per catalog**, capped by +`max_items_per_catalog`; deduplication then merges the results. + +Invalid configuration throws a JavaScript Error; invalid queries reject the +Promise. Individual catalog failures, including timeouts and CORS failures, +are recorded in `metadata.failures`. Even when every catalog fails, the Promise +resolves with an empty result and failure metadata: always inspect it. + +`listCollections()` returns one entry per catalog with `collections` and a +nullable `error`. Each collection includes its `canonical_id` and original +`collection` metadata. Discovery follows next links, uses the configured +concurrency/timeout, and limits results to 10,000 collections per catalog. +It does not retry or change which catalogs subsequent searches query. + +## Browser scope + +- Endpoints must allow browser requests through CORS. A server-side proxy may + be needed for catalogs that do not. WASM cannot bypass browser restrictions. +- The first search request uses STAC POST `/search`; pagination supports GET + and POST next links, relative URLs, headers, and merged POST bodies. +- Configuration stays in memory. No filesystem YAML loading, GeoParquet, + background health monitoring, or engine capability-based catalog filtering. +- This build targets browsers. Node.js and WASI are not validated targets. +- Browser timers can be delayed in background tabs. Timeouts cover each full + paginated search attempt, and failed attempts discard their partial items. +- Native search uses `stac` 0.16 and `stac-io`; WASM uses `stac` 0.17.0 without + filesystem features and a Fetch transport. The exact WASM version is pinned + because newer STAC releases conflict with the native GeoParquet dependency graph. WASM backend futures are local; + native backend futures retain their Send bounds. + +## Tests + +```sh +cd crates/wasm +npm ci +npm run build +npx playwright install chromium +npm run check +``` + +For routine checks against an already-built package, run just `npm run check`. +It type-checks the public API and runs one headless browser sequentially; it does +not compile Rust or run the native workspace tests. `npm run build` uses one +Cargo worker. The dedicated **WASM checks** GitHub Actions workflow builds from +source and runs the same checks on CI; it can be run manually on its own. + +The TypeScript check includes `ESNext.Disposable` because the generated bindings +expose `Symbol.dispose`. You can still use `client.free()` explicitly. + +Set `CHROME_PATH` to use an existing Chrome executable. The browser suite runs +against a local STAC fixture server and checks pagination, retries, timeouts, +aliases, deduplication, result serialization, validation, and partial failures. +Set `SUPERSTAC_LIVE_TEST=1 npm test` to also query Earth Search; this optional +check needs internet access and the provider's CORS support. diff --git a/crates/wasm/examples/index.html b/crates/wasm/examples/index.html new file mode 100644 index 0000000..4bb3d51 --- /dev/null +++ b/crates/wasm/examples/index.html @@ -0,0 +1,39 @@ + + + + +SuperSTAC browser search + +

SuperSTAC browser search

+

Search Sentinel-2 scenes over western Europe in January 2024 using Earth Search.

+ +
Loading WebAssembly…
+ + diff --git a/crates/wasm/package-lock.json b/crates/wasm/package-lock.json new file mode 100644 index 0000000..dea8b82 --- /dev/null +++ b/crates/wasm/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "superstac-wasm-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "superstac-wasm-tests", + "devDependencies": { + "playwright": "1.56.1", + "typescript": "5.9.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/crates/wasm/package.json b/crates/wasm/package.json new file mode 100644 index 0000000..26d10b8 --- /dev/null +++ b/crates/wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "superstac-wasm-tests", + "private": true, + "type": "module", + "scripts": { + "build": "wasm-pack build --target web --release --locked --jobs 1", + "test": "node tests/browser.mjs", + "check:types": "tsc -p tsconfig.json", + "check": "npm run check:types && npm test" + }, + "devDependencies": { + "playwright": "1.56.1", + "typescript": "5.9.3" + } +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..9ba436e --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,196 @@ +//! JavaScript bindings for browser-hosted live STAC searches. +#![cfg(target_arch = "wasm32")] + +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; +use superstac_core::models::catalog::Catalog; +use superstac_search::{executor::SearchExecutor, options::FederationOptions, query::SearchQuery}; +use wasm_bindgen::prelude::*; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Config { + catalogs: Vec, + #[serde(default)] + settings: Settings, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CatalogConfig { + id: String, + url: String, + #[serde(default)] + collection_aliases: HashMap, + #[serde(default)] + asset_aliases: HashMap>, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Settings { + deduplicate_items: bool, + unify_response: bool, + max_concurrent_catalogs: usize, + per_catalog_timeout_seconds: u32, + max_retry_attempts: u8, + max_items_per_catalog: usize, +} + +impl Default for Settings { + fn default() -> Self { + Self { + deduplicate_items: true, + unify_response: true, + max_concurrent_catalogs: 8, + per_catalog_timeout_seconds: 30, + max_retry_attempts: 2, + max_items_per_catalog: 1000, + } + } +} + +fn error(value: impl std::fmt::Display) -> JsError { + JsError::new(&value.to_string()) +} + +fn to_js(value: &impl Serialize) -> Result { + value + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + .map_err(error) +} + +#[wasm_bindgen] +pub struct SuperSTAC { + catalogs: Vec, + client: reqwest::Client, + options: FederationOptions, +} + +#[wasm_bindgen] +impl SuperSTAC { + #[wasm_bindgen(constructor)] + pub fn new(config: ConfigInput) -> Result { + let config: Config = serde_wasm_bindgen::from_value(config.into()).map_err(error)?; + let settings = config.settings; + if config.catalogs.is_empty() { + return Err(error("at least one catalog is required")); + } + if settings.max_concurrent_catalogs == 0 + || settings.per_catalog_timeout_seconds == 0 + || settings.max_retry_attempts == 0 + || settings.max_items_per_catalog == 0 + { + return Err(error( + "concurrency, timeout, attempts, and item cap must be positive", + )); + } + let mut ids = HashSet::new(); + let mut catalogs = Vec::new(); + for input in config.catalogs { + if !ids.insert(input.id.clone()) { + return Err(error("duplicate catalog id")); + } + let url = reqwest::Url::parse(&input.url).map_err(error)?; + if !matches!(url.scheme(), "http" | "https") + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + || url.password().is_some() + { + return Err(error("catalog URL must be an HTTP(S) base URL without credentials, query, or fragment")); + } + let mut catalog = + Catalog::new(&input.id, None::, &input.url, None::, None) + .map_err(error)?; + catalog.collection_aliases = input.collection_aliases; + catalog.asset_aliases = input.asset_aliases; + catalogs.push(catalog); + } + let mut options = FederationOptions::default(); + options.deduplicate = settings.deduplicate_items; + options.unify_response = settings.unify_response; + options.max_concurrent = settings.max_concurrent_catalogs; + options.per_catalog_timeout = + Duration::from_secs(settings.per_catalog_timeout_seconds.into()); + options.retry.max_attempts = settings.max_retry_attempts; + options.max_items_per_catalog = settings.max_items_per_catalog; + Ok(Self { + catalogs, + client: reqwest::Client::new(), + options, + }) + } + + /// Search all registered catalogs. Individual catalog failures are in metadata. + pub async fn search(&self, query: QueryInput) -> Result { + let mut value: serde_json::Value = + serde_wasm_bindgen::from_value(query.into()).map_err(error)?; + let object = value + .as_object_mut() + .ok_or_else(|| error("query must be an object"))?; + for key in object.keys() { + if !matches!( + key.as_str(), + "collections" | "ids" | "intersects" | "bbox" | "datetime" | "limit" | "sortby" + ) { + return Err(error(format!("unknown query field: {key}"))); + } + } + object + .entry("collections") + .or_insert_with(|| serde_json::json!([])); + let query: SearchQuery = serde_json::from_value(value).map_err(error)?; + if query.bbox.is_some() && query.intersects.is_some() { + return Err(error("bbox and intersects cannot be combined")); + } + if query.limit == Some(0) { + return Err(error("limit must be positive")); + } + let result = SearchExecutor::new(self.client.clone()) + .federated_search(self.catalogs.clone(), query, self.options) + .await + .map_err(error)?; + Ok(to_js(&result)?.unchecked_into()) + } + + /// Discover collections per catalog, preserving failures and original metadata. + #[wasm_bindgen(js_name = listCollections)] + pub async fn list_collections(&self) -> Result { + use futures::StreamExt; + let results: Vec = futures::stream::iter(self.catalogs.iter().map(|catalog| async move { + let result = superstac_search::runtime::timeout(self.options.per_catalog_timeout, + superstac_search::browser::collections(&self.client, &catalog.url, 10_000)).await; + match result { + Ok(Ok(collections)) => { + let collections: Vec<_> = collections.into_iter().map(|collection| { + let id = collection.get("id").and_then(|id| id.as_str()).unwrap_or(""); + serde_json::json!({ "canonical_id": catalog.canonical_collection(id), "collection": collection }) + }).collect(); + serde_json::json!({ "catalog_id": catalog.id, "collections": collections, "error": null }) + } + Ok(Err(e)) => serde_json::json!({ "catalog_id": catalog.id, "collections": [], "error": e.to_string() }), + Err(e) => serde_json::json!({ "catalog_id": catalog.id, "collections": [], "error": e }), + } + })).buffered(self.options.max_concurrent).collect().await; + Ok(to_js(&results)?.unchecked_into()) + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "SuperSTACConfig")] + pub type ConfigInput; + #[wasm_bindgen(typescript_type = "SearchQuery")] + pub type QueryInput; + #[wasm_bindgen(typescript_type = "SearchResponse")] + pub type SearchOutput; + #[wasm_bindgen(typescript_type = "CatalogCollections[]")] + pub type CollectionsOutput; +} + +#[wasm_bindgen(typescript_custom_section)] +const TYPES: &'static str = include_str!("types.d.ts"); diff --git a/crates/wasm/src/types.d.ts b/crates/wasm/src/types.d.ts new file mode 100644 index 0000000..cbfccf0 --- /dev/null +++ b/crates/wasm/src/types.d.ts @@ -0,0 +1,53 @@ +export interface SuperSTACConfig { + catalogs: { + id: string; + url: string; + collection_aliases?: Record; + asset_aliases?: Record>; + }[]; + settings?: { + deduplicate_items?: boolean; + unify_response?: boolean; + max_concurrent_catalogs?: number; + per_catalog_timeout_seconds?: number; + max_retry_attempts?: number; + max_items_per_catalog?: number; + }; +} +export interface SearchQuery { + collections?: string[]; + ids?: string[]; + bbox?: [number, number, number, number] | [number, number, number, number, number, number]; + intersects?: { type: string; coordinates?: unknown; geometries?: unknown[] }; + datetime?: string; + limit?: number; + sortby?: string[]; +} +export interface StacItem { + type: "Feature"; + id: string; + collection?: string; + geometry: Record | null; + properties: Record; + assets: Record; + links: Record[]; + [key: string]: unknown; +} +export interface SearchResponse { + items: { catalog_id: string; seen_in: string[]; item: StacItem }[]; + metadata: { + superstac_version: string; + catalogs_queried: number; + catalogs_succeeded: number; + catalogs_failed: number; + total_items: number; + duplicates_removed: number; + failures: { catalog_id: string; reason: string }[]; + unsupported_collections: string[]; + }; +} +export interface CatalogCollections { + catalog_id: string; + collections: { canonical_id: string; collection: Record }[]; + error: string | null; +} diff --git a/crates/wasm/tests/browser.mjs b/crates/wasm/tests/browser.mjs new file mode 100644 index 0000000..bdf1788 --- /dev/null +++ b/crates/wasm/tests/browser.mjs @@ -0,0 +1,140 @@ +// Exercise the generated JS package and real browser Fetch against a local STAC server. +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; + +const calls = []; +let slowClosed = false; +const item = (id) => ({ type: 'Feature', stac_version: '1.0.0', id, + collection: 'local', geometry: null, + properties: { datetime: '2024-01-01T00:00:00Z' }, + assets: { B02: { href: 'https://example.com/blue.tif' } }, links: [] }); +const server = createServer(async (req, res) => { + try { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'content-type,x-page'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + if (req.method === 'OPTIONS') { res.end(); return; } + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/') { + res.setHeader('Content-Type', 'text/html'); res.end('WASM tests'); return; + } + if (url.pathname.startsWith('/pkg/')) { + const name = url.pathname.slice(5); + if (!/^[\w.-]+$/.test(name)) { res.writeHead(404).end(); return; } + res.setHeader('Content-Type', name.endsWith('.wasm') ? 'application/wasm' : 'text/javascript'); + res.end(await readFile(new URL(`../pkg/${name}`, import.meta.url))); return; + } + let raw = ''; + for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : undefined; + calls.push({ path: req.url, method: req.method, body, header: req.headers['x-page'] }); + res.setHeader('Content-Type', 'application/json'); + const send = (features, links = []) => res.end(JSON.stringify({ type: 'FeatureCollection', features, links })); + if (url.pathname.startsWith('/slow/')) { res.on('close', () => { slowClosed = true; }); return; } + if (url.pathname.startsWith('/broken/')) { res.writeHead(503).end('{}'); return; } + if (url.pathname === '/retry/search' && calls.filter(c => c.path === req.url).length === 1) { + res.writeHead(503).end('{}'); return; + } + if (url.pathname === '/a/collections') { + res.end(JSON.stringify({ collections: [{ id: 'local', title: 'Local collection' }], + links: [{ rel: 'next', href: './collections?page=2' }], + ...(url.search ? { collections: [{ id: 'other' }], links: [] } : {}) })); return; + } + if (url.pathname.endsWith('/collections')) { res.end('{"collections":[],"links":[]}'); return; } + if (url.pathname === '/loop/search') { send([], [{ rel: 'next', href: './search', method: 'POST', body }]); return; } + if (url.pathname === '/a/search') { + send([item(url.search ? 'second' : 'shared')], url.search ? [] : [{ rel: 'next', href: './search?page=2' }]); return; + } + if (url.pathname === '/post/search') { + send([item(body?.token ? 'second' : 'shared')], body?.token ? [] : [ + { rel: 'next', href: './search', method: 'POST', body: { token: 'next' }, merge: true, headers: { 'x-page': 'two' } } + ]); return; + } + send([item('shared')]); + } catch (e) { res.writeHead(500).end(JSON.stringify({ error: String(e) })); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +// A separate page origin exercises CORS and preflight on the fixture requests. +const pageServer = createServer(server.listeners('request')[0]); +await new Promise(resolve => pageServer.listen(0, '127.0.0.1', resolve)); +const pageOrigin = `http://127.0.0.1:${pageServer.address().port}`; +let browser; +try { + browser = await chromium.launch({ ...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}) }); + const page = await browser.newPage(); + await page.goto(pageOrigin); + const result = await page.evaluate(async ({ origin, live }) => { + const { default: init, SuperSTAC } = await import('/pkg/superstac_wasm.js'); + await init(); + const check = (condition, message) => { if (!condition) throw new Error(message); }; + const catalog = id => ({ id, url: `${origin}/${id}`, + collection_aliases: { canonical: 'local' }, asset_aliases: { canonical: { blue: 'B02' } } }); + const make = (ids, settings = {}) => new SuperSTAC({ catalogs: ids.map(catalog), settings }); + const client = make(['a', 'b', 'retry', 'broken']); + const result = await client.search({ collections: ['canonical'], limit: 2 }); + check(result.metadata.total_items === 2, JSON.stringify(result)); + check(result.metadata.catalogs_succeeded === 3 && result.metadata.catalogs_failed === 1, 'partial failures'); + check(result.metadata.duplicates_removed === 2, 'deduplication'); + const shared = result.items.find(i => i.item.id === 'shared'); + check(shared.seen_in.length === 3, 'provenance'); + check(shared.item.collection === 'canonical' && shared.item.assets.blue.href.endsWith('blue.tif'), 'aliases and plain objects'); + check(!('B02' in shared.item.assets), 'asset alias removes original key'); + check(Array.isArray(result.items) && !(shared.item.properties instanceof Map), 'JS serialization'); + const discovered = await client.listCollections(); + check(discovered[0].collections.length === 2 && discovered[0].collections[0].canonical_id === 'canonical', 'paginated discovery'); + check(typeof discovered[3].error === 'string', 'discovery partial failure'); + client.free(); + const capped = make(['a'], { max_items_per_catalog: 1 }); + check((await capped.search({ limit: 20 })).items.length === 1, 'item cap'); capped.free(); + const post = make(['post']); + check((await post.search({ collections: ['canonical'], limit: 2 })).items.length === 2, 'POST pagination'); post.free(); + const slow = make(['slow', 'b'], { per_catalog_timeout_seconds: 1, max_retry_attempts: 1 }); + const timed = await slow.search({}); + check(timed.metadata.catalogs_succeeded === 1 && timed.metadata.failures[0].reason.includes('timeout'), 'browser timeout'); slow.free(); + const loop = make(['loop'], { max_retry_attempts: 1 }); + check((await loop.search({})).metadata.failures[0].reason.includes('pagination'), 'pagination cycle'); loop.free(); + for (const config of [{ catalogs: [] }, { catalogs: [catalog('a'), catalog('a')] }, + { catalogs: [catalog('a')], settings: { max_retry_attempts: 0 } }, + { catalogs: [{ id: 'a', url: 'file:///tmp/data' }] }]) { + let threw = false; + try { new SuperSTAC(config); } catch (e) { threw = e instanceof Error; } + check(threw, 'invalid config throws Error'); + } + const raw = make(['b', 'retry'], { deduplicate_items: false, unify_response: false }); + const unmodified = await raw.search({}); + check(unmodified.items.length === 2 && unmodified.items.every(i => i.item.collection === 'local' && i.item.assets.B02), 'disabled normalization and deduplication'); + raw.free(); + const invalid = make(['a']); + for (const query of [{ collection: ['typo'] }, { limit: 0 }, { bbox: [1, 2] }, { bbox: [0, 0, 1, 1], intersects: { type: 'Point', coordinates: [0, 0] } }]) { + let threw = false; + try { await invalid.search(query); } catch (e) { threw = e instanceof Error; } + check(threw, 'invalid query rejects with Error'); + } + invalid.free(); + if (live) { + const liveClient = new SuperSTAC({ catalogs: [{ id: 'earth-search', url: 'https://earth-search.aws.element84.com/v1' }] }); + const response = await liveClient.search({ collections: ['sentinel-2-l2a'], bbox: [6, 49, 7, 50], + datetime: '2024-01-01T00:00:00Z/2024-01-31T23:59:59Z', limit: 1 }); + check(response.metadata.catalogs_succeeded === 1 && response.items.length === 1, JSON.stringify(response.metadata)); + liveClient.free(); + return { fixtureTests: 'passed', liveItem: response.items[0].item.id }; + } + return { fixtureTests: 'passed' }; + }, { origin, live: process.env.SUPERSTAC_LIVE_TEST === '1' }); + assert.equal(calls.filter(c => c.path === '/retry/search').length, 3); + assert.ok(slowClosed, 'timeout aborts the outstanding Fetch request'); + const postPage = calls.find(c => c.path === '/post/search' && c.body?.token); + assert.deepEqual(postPage.body.collections, ['local']); + assert.equal(postPage.header, 'two'); + assert.equal(calls.filter(c => c.path === '/a/search?page=2').length, 1, 'cap must stop pagination'); + console.log(result); +} finally { + await browser?.close(); + pageServer.closeAllConnections(); + await new Promise(resolve => pageServer.close(resolve)); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); +} diff --git a/crates/wasm/tests/types.ts b/crates/wasm/tests/types.ts new file mode 100644 index 0000000..b899dfb --- /dev/null +++ b/crates/wasm/tests/types.ts @@ -0,0 +1,9 @@ +import init, { SuperSTAC, type SearchResponse, type CatalogCollections } from '../pkg/superstac_wasm.js'; +await init(); +const client = new SuperSTAC({ catalogs: [{ id: 'test', url: 'https://example.com' }] }); +const response: SearchResponse = await client.search({ bbox: [0, 0, 1, 1], limit: 2 }); +const collections: CatalogCollections[] = await client.listCollections(); +console.log(response.metadata.total_items, collections[0].catalog_id); +// @ts-expect-error limit must be a number +client.search({ limit: '2' }); +client.free(); diff --git a/crates/wasm/tsconfig.json b/crates/wasm/tsconfig.json new file mode 100644 index 0000000..9c3ab41 --- /dev/null +++ b/crates/wasm/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2022", + "DOM", + "ESNext.Disposable" + ] + }, + "include": [ + "tests/types.ts" + ] +} diff --git a/docs/.gitignore b/docs/.gitignore index acc0cec..c1cd0f5 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -7,3 +7,6 @@ out/ public/brand/ !lib/ !lib/** + +# Generated by the WASM build in CI, or prepare-wasm locally. +public/wasm/ diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/docs/Dockerfile b/docs/Dockerfile index 3ddf5e6..ad3a91a 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -7,6 +7,8 @@ COPY package.json pnpm-lock.yaml source.config.ts versions.json ./ COPY scripts/check-node.mjs ./scripts/check-node.mjs RUN pnpm install --frozen-lockfile COPY . . +# CI supplies the tested WASM artifact; fail clearly if a local build omitted it. +RUN test -s public/wasm/superstac_wasm.js && test -s public/wasm/superstac_wasm_bg.wasm RUN pnpm check && pnpm build FROM node:26-alpine AS runner diff --git a/docs/README.md b/docs/README.md index 1cbc913..a040f91 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,8 @@ older folders for readers using those releases; corrections can still be made. Commit `pnpm-lock.yaml` when changing dependencies. +Use the pnpm version pinned in `package.json` (9.8.0, also used in CI). Avoid mixing `npm install` and pnpm in this directory; mixed dependency folders can break CSS module resolution. + ## Check changes ```bash @@ -49,9 +51,65 @@ port other than 3000. ## Publish -Coolify builds and deploys this site when changes are pushed to `main`. Each build -includes all versions in `content/docs`; publishing a package release does not -replace the docs with that release’s checkout. +The [Docs image workflow](../.github/workflows/docs-checks.yml) builds the browser +package, runs its checks, and puts the tested WASM files into the docs image. +It then checks the docs build, pages, links, and WASM asset MIME type. These builds +run in GitHub Actions, not on your laptop. Every build contains all docs versions. + +Pull requests and manual runs build and test without publishing. Pushes to `main` +publish the passing image as `ghcr.io//-docs:sha-` and update +`:production`. Package releases do not replace the docs with a release checkout. + +Configure Coolify as a **Docker Image** application using that `:production` image, +port 3000, and the existing domain. The image serves the `/superstac` base path. +If the GHCR package is private, give Coolify registry credentials with read access. +Pull/redeploy after the workflow succeeds; to roll back, select a previous +`sha-` image. The workflow does not call a deployment webhook or change +Coolify settings. It produces an AMD64 image for an x86-64 host. + +For a local Docker build, first run `node docs/scripts/prepare-wasm.mjs` from the +repository root. Docker uses `docs/` as its context and does not compile Rust. + +## Browser playground + +The guide is in `content/docs/v0.3/wasm`. The playground is a small static app in +`public/playground`, served at `/superstac/playground` through a Next.js rewrite. +The landing page links directly to it. Its modules separate configuration, catalog +editing, activity recording, map controls, viewer links, and results so features can grow independently. It loads WASM +on the first search or collection lookup. Its theme follows the system by default +and can be changed in the page header. Keep scene and catalog +language in the results; API details belong in the guide and code example. + +Reuse an existing package locally without compiling Rust: + +```sh +node docs/scripts/prepare-wasm.mjs +``` + +This copies `crates/wasm/pkg` into the ignored `docs/public/wasm` directory. If it +is missing, download the `wasm-web` artifact from **WASM checks** and extract it +there, or follow the binding's build instructions. Docs startup never builds +Rust. Open `/superstac/playground` on the docs dev server. + +Playground tests use the actual WASM package with fixture catalog responses in +one headless browser. Run `npm run test:playground` in `crates/wasm`. Provider +availability is not a CI requirement, and no generated binaries are committed. + +The overview map uses pinned MapLibre GL JS 6.11.2 from unpkg and OpenFreeMap +basemaps (Positron and Dark), preserving their OpenStreetMap/OpenMapTiles attribution. One map worker is used. Its layout +follows shadcn/mapcn patterns, while the static app keeps the docs site's palette +and logo. It does not install the React mapcn component. Map loading is independent +of WASM: searches and external viewer links still work if WebGL or the CDN fails. +Scene previews stay on OpenFreeMap. JPEG/PNG/WebP image assets are placed over +scene bounds with an explicit approximate-alignment label. GeoTIFFs use the lazy, +pinned `@geomatico/maplibre-cog-protocol@0.5.0` reader only when selected. Asset +access still depends on CORS, HTTP ranges and provider authentication. STAC Map +and STAC Browser are external links only, so their basemaps do not load in our page. + +`modules/activity.js` observes catalog Fetch calls during playground operations; +it passes the original arguments and Response through, without adding headers. +Timing ends at response headers. The monitor is page-local, bounded to 200 events, +and omits query values and bodies. The standalone WASM API is unchanged. Health is +last-observed status, with manual connection checks and no background polling. -The [Docs checks workflow](../.github/workflows/docs-checks.yml) builds and tests -the site on pull requests. It can also be run manually and does not deploy. +The playground’s **Browse STAC Index** picker loads `https://stacindex.org/api/catalogs` on demand. `modules/stac-index.js` filters public API entries, removes duplicate endpoints, and fills the catalog editor for review; it does not probe the directory’s endpoints. `modules/alias-editor.js` supplies editable collection and asset name pairs. Scene results overlay the map and collapse when previewing an asset. diff --git a/docs/app/global.css b/docs/app/global.css index a9a2032..1c4f820 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -241,3 +241,7 @@ button { cursor: pointer; } @media (prefers-reduced-motion: reduce) { .inventory-transfer span:first-child { opacity: 1; top: 17px; } } + +/* Four interface guides, with room for their descriptions. */ +.interface-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } +@media (max-width: 700px) { .interface-cards { grid-template-columns: 1fr; } } diff --git a/docs/app/landing.module.css b/docs/app/landing.module.css new file mode 100644 index 0000000..605e387 --- /dev/null +++ b/docs/app/landing.module.css @@ -0,0 +1,98 @@ +.landing { width: min(1120px, calc(100% - 48px)); margin: 0 auto; color: var(--ink); } +.hero { padding: 88px 24px 76px; text-align: center; } +.release { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); margin-bottom: 28px; } +.hero h1 { font-family: var(--font-sans); font-size: clamp(48px, 7vw, 80px); font-weight: 650; line-height: 1.04; letter-spacing: -.06em; margin: 0; } +.hero>p { max-width: 640px; margin: 24px auto 0; font-size: 19px; line-height: 1.65; color: var(--muted); } +.actions { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 30px; } +.primary, .secondary { display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 7px; border: 1px solid var(--line); padding: 12px 20px; font-size: 14px; font-weight: 550; min-height: 44px; } +.primary { background: var(--brand); color: var(--brand-foreground); border-color: var(--brand); } +.secondary { background: var(--color-fd-background); } +.primary:hover { opacity: .9; } +.secondary:hover { background: var(--surface); } +.hero .heroNote { font-size: 12px; margin-top: 22px; } +.example { display: grid; grid-template-columns: .8fr 1.2fr; align-items: center; gap: 56px; border-top: 1px solid var(--color-fd-border); border-bottom: 1px solid var(--color-fd-border); padding: 52px 0; } +.eyebrow { font-size: 12px; color: var(--muted); } +.example h2, .sectionHeading h2, .inventory h2 { font-family: var(--font-sans); font-size: 28px; font-weight: 600; line-height: 1.25; letter-spacing: -.035em; margin: 12px 0 16px; } +.exampleCopy>p, .inventory p { color: var(--muted); font-size: 15px; line-height: 1.8; } +.example ul { list-style: disc; padding-left: 18px; margin: 24px 0; color: var(--muted); font-size: 14px; line-height: 1.7; } +.example li+li { margin-top: 8px; } +.textLink { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 550; color: var(--brand); } +.landing a:focus-visible, .footer a:focus-visible { outline: 2px solid var(--brand); outline-offset: 5px; border-radius: 3px; } +.textLink:hover, .release:hover, .footer a:hover, .alpha a:hover { text-decoration: underline; text-underline-offset: 4px; } +.example :global(.code-preview) { border-radius: 10px; } +.example :global(.code-preview pre) { font-size: 13px; line-height: 1.75; min-height: 320px; max-height: 420px; padding: 20px; white-space: pre-wrap; overflow-wrap: normal; word-break: normal; } +.example :global(.code-preview pre code) { white-space: inherit; overflow-wrap: inherit; } +.example :global(.code-toolbar button) { font-size: 13px; min-height: 44px; } +.example :global(.code-footer) { font-size: 11px; padding: 12px 16px; } +.guides { padding: 56px 0; } +.sectionHeading { margin-bottom: 28px; } +.sectionHeading h2 { margin: 0 0 8px; } +.sectionHeading p { color: var(--muted); font-size: 14px; } +.guideGrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--color-fd-border); border-radius: 10px; overflow: hidden; } +.guide { padding: 24px; min-width: 0; } +.guide+.guide { border-left: 1px solid var(--color-fd-border); } +.guide:hover { background: var(--surface); } +.guide>svg { color: var(--muted); margin-bottom: 22px; } +.guide h3 { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 15px; font-weight: 550; margin-bottom: 10px; } +.guide h3 svg { color: var(--muted); flex-shrink: 0; } +.guide p { color: var(--muted); font-size: 13px; line-height: 1.7; } +.inventory { display: flex; align-items: center; justify-content: space-between; gap: 40px; border-top: 1px solid var(--color-fd-border); padding: 36px 0; } +.inventory>div:first-child { max-width: 620px; } +.inventory h2 { font-size: 24px; margin-top: 0; } +.inventoryLinks { display: flex; flex-direction: column; gap: 14px; flex-shrink: 0; } +.inventoryLinks>a:last-child { font-size: 13px; color: var(--muted); } +.alpha { font-size: 12px; color: var(--muted); margin: 12px 0 36px; } +.alpha a { text-decoration: underline; text-underline-offset: 3px; } +.footer { width: min(1120px, calc(100% - 48px)); margin: 0 auto; border-top: 1px solid var(--color-fd-border); padding: 24px 0; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 20px; color: var(--muted); font-size: 12px; } +.footer nav { display: flex; flex-wrap: wrap; gap: 24px; } +@media (max-width: 900px) { + .example { gap: 28px; grid-template-columns: minmax(0, 1fr); } + .exampleCopy { max-width: 620px; } + .guideGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .guide:nth-child(3) { border-left: 0; } + .guide:nth-child(n+3) { border-top: 1px solid var(--color-fd-border); } + .inventory { align-items: start; flex-direction: column; gap: 20px; } +} +@media (max-width: 540px) { + .landing, .footer { width: calc(100% - 32px); } + .hero { padding: 56px 0 48px; } + .hero>p { font-size: 17px; } + .desktopBreak { display: none; } + .example, .guides { padding: 36px 0; } + .guide { padding: 18px; } + .guide h3 { font-size: 14px; } + .example :global(.code-toolbar button) { padding-inline: 10px; } +} + +.example :global(.code-toolbar) { gap: 8px; } +.example :global(.code-toolbar [role=tablist]) { display: flex; flex-wrap: wrap; min-width: 0; } +.example :global(.code-toolbar>button) { flex-shrink: 0; } + +.diagram { margin: 24px 0 20px; } +.diagram :global(.federation-map) { border-radius: 10px; width: 100%; max-width: none; background: var(--surface); } +@media (min-width: 701px) { + .diagram :global(.catalog-node) { padding: 8px; gap: 6px; } + .diagram :global(.catalog-node strong) { font-size: 10px; } + .diagram :global(.catalog-node small) { font-size: 8px; } +} +@media (max-width: 900px) { .diagram { max-width: 480px; } } + +.foundations { padding: 40px 0; border-bottom: 1px solid var(--color-fd-border); } +.foundations h2 { margin: 0 0 12px; font-family: var(--font-sans); font-size: 24px; font-weight: 600; letter-spacing: -.035em; } +.foundations p { max-width: 660px; color: var(--muted); font-size: 14px; line-height: 1.8; } +.foundations code { font-size: .95em; } +.foundations nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin-top: 26px; } +.foundations nav a { display: grid; grid-template-columns: auto 1fr; align-items: center; justify-content: start; column-gap: 8px; font-size: 16px; font-weight: 550; } +.foundations nav a>svg { color: var(--muted); } +.foundations nav span { grid-column: 1 / -1; font-size: 12px; font-weight: 400; color: var(--muted); margin-top: 6px; } +.foundations nav a:hover { color: var(--brand); } +@media (max-width: 540px) { + .foundations nav { grid-template-columns: 1fr; gap: 20px; } +} + +.inventoryLinks .benchmark { display: grid; gap: 6px; max-width: 320px; padding-top: 8px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.benchmark strong { color: var(--ink); font-size: 28px; letter-spacing: -.03em; font-weight: 600; } +.benchmark strong span { font-size: 13px; color: var(--muted); font-weight: 400; letter-spacing: normal; } +.benchmark .benchmarkLink { display: inline-flex; gap: 6px; align-items: center; color: var(--brand); margin-top: 4px; } +.benchmark:hover .benchmarkLink { text-decoration: underline; text-underline-offset: 3px; } +.foundations { border-top: 1px solid var(--color-fd-border); } diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index b8197a1..4c581f9 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -21,7 +21,7 @@ const crimson = localFont({ export const metadata: Metadata = { metadataBase: new URL('https://spatialnode.com'), title: { default: 'SuperSTAC — Many catalogs. One search.', template: '%s | SuperSTAC' }, - description: 'Search across STAC catalogs through one interface, with Python, Rust, or the command line.', + description: 'Search across STAC catalogs through one interface, with JavaScript, Python, Rust, or the command line.', icons: { icon: '/superstac/favicon.svg' }, }; diff --git a/docs/app/page.tsx b/docs/app/page.tsx index 41c5f97..cd49dd8 100644 --- a/docs/app/page.tsx +++ b/docs/app/page.tsx @@ -1,12 +1,12 @@ import Link from 'next/link'; import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import { ArrowRight, ArrowUpRight, Braces, Layers, Terminal, GitMerge, Scan, BookOpen } from 'lucide-react'; +import { ArrowRight, ArrowUpRight, Braces, Layers, Terminal, Scan } from 'lucide-react'; import { baseOptions } from '@/lib/layout.shared'; import { FederationMap } from '@/components/federation-map'; import { CodePreview } from '@/components/code-preview'; -import { InventoryDemo } from '@/components/inventory-demo'; import { PoweredBySpatialnode } from '@/components/powered-by-spatialnode'; import type { Metadata } from 'next'; +import styles from './landing.module.css'; const socialTitle = 'SuperSTAC — Many catalogs. One search.'; const socialDescription = 'Find satellite imagery across catalogs, combine results, and save study-area metadata for repeated searches with fewer API requests.'; @@ -37,6 +37,7 @@ export const metadata: Metadata = { }; const guides = [ + { icon: Scan, title: 'Browser / WASM', text: 'Search live catalogs from a JavaScript or TypeScript app.', href: '/docs/wasm/overview' }, { icon: Braces, title: 'Python', text: 'Search from scripts and notebooks with the sync or async client.', href: '/docs/python/overview' }, { icon: Layers, title: 'Rust', text: 'Add the search engine to a Rust application.', href: '/docs/rust/overview' }, { icon: Terminal, title: 'Command line', text: 'List collections, search catalogs, and export results as JSON.', href: '/docs/cli/overview' }, @@ -45,124 +46,71 @@ const guides = [ export default function Home() { return ( -
-
-
- - v0.3 · What’s new - -

Many catalogs.
One search.

-

Find satellite imagery across providers with one query. Combine the results, save the metadata you need, and explore it again with fewer API requests.

-
- Quickstart - Installation -
-
pip install superstac
+
+
+ v0.3 · What’s new
-
-
-

Find imagery. Keep the metadata you need.

-

One search workflow for Python notebooks, Rust applications, and the command line.

-
-
- - -

Search across providers

-

Query relevant catalogs concurrently and receive combined results, with visibility into which sources responded or failed.

- How federation works - - - -

Save once. Explore repeatedly.

-

Save your study-area metadata to GeoParquet and reuse it across searches. Automatic mode checks live catalogs when the saved data is too old or doesn’t cover your query.

- Build your inventory - - - -

Spend less time combining results

-

Configure collection and asset aliases, remove duplicate item IDs, and pass STAC metadata into your existing analysis workflow.

- Run your first search - -
-
- -
-
-

Keep exploring the same study area.

-

Exploring the same region over multiple sessions? Save its catalog metadata to GeoParquet, then refine your searches locally. Scope ingestion by collection, area, and time so you only collect what you need.

-
    -
  • Follow ingestion progress, set a storage limit, and resume interrupted work.
  • -
  • Search the saved coverage locally; let automatic mode fall back to live catalogs when needed.
  • -
  • Add recent dates and clean up old files as your project grows.
  • -
-

Inventories store metadata. Imagery previews and downloads still use the provider’s assets.

- Try the GeoParquet notebook -
-
- -

How fast is local search?

-

Our benchmark measures how skipping irrelevant data speeds up a selective search over 100,000 synthetic items. See the results and run it yourself.

- Explore the benchmark +
+
+ One query, multiple sources +

Start with the catalogs you need.

+

Run your query. SuperSTAC combines the results across catalogs.

+
+ Walk through a search
+
-
-

Guides

-
+
+

Use it where you work.

Pick your interface.

+
{guides.map(({ icon: Icon, ...guide }) => ( - - -

{guide.title}

+ +
-
-
-

Search example

-

Register the catalogs you want to search, then query a collection. SuperSTAC sends the request to the relevant catalogs and combines their results.

-
    -
  • Filter by location and date.
  • -
  • Normalize collection and asset names, and remove duplicate item IDs.
  • -
  • Check which catalogs responded and which failed.
  • -
- Full example and configuration -
- -
- -
-
-

Why SuperSTAC?

-

The pipeline problem that led to SuperSTAC, and the thinking behind it.

+
+

Same area. More questions.

Save catalog metadata to GeoParquet and search it locally. Keep refining your study area without repeating every API request.

+
+ Work with saved metadata
- - Read the story on Spatialnode
- -
-

Reference

+
- Configuration - Python API - Troubleshooting +

Built on STAC. Powered by Rust.

+

Built with open standards and open-source tools from the STAC community.

+
-
- -

SuperSTAC is in alpha. APIs and configuration may change. Current limitations →

-
+ +

SuperSTAC is in alpha. See current limitations.

-
+ ); diff --git a/docs/components/code-preview-tabs.tsx b/docs/components/code-preview-tabs.tsx index 7abbd03..6cbfcc7 100644 --- a/docs/components/code-preview-tabs.tsx +++ b/docs/components/code-preview-tabs.tsx @@ -1,4 +1,5 @@ 'use client'; +import Link from 'next/link'; import { useState } from 'react'; import { Check, Copy } from 'lucide-react'; import type { ReactNode } from 'react'; @@ -19,6 +20,6 @@ export function CodePreviewTabs({ highlighted }: { highlighted: Record= 0) { event.preventDefault(); setTab(names[next]); setCopied(false); setCopyFailed(false); document.getElementById(`tab-${names[next]}`)?.focus(); } }} onClick={() => { setTab(name); setCopied(false); setCopyFailed(false); }}>{name})}
{highlighted[tab]}
-
{copyFailed ? 'Select the code to copy it manually.' : copied ? 'Copied to clipboard.' : 'See the quickstart for setup.'}superstac
+
{copyFailed ? 'Select the code to copy it manually.' : copied ? 'Copied to clipboard.' : tab === 'JavaScript' ? 'Browser / WASM' : 'See the quickstart for setup.'}{tab === 'JavaScript' ? Installation guide ↗ : superstac}
; } diff --git a/docs/components/code-preview.tsx b/docs/components/code-preview.tsx index 3f15e6e..0e3f009 100644 --- a/docs/components/code-preview.tsx +++ b/docs/components/code-preview.tsx @@ -3,11 +3,12 @@ import { examples } from '@/lib/code-examples'; import { CodePreviewTabs } from './code-preview-tabs'; export async function CodePreview() { - const [Python, Rust, CLI] = await Promise.all([ + const [Python, JavaScript, Rust, CLI] = await Promise.all([ highlight(examples.Python, { lang: 'python', theme: 'github-dark' }), + highlight(examples.JavaScript, { lang: 'javascript', theme: 'github-dark' }), highlight(examples.Rust, { lang: 'rust', theme: 'github-dark' }), highlight(examples.CLI, { lang: 'bash', theme: 'github-dark' }), ]); - return ; + return ; } diff --git a/docs/content/docs/v0.3/concepts/federation.md b/docs/content/docs/v0.3/concepts/federation.md index 38ba2a2..7d4dcf9 100644 --- a/docs/content/docs/v0.3/concepts/federation.md +++ b/docs/content/docs/v0.3/concepts/federation.md @@ -18,12 +18,16 @@ Federation means searching several catalogs through one client. In live mode, Su ## From request to response +The Python, Rust, and CLI live-search path follows these steps: + 1. **Start and inspect.** The engine checks catalog health and discovers `/collections` on healthy catalogs. 2. **Choose sources.** It normally excludes unhealthy catalogs and catalogs known not to serve any requested collection. If collection discovery failed, the unknown catalog can still be queried. 3. **Translate names.** Each catalog's collection aliases translate your canonical query into local names. 4. **Search in parallel.** SuperSTAC limits how many catalogs it queries at once, retries failed requests, and reads result pages up to the limit for each catalog. 5. **Combine the results.** SuperSTAC can rename collections and assets using your aliases and remove duplicate item IDs. The response also lists any catalog failures. +The browser binding sends searches directly to the catalogs you configure. It supports aliases, pagination, retries, and deduplication, but does not run background health checks or choose catalogs from discovered capabilities. + An empty `collections` list removes the collection filter; it does not select a default dataset. ## Coverage and duplicate items diff --git a/docs/content/docs/v0.3/guides/aliases.md b/docs/content/docs/v0.3/guides/aliases.md index 7fa310e..4c4874c 100644 --- a/docs/content/docs/v0.3/guides/aliases.md +++ b/docs/content/docs/v0.3/guides/aliases.md @@ -5,6 +5,77 @@ description: Use consistent names when catalogs expose different collection IDs Catalogs can use different names for the same collection or band. Set aliases on each catalog so your queries and results use the names you choose. +## Try it with Earth Search + +This example calls the collection `optical` and the `visual` asset `rgb`. It searches using your names and prints the renamed asset URL when available. Aliases rename metadata keys; they do not create or transform imagery. + +```python +from superstac import Client + +client = Client(catalogs=[{ + "id": "earth-search", + "url": "https://earth-search.aws.element84.com/v1", + "collection_aliases": {"optical": "sentinel-2-l2a"}, + "asset_aliases": {"optical": {"rgb": "visual"}}, +}]) +try: + result = client.search( + collections=["optical"], + bbox=[6, 45, 7, 46], + datetime="2024-06-01T00:00:00Z/2024-06-30T23:59:59Z", + limit=3, + ) + for item in result.items(): + print(item["collection"], item.get("assets", {}).get("rgb", {}).get("href")) + print(result.metadata["failures"]) +finally: + client.shutdown() +``` + +In a browser app, the same mappings go on the catalog object: + +```js +import init, { SuperSTAC } from '@spatialnode/superstac-wasm'; + +await init(); +const client = new SuperSTAC({ + catalogs: [{ + id: 'earth-search', + url: 'https://earth-search.aws.element84.com/v1', + collection_aliases: { optical: 'sentinel-2-l2a' }, + asset_aliases: { optical: { rgb: 'visual' } }, + }], +}); +try { + const result = await client.search({ + collections: ['optical'], + bbox: [6, 45, 7, 46], + datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z', + limit: 3, + }); + for (const { item } of result.items) { + console.log(item.collection, item.assets?.rgb?.href); + } + console.warn(result.metadata.failures); +} finally { + client.free(); +} +``` + +For the CLI or Rust, add these mappings to the Earth Search entry in the [sample YAML](/superstac/versions/v0.3/examples/superstac.yml), then search for `optical`: + +```yaml +collection_aliases: + optical: sentinel-2-l2a +asset_aliases: + optical: + rgb: visual +``` + +```bash +superstac search --collection optical --bbox=6,45,7,46 --limit 3 +``` + ## Collection aliases Put the name you want to use on the left and the catalog’s actual name on the right: **your name → catalog name**. For a catalog whose collection is called `S2MSI2A`: diff --git a/docs/content/docs/v0.3/guides/catalogs.md b/docs/content/docs/v0.3/guides/catalogs.md index 412339f..024d47b 100644 --- a/docs/content/docs/v0.3/guides/catalogs.md +++ b/docs/content/docs/v0.3/guides/catalogs.md @@ -48,7 +48,9 @@ Add the provider first, then pass its ID as `provider` when adding a catalog. Th ```python from superstac import Client -client = Client.open("https://earth-search.aws.element84.com/v1") +client = Client.open( + "https://earth-search.aws.element84.com/v1", id="earth-search" +) try: print(client.list_collections()) print(client.collections_by_catalog()) @@ -69,3 +71,32 @@ finally: If you add catalogs after startup, call `shutdown()` and then `start()` to run startup discovery again. Calling `start()` while already started is a no-op. Include the existing title and description when updating a catalog if you want to keep them; leaving them out can clear them. Changes are held in memory and disappear when you discard the client. Update your configuration file separately to keep them for the next run. + +## Change your catalog list + +This complete example adds a catalog, updates its description, searches it, then removes it. Register changes before `start()` or restart discovery afterward. + +```python +from superstac import Client + +client = Client() +try: + client.add_catalog({ + "id": "earth-search", + "title": "Earth Search", + "url": "https://earth-search.aws.element84.com/v1", + }) + client.update_catalog("earth-search", { + "title": "Earth Search", + "description": "Imagery for my project", + }) + result = client.search(collections=["sentinel-2-l2a"], limit=1) + print(len(result), result.metadata["failures"]) + client.shutdown() + client.delete_catalog("earth-search") + print(client.list_catalogs()) +finally: + client.shutdown() +``` + +The browser binding accepts catalogs in its constructor. To change them, finish pending calls, free the old client, and create a new one. It does not expose the Python registry methods. In the [playground](/docs/v0.3/wasm/playground), **Catalogs → Browse STAC Index** helps you find public APIs, and **Add by URL** accepts your own endpoint. diff --git a/docs/content/docs/v0.3/guides/geoparquet.md b/docs/content/docs/v0.3/guides/geoparquet.md index 9c38914..0bde29f 100644 --- a/docs/content/docs/v0.3/guides/geoparquet.md +++ b/docs/content/docs/v0.3/guides/geoparquet.md @@ -12,11 +12,7 @@ For a runnable Python example, [open the notebook](/docs/v0.3/python/geoparquet- The CLI examples below use a catalog named `earth-search` in `superstac.yml` and its configured collection name, `sentinel-2-l2a`. -Python wheels include GeoParquet. To build the CLI with it: - -```sh -cargo build -p superstac-cli --features geoparquet -``` +For setup, see [installation](/docs/v0.3/start/installation). ## Save your first inventory @@ -94,23 +90,48 @@ the managed dataset’s coverage and freshness guarantees. ## Use Python +Save this as `inventory.py`. It creates a small inventory, searches it without calling the catalog, and releases both clients when finished. No YAML file is needed. + ```python from superstac import Client -client = Client.from_yaml("superstac.yml") -client.ingest( - "earth-search", "./data", name="madrid-feb", - collections=["sentinel-2-l2a"], datetime="2025-02-01/2025-03-01", - bbox=[-4.5, 39.5, -3, 41], - progress=lambda event: print(event["phase"], event["items_saved"]), -) -local = Client.from_yaml("superstac.yml", mode="snapshot", dataset="./data") -results = local.search( - collections=["sentinel-2-l2a"], datetime="2025-02-10/2025-02-15", - bbox=[-4, 40, -3.5, 40.5], sortby=["-datetime"], limit=20, -) +catalogs = [{ + "id": "earth-search", + "url": "https://earth-search.aws.element84.com/v1", +}] +client = Client(catalogs=catalogs) +try: + saved = client.ingest( + "earth-search", "./data", name="alps-june", + collections=["sentinel-2-l2a"], + datetime="2024-06-01T00:00:00Z/2024-06-07T23:59:59Z", + bbox=[6, 45, 7, 46], + max_dataset_mib=128, + progress=lambda event: print(event["phase"], event["items_saved"]), + ) + print(saved) +finally: + client.shutdown() + del client + +local = Client(catalogs=catalogs, mode="snapshot", dataset="./data") +try: + result = local.search( + collections=["sentinel-2-l2a"], + datetime="2024-06-02T00:00:00Z/2024-06-03T23:59:59Z", + bbox=[6.2, 45.2, 6.8, 45.8], + sortby=["-datetime"], + limit=5, + ) + print(result.matched(), result.metadata["failures"]) +finally: + local.shutdown() + del local ``` +Run `python inventory.py` or `uv run inventory.py`. Ingestion downloads metadata pages and writes Parquet files; it is more work than a small live search. Start with the bounded dates and area above. + + Use `mode="auto"` and `max_snapshot_age_seconds=86400` for live fallback. `AsyncClient` supports the same options, with awaitable `ingest`, search, and maintenance methods. Constructors and `from_yaml()` remain synchronous. @@ -119,6 +140,23 @@ Progress callbacks run on a worker thread and should return promptly. Callback exceptions are reported without stopping ingestion. See the [Python API](/docs/v0.3/python/api) for method signatures. +### Compact and preview cleanup in Python + +After releasing the `local` client above, run: + +```python +from superstac import Client + +maintenance = Client() +try: + print(maintenance.compact_dataset("./data")) + print(maintenance.cleanup_dataset("./data")) + # After reviewing the report, opt into deleting unused files: + # print(maintenance.cleanup_dataset("./data", apply=True)) +finally: + maintenance.shutdown() +``` + ## Update an inventory Rerun ingestion with the same name to replace that scope. Other scopes remain @@ -170,14 +208,59 @@ clients before applying cleanup; active readers or writers prevent it from runni ## Rust -Enable the engine’s `geoparquet` feature. Use `ingest_catalog` with `IngestOptions` -to save data, `SuperSTACEngine::from_dataset` for local search, or -`SuperSTACEngine::automatic(storage, path, max_age)` for live fallback. +Use the dependencies from the [Rust guide](/docs/v0.3/rust/overview), with `superstac-engine = { version = "0.3", features = ["geoparquet"] }`. Save the [sample YAML](/superstac/versions/v0.3/examples/superstac.yml) as `superstac.yml` in your project root. + +This complete `src/main.rs` saves a small inventory, then queries it locally: + +```rust +use superstac_config::init_from_yaml; +use superstac_core::models::storage::Storage; +use superstac_engine::{ + ingest_catalog, IngestOptions, IngestScope, SuperSTACEngine, +}; +use superstac_search::query::SearchQuery; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let storage = init_from_yaml(Storage::Memory, "superstac.yml")?; + let catalog = storage.get_catalog("earth-search")?; + let scope = IngestScope { + collections: vec!["sentinel-2-l2a".into()], + bbox: Some(vec![6.0, 45.0, 7.0, 46.0].try_into()?), + datetime: Some( + "2024-06-01T00:00:00Z/2024-06-07T23:59:59Z".into(), + ), + }; + let mut options = IngestOptions::new("./data", scope); + options.name = Some("alps-june".into()); + options.max_dataset_bytes = Some(128 * 1024 * 1024); + ingest_catalog(&catalog, options).await?; + + let engine = SuperSTACEngine::from_dataset(storage, "./data")?; + let result = engine.search(SearchQuery { + collections: vec!["sentinel-2-l2a".into()], + bbox: Some(vec![6.2, 45.2, 6.8, 45.8].try_into()?), + datetime: Some( + "2024-06-02T00:00:00Z/2024-06-03T23:59:59Z".into(), + ), + limit: Some(5), + sortby: Some(vec!["-datetime".into()]), + ids: None, + intersects: None, + }).await; + engine.shutdown().await; + let result = result?; + println!("{} items", result.metadata.total_items); + for failure in result.metadata.failures { + eprintln!("{}: {}", failure.catalog_id, failure.reason); + } + Ok(()) +} +``` + +For live fallback, replace `from_dataset` with `SuperSTACEngine::automatic(storage, "./data", std::time::Duration::from_secs(86400))`; this constructor does not return a `Result`. -The engine also exports `compact_dataset` and `cleanup_dataset`. -`IngestOptions::progress` accepts an `Arc`; -callbacks should return promptly and not panic. Datasets currently use local -storage, not remote object stores. +The engine also exports `compact_dataset` and `cleanup_dataset`. Drop snapshot engines before maintenance to release their dataset locks. Datasets use local storage rather than remote object stores. ## Performance and version information @@ -202,5 +285,4 @@ print(json.loads(footer.metadata[b"superstac"])) For bug reports, include `superstac --version` or Python’s `superstac.__version__`. Search diagnostics also include `metadata.superstac_version`. -Version-1 inventories remain readable. Their next successful ingestion writes a -version-2 manifest, which older SuperSTAC builds cannot read. +Reusing an inventory from an older release? See the [upgrade notes](/docs/v0.3/releases). diff --git a/docs/content/docs/v0.3/guides/meta.json b/docs/content/docs/v0.3/guides/meta.json index f955d5f..0be6156 100644 --- a/docs/content/docs/v0.3/guides/meta.json +++ b/docs/content/docs/v0.3/guides/meta.json @@ -1,6 +1,7 @@ { "title": "Guides", "pages": [ + "recipes", "catalogs", "aliases", "resilience", diff --git a/docs/content/docs/v0.3/guides/recipes.mdx b/docs/content/docs/v0.3/guides/recipes.mdx new file mode 100644 index 0000000..242085e --- /dev/null +++ b/docs/content/docs/v0.3/guides/recipes.mdx @@ -0,0 +1,161 @@ +--- +title: Search recipes +description: Ready-to-adapt searches for a polygon, clearer scenes, item IDs, and asset links. +--- + +import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; + +These examples use a public Sentinel-2 collection on Earth Search. [Install SuperSTAC](/docs/v0.3/start/installation) first; no API key is needed for these metadata requests. + +## Search a polygon and keep clearer scenes + +The example searches a closed GeoJSON polygon, asks for the newest records, and keeps returned scenes with at most 20% cloud cover. It then looks up one of those real item IDs and prints its assets. + +Cloud cover is filtered **after** the search. This does not find every low-cloud scene in the catalog: increase the returned limit or adjust the query if the sample is too small. Cloud cover describes the whole scene, not just your polygon. + + + + +Save as `clearer-scenes.py` and run `python clearer-scenes.py` (or `uv run clearer-scenes.py`). It writes `clearer-scenes.geojson` to the working directory. + +```python +import json +from pathlib import Path +from superstac import Client + +area = { + "type": "Polygon", + "coordinates": [[[6, 45], [7, 45], [7, 46], [6, 46], [6, 45]]], +} +client = Client.open("https://earth-search.aws.element84.com/v1") +try: + result = client.search( + collections=["sentinel-2-l2a"], + intersects=area, + datetime="2024-06-01T00:00:00Z/2024-06-30T23:59:59Z", + sortby=["-datetime"], + limit=20, + ) + # Filter only the records returned by this search. + clearer = [ + item for item in result.items() + if isinstance(item["properties"].get("eo:cloud_cover"), (int, float)) + and item["properties"]["eo:cloud_cover"] <= 20 + ] + Path("clearer-scenes.geojson").write_text(json.dumps({ + "type": "FeatureCollection", + "features": clearer, + }, indent=2), encoding="utf-8") + print(f"Kept {len(clearer)} of {len(result)} returned records") + for failure in result.metadata["failures"]: + print("Catalog failed:", failure) + + # Look up a real ID from the results instead of guessing one. + if clearer: + item = clearer[0] + lookup = client.search( + collections=[item["collection"]], ids=[item["id"]], limit=1 + ) + for found in lookup.items(): + for name, asset in found.get("assets", {}).items(): + print(name, asset.get("type"), asset.get("href")) + print("Lookup failures:", lookup.metadata["failures"]) +finally: + client.shutdown() +``` + + + + +Run in your app’s browser code after installing the WASM package. The GeoJSON and asset URLs appear in the console. For visible page output, adapt the [browser app example](/docs/v0.3/wasm/overview#put-it-on-a-page). + +```js +import init, { SuperSTAC } from '@spatialnode/superstac-wasm'; + +await init(); +const client = new SuperSTAC({ + catalogs: [{ + id: 'earth-search', + url: 'https://earth-search.aws.element84.com/v1', + }], +}); +try { + const result = await client.search({ + collections: ['sentinel-2-l2a'], + intersects: { + type: 'Polygon', + coordinates: [[[6, 45], [7, 45], [7, 46], [6, 46], [6, 45]]], + }, + datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z', + sortby: ['-datetime'], + limit: 20, + }); + const clearer = result.items.filter(({ item }) => { + const cloud = item.properties['eo:cloud_cover']; + return typeof cloud === 'number' && cloud <= 20; + }); + console.log({ + type: 'FeatureCollection', + features: clearer.map(({ item }) => item), + }); + console.warn('Search failures:', result.metadata.failures); + + if (clearer.length) { + const { item } = clearer[0]; + const lookup = await client.search({ + collections: [item.collection], ids: [item.id], limit: 1, + }); + for (const { item: found } of lookup.items) { + for (const [name, asset] of Object.entries(found.assets ?? {})) { + console.log(name, asset.type, asset.href); + } + } + console.warn('Lookup failures:', lookup.metadata.failures); + } +} finally { + client.free(); +} +``` + + + + +If the catalog rejects sorting, remove `sortby` and retry. Sorting applies within each catalog; it does not sort a combined response globally. Use `bbox` instead of `intersects` when a rectangle is sufficient. + +## Export from the terminal + +The CLI uses a bounding box rather than a GeoJSON geometry. Save the [sample configuration](/superstac/versions/v0.3/examples/superstac.yml) as `superstac.yml`. This example also requires [jq](https://jqlang.org/): + +```bash +superstac --json search \ + --collection sentinel-2-l2a \ + --bbox=6,45,7,46 \ + --datetime 2024-06-01T00:00:00Z/2024-06-30T23:59:59Z \ + --limit 20 > results.json + +jq '.metadata.failures' results.json +jq '{type: "FeatureCollection", features: [.items[].item]}' \ + results.json > items.geojson +``` + +Keep `results.json` if you need provenance and failure metadata. `items.geojson` contains only the STAC items. Logs go to stderr, so redirecting stdout preserves valid JSON. + +## Choose the right asset + +An item can contain a thumbnail, a rendered preview, GeoTIFF bands, and metadata files. Inspect each asset’s `type`, `roles`, and `href` before opening it. + +| You want to… | Look for… | +| --- | --- | +| Show a small preview | A PNG, JPEG, or WebP thumbnail or rendered preview | +| Inspect full-resolution pixels | GeoTIFF or COG assets and a raster reader | +| Keep a record of the search | Item metadata plus the search failure list | +| Preview imagery without writing a viewer | The [playground](/docs/v0.3/wasm/playground) | + +A GeoTIFF is not a browser thumbnail. Asset access is separate from metadata access: a public item can link to assets that need signing or authentication. SuperSTAC returns those links without signing them. + +## More workflows + +- [Use the same collection and band names across catalogs](/docs/v0.3/guides/aliases). +- [Add catalogs and discover available collections](/docs/v0.3/guides/catalogs). +- [Save metadata and query it locally](/docs/v0.3/guides/geoparquet). +- [Handle timeouts and partial failures](/docs/v0.3/guides/resilience). diff --git a/docs/content/docs/v0.3/guides/resilience.md b/docs/content/docs/v0.3/guides/resilience.md index 295902b..f2dfa75 100644 --- a/docs/content/docs/v0.3/guides/resilience.md +++ b/docs/content/docs/v0.3/guides/resilience.md @@ -10,7 +10,10 @@ Use these settings to control how much work a search can do and how long it wait ```python from superstac import Client -client = Client(settings={ +client = Client(catalogs=[{ + "id": "earth-search", + "url": "https://earth-search.aws.element84.com/v1", +}], settings={ "max_concurrent_catalogs": 4, "per_catalog_timeout_seconds": 20, "max_retry_attempts": 2, @@ -18,6 +21,11 @@ client = Client(settings={ "retry_max_backoff_ms": 2000, "max_items_per_catalog": 100, }) +try: + result = client.search(collections=["sentinel-2-l2a"], limit=5) + print(len(result), result.metadata["failures"]) +finally: + client.shutdown() ``` Use positive values for these settings. `max_retry_attempts` includes the first attempt, so `2` allows one retry and `1` turns retries off. The wait between retries doubles until it reaches `retry_max_backoff_ms`. @@ -61,3 +69,7 @@ settings: Disabling the monitor does not skip the initial health check. The global `enable_background_health_monitor` update is not currently applied by memory storage; use the per-catalog setting above. Always call `shutdown()` (or `await client.shutdown()`) to stop background tasks when finished. + +## Browser searches + +The WASM client supports concurrency, per-attempt timeout, attempt count, and item caps in its constructor’s `settings`. It does not expose retry backoff controls or run background health monitoring. Use `result.metadata.failures` to inspect the search outcome; see [browser settings](/docs/v0.3/wasm/overview#configure-the-client). diff --git a/docs/content/docs/v0.3/index.mdx b/docs/content/docs/v0.3/index.mdx index 1b7c941..d9ae577 100644 --- a/docs/content/docs/v0.3/index.mdx +++ b/docs/content/docs/v0.3/index.mdx @@ -1,25 +1,22 @@ --- title: Introduction -description: Search across STAC catalogs through one interface, with Python, Rust, or the command line. +description: Unified search across STAC catalogs. --- import { Cards, Card } from 'fumadocs-ui/components/card'; **Many catalogs. One search.** -Search several STAC catalogs with one query. Add the catalogs you use, choose an area and date range, and get their results together. -Use Python in a notebook or script, Rust in your application, or the command line. SuperSTAC searches catalogs in parallel and reports which ones failed, so you can check whether the results are complete. +Search several STAC catalogs with one query. Add the catalogs you use, choose an area and date range, and get their results together. -For the background and motivation, read [Introducing SuperSTAC: Many catalogs. One search.](https://www.spatialnode.net/articles/introducing-superstac-many-catalogs-one-search2a5e11) +Try it in your browser, use Python in a notebook or script, add Rust to your application, or work from the command line. SuperSTAC searches catalogs in parallel and reports which ones failed, so you can check whether the results are complete. - - - - - - + + + + ## What you can build @@ -34,7 +31,22 @@ Start with [installation](/docs/v0.3/start/installation), then [run your first s | Interface | A good fit for | | --- | --- | +| [Browser / WASM](/docs/v0.3/wasm/overview) | JavaScript and TypeScript apps that search live catalogs. | | [Python Client](/docs/v0.3/python/overview) | Notebooks, scripts, and synchronous pipelines. | | [Python AsyncClient](/docs/v0.3/python/async) | Applications that already use asyncio. | | [Rust engine](/docs/v0.3/rust/overview) | Native integrations on Tokio. | | [CLI](/docs/v0.3/cli/overview) | Terminal exploration and shell workflows. | + +## What works where? + +| Capability | Python | JavaScript / WASM | Rust | CLI | +| --- | --- | --- | --- | --- | +| Live search and collection discovery | Yes | Yes | Yes | Yes | +| Collection and asset aliases | Yes | Yes | Yes | Yes | +| Retries, timeouts, deduplication | Yes | Yes | Yes | Yes | +| GeoJSON geometry filter | Yes | Yes | Yes | Bounding box only | +| Source catalog on each returned item | Not exposed | Yes | Yes | JSON output | +| Saved GeoParquet inventories | Included in wheels | No | Optional feature | Optional feature | +| YAML configuration | Yes | No | Yes | Yes | + +SuperSTAC returns metadata and asset links. For current limits, see [status and limitations](/docs/v0.3/reference/status). diff --git a/docs/content/docs/v0.3/meta.json b/docs/content/docs/v0.3/meta.json index 0ec7745..71b997e 100644 --- a/docs/content/docs/v0.3/meta.json +++ b/docs/content/docs/v0.3/meta.json @@ -5,6 +5,7 @@ "start", "concepts", "guides", + "wasm", "python", "rust", "cli", diff --git a/docs/content/docs/v0.3/python/api.md b/docs/content/docs/v0.3/python/api.md index d94f98d..19eda33 100644 --- a/docs/content/docs/v0.3/python/api.md +++ b/docs/content/docs/v0.3/python/api.md @@ -8,9 +8,13 @@ Import the public types with `from superstac import Client, AsyncClient`. Both c ## Constructors ```text -Client(config=None, *, catalogs=None, providers=None, settings=None, storage="memory") -Client.open(url, *, id=None, storage="memory") -Client.from_yaml(yaml_path, *, storage="memory") +Client(config=None, *, catalogs=None, providers=None, settings=None, + storage="memory", mode="live", dataset=None, + max_snapshot_age_seconds=86400) +Client.open(url, *, id=None, storage="memory", mode="live", + dataset=None, max_snapshot_age_seconds=86400) +Client.from_yaml(yaml_path, *, storage="memory", mode="live", + dataset=None, max_snapshot_age_seconds=86400) ``` `AsyncClient` has the same signatures. Await only `AsyncClient.open(...)`; its constructor and `from_yaml()` are synchronous. @@ -79,6 +83,36 @@ Pass `collections` explicitly, using `[]` for all collections. See [search param Python returns the STAC item dictionaries without Rust’s `catalog_id` and `seen_in` fields. Use metadata to check failures and counts for the whole search; it does not identify the source of each item. +## Saved GeoParquet inventories + +Published Python wheels include these methods. Check a custom build with `from superstac import geoparquet_available`. + +| Constructor option | Use | +| --- | --- | +| `mode="live"` | Query catalog APIs; the default. | +| `mode="snapshot", dataset="./data"` | Search saved metadata only. | +| `mode="auto", dataset="./data"` | Use complete, fresh saved coverage or fall back to the API. | +| `max_snapshot_age_seconds=86400` | Maximum saved-data age in auto mode; measured from ingestion start. | + +```text +client.ingest(catalog_id, output, *, collections=[], bbox=None, + datetime=None, name=None, resume=False, + incremental_since=None, page_size=500, + items_per_file=10000, max_dataset_mib=1024, + timeout_seconds=60, all=False, progress=None) +client.compact_dataset(dataset, *, items_per_file=10000, + max_dataset_mib=1024) +client.cleanup_dataset(dataset, *, apply=False) +``` + +All three methods return dictionaries. Await them on `AsyncClient`. Ingestion needs collection, spatial, or temporal scope unless you explicitly opt into `all=True`. `page_size` controls provider requests, not the total number of records saved. + +`progress` receives a dictionary with fields such as `phase` and `items_saved`. It runs on a worker thread: keep it short and avoid changing notebook or browser widgets directly from it. + +Cleanup previews removable files by default; `apply=True` removes unreferenced files. Release snapshot clients before cleanup or compaction. `shutdown()` stops background tasks but keeps the client’s dataset lock until the object is released. + +See [the complete inventory workflow](/docs/v0.3/guides/geoparquet#use-python) for ingestion, local search, and cleanup. + ## Errors Invalid Python input generally raises `ValueError`. Engine errors are mapped to `RuntimeError`; `get_collection()` uses `KeyError` for a missing collection. A search can also return normally with catalog failures listed in `search.metadata["failures"]`. Check that list even when no exception is raised. diff --git a/docs/content/docs/v0.3/python/async.md b/docs/content/docs/v0.3/python/async.md index 3562c34..8793696 100644 --- a/docs/content/docs/v0.3/python/async.md +++ b/docs/content/docs/v0.3/python/async.md @@ -36,6 +36,7 @@ In a notebook that already has an event loop, use `await main()` instead of `asy | `AsyncClient.from_yaml(path)` | Synchronous construction and file loading. | | `AsyncClient.open(url)` | Await; registers and starts a single-catalog client. | | `start()`, `shutdown()`, `search(...)` | Await. | +| `ingest(...)`, `compact_dataset(...)`, `cleanup_dataset(...)` | Await; see [inventories](/docs/v0.3/guides/geoparquet). | | `list_collections()`, `get_collections()`, `get_collection(id)` | Await. | | `catalogs_supporting(id)`, `collections_by_catalog()`, `describe_collection(catalog_id, collection_id)` | Await. | | Adding, reading, updating, or deleting catalogs/providers; settings methods | Synchronous; access local memory. | diff --git a/docs/content/docs/v0.3/python/overview.md b/docs/content/docs/v0.3/python/overview.md index 6aeb35a..1a95975 100644 --- a/docs/content/docs/v0.3/python/overview.md +++ b/docs/content/docs/v0.3/python/overview.md @@ -5,6 +5,8 @@ description: Search multiple STAC APIs from scripts and notebooks with the synch Use `Client` to search STAC catalogs from a script or notebook. Results and collection documents are ordinary Python dictionaries. +Install with [pip, uv, Poetry, or a Conda environment](/docs/v0.3/start/installation#python). + For an interactive walkthrough, [try the Python notebook in Colab or Jupyter](/docs/v0.3/python/notebook). Search two catalogs, browse the items in a table, map their footprints, and export GeoJSON. ## Connect to one catalog @@ -63,3 +65,10 @@ Iterate with `for item in search.items()`. Search collects the results before re If you use pystac-client, you will recognize `Client.open()`, `search()`, and `items()`. A few differences matter when switching: items are dictionaries rather than `pystac.Item` objects, and `matched()` counts returned items rather than all matches in the catalog. SuperSTAC does not support pystac-client’s full set of options, modifiers, authentication hooks, or pagination methods. Use `to_geojson()`, not `as_geojson()`. See the [API reference](/docs/v0.3/python/api/) for supported methods and [async guide](/docs/v0.3/python/async/) for asyncio applications. + +## Next steps + +- [Search a polygon, filter returned records, and inspect assets](/docs/v0.3/guides/recipes). +- [Give collections and assets your own names](/docs/v0.3/guides/aliases). +- [Save metadata and search it locally](/docs/v0.3/guides/geoparquet#use-python). +- [Use asyncio](/docs/v0.3/python/async) or look up [method signatures](/docs/v0.3/python/api). diff --git a/docs/content/docs/v0.3/reference/results.md b/docs/content/docs/v0.3/reference/results.md index f37f1db..23c28c8 100644 --- a/docs/content/docs/v0.3/reference/results.md +++ b/docs/content/docs/v0.3/reference/results.md @@ -3,7 +3,7 @@ title: Results and provenance description: Read search results, check failed catalogs, and understand how duplicate items are handled. --- -## Rust and CLI response +## JavaScript, Rust, and CLI response A response contains items and metadata about the search. This example shortens the STAC item to show the surrounding fields: @@ -34,6 +34,7 @@ A response contains items and metadata about the search. This example shortens t | Field | Meaning | | --- | --- | +| `superstac_version` | Version of the engine that produced the response. | | `catalogs_queried` | Number selected for execution, after health/source filtering. | | `catalogs_succeeded` | Selected catalogs that completed successfully. | | `catalogs_failed` | Selected catalogs that failed. | @@ -42,7 +43,7 @@ A response contains items and metadata about the search. This example shortens t | `failures` | List of `{catalog_id, reason}` for failed searches. | | `unsupported_collections` | Requested canonical IDs that no candidate catalog was known to serve, reported conservatively when collection knowledge is complete. | -A catalog skipped because it is unhealthy or does not offer the requested collection may be absent from `failures`. If a source is missing, compare the metadata with the catalogs you registered. +In the native engine, a catalog skipped because it is unhealthy or does not offer the requested collection may be absent from `failures`. If a source is missing, compare the metadata with the catalogs you registered. ## Python representation diff --git a/docs/content/docs/v0.3/reference/search.md b/docs/content/docs/v0.3/reference/search.md index f84c66d..3da4e82 100644 --- a/docs/content/docs/v0.3/reference/search.md +++ b/docs/content/docs/v0.3/reference/search.md @@ -3,7 +3,7 @@ title: Search parameters description: Choose collections, areas, dates, item limits, and sort order for a search. --- -Pass filters as keyword arguments in Python or as a `SearchQuery` in Rust. In live mode, SuperSTAC sends them to each selected catalog. For saved metadata, see the [GeoParquet guide](/docs/v0.3/guides/geoparquet). +Pass filters as keyword arguments in Python, an object in JavaScript, a `SearchQuery` in Rust, or flags in the CLI. In live mode, SuperSTAC sends them to each selected catalog. For saved metadata, see the [GeoParquet guide](/docs/v0.3/guides/geoparquet). | Field | Python shape | Behavior | | --- | --- | --- | @@ -41,6 +41,12 @@ SuperSTAC reads result pages from each catalog until it reaches the smaller of ` With three catalogs and `limit=10`, you can get up to 30 items before duplicates are removed. The returned count can be smaller, and more matching items may exist in the catalogs. +## Examples you can adapt + +[Search recipes](/docs/v0.3/guides/recipes) includes complete Python and JavaScript examples for a GeoJSON area, per-catalog sorting, looking up returned IDs, inspecting assets, and filtering the records you already fetched. + +JavaScript uses the same field names. It can omit `collections`; Python requires an explicit list. The CLI supports the listed filters except `intersects`. + ## Unsupported options Search does not yet support CQL2 or cloud-cover filters, selecting which fields to return, sorting the combined results, or custom request headers. Use only the fields listed above: unknown Python keys may be silently ignored. diff --git a/docs/content/docs/v0.3/reference/status.md b/docs/content/docs/v0.3/reference/status.md index 338a26d..049071a 100644 --- a/docs/content/docs/v0.3/reference/status.md +++ b/docs/content/docs/v0.3/reference/status.md @@ -7,6 +7,7 @@ SuperSTAC is a pre-1.0, MIT-licensed project. This documentation targets version ## Available +- Browser WebAssembly binding for live STAC search, with JavaScript and TypeScript support. - Python sync and async clients backed by Rust. - Save metadata to GeoParquet, search it locally, update it, and remove old files. - A Tokio-based Rust engine and command-line tool. @@ -14,7 +15,9 @@ SuperSTAC is a pre-1.0, MIT-licensed project. This documentation targets version - Collection discovery and source selection. - Concurrent searches with per-catalog caps, retries, and timeouts. - Collection/asset aliases and optional response normalization. -- Item-ID deduplication, failure metadata, and Rust/CLI item provenance. +- Item-ID deduplication, failure metadata, and JavaScript/Rust/CLI item provenance. + +The [browser binding](/docs/v0.3/wasm/overview) supports live catalogs only. GeoParquet, filesystem configuration, background health monitoring, and asset signing are not included. ## Not implemented yet @@ -24,7 +27,7 @@ SuperSTAC is a pre-1.0, MIT-licensed project. This documentation targets version - CQL2, field projection, or cloud-cover search options. - Full pystac-client API compatibility. -The package searches item metadata; it does not download, mosaic, render, or reproject imagery. +The search package returns metadata and asset links. The playground adds map previews using separate rendering tools; the package itself does not download or process image pixels. ## Things to check when using v0.3 diff --git a/docs/content/docs/v0.3/reference/troubleshooting.md b/docs/content/docs/v0.3/reference/troubleshooting.md index 0ba407c..dcd5c39 100644 --- a/docs/content/docs/v0.3/reference/troubleshooting.md +++ b/docs/content/docs/v0.3/reference/troubleshooting.md @@ -44,6 +44,14 @@ Use `--json` for machine-readable output. CLI logs and ingestion progress go to Use Python 3.9+ and ensure the package is installed in the interpreter you are running. Check `python -m pip show superstac`. If a compatible wheel is unavailable, follow the [source build instructions](/docs/v0.3/start/installation/). +## A browser search fails but the URL opens in a tab + +A catalog can allow navigation while blocking requests from another website. Inspect the failed request in Developer Tools → Network. The API must allow your app’s origin through CORS, including POST searches. SuperSTAC cannot change a catalog’s CORS policy; use a catalog that supports browser access or a proxy you control. + +## The WASM module does not load + +Check the `.wasm` request in Developer Tools. Serve it over HTTP(S), ensure it returns the binary rather than an HTML page, and follow your bundler’s asset-URL rules. The [complete browser example](/docs/v0.3/wasm/overview#put-it-on-a-page) shows an explicit URL with Vite. + ## Report a problem Include the package version or commit, Python/Rust version, operating system, a minimal configuration without credentials, the query, and failure metadata. Open an issue on [GitHub](https://github.com/spatialnode/superstac/issues). @@ -57,7 +65,7 @@ python -c "import superstac; print(superstac.__version__)" Rust applications can report `superstac_engine::VERSION`. Search response metadata includes `superstac_version`, including searches with per-catalog failures. -Provider requests use `User-Agent: superstac/`. This identifies the +Native provider requests use `User-Agent: superstac/`. Browser requests keep the browser’s User-Agent. This identifies the library, not an individual user, and does not send telemetry to SuperSTAC. Newly ingested or compacted Parquet files record `created_by` and a JSON diff --git a/docs/content/docs/v0.3/releases.mdx b/docs/content/docs/v0.3/releases.mdx index 2c0a7f5..dd0e13e 100644 --- a/docs/content/docs/v0.3/releases.mdx +++ b/docs/content/docs/v0.3/releases.mdx @@ -1,5 +1,5 @@ --- -title: What’s new +title: What's new description: Recent SuperSTAC releases and upgrade notes. --- @@ -9,7 +9,7 @@ October 2, 2026 Save STAC metadata for your study area, then search it again without downloading the same metadata each time. Use automatic mode to query live catalogs when your -saved data is too old or doesn’t cover the search. +saved data is too old or doesn't cover the search. GeoParquet is available in Python, Rust, and the CLI. Ingestion shows progress and can resume after an interruption. You can also update an inventory and clean up @@ -18,9 +18,15 @@ old files. Imagery and previews still come from the provider. [Try the notebook](/docs/v0.3/python/geoparquet-notebook) or follow the [GeoParquet guide](/docs/v0.3/guides/geoparquet). -**Upgrading:** Python wheels include GeoParquet. For Rust and the CLI, enable the -`geoparquet` feature. Existing version-1 inventories remain readable; writing an -update upgrades their manifest to version 2, which older builds cannot read. +
+Using an inventory from an older release? + +You can read existing inventories without changing them. The next successful +ingestion updates the inventory manifest from version 1 to version 2. Older +SuperSTAC builds cannot read the updated manifest, so keep a backup if you need +to switch back to an older build. + +
## v0.2 — Python support diff --git a/docs/content/docs/v0.3/rust/overview.md b/docs/content/docs/v0.3/rust/overview.md index b7276ce..25e68a5 100644 --- a/docs/content/docs/v0.3/rust/overview.md +++ b/docs/content/docs/v0.3/rust/overview.md @@ -7,6 +7,15 @@ Use `SuperSTACEngine` to search catalogs from a Tokio application. It checks cat ## Dependencies +Create an application first: + +```bash +cargo new imagery-search +cd imagery-search +``` + +Add these dependencies to the generated `Cargo.toml`: + ```toml title="Cargo.toml" [dependencies] superstac-core = "0.3" @@ -32,29 +41,33 @@ use superstac_search::query::SearchQuery; async fn main() -> Result<(), Box> { let storage = init_from_yaml(Storage::Memory, "superstac.yml")?; let engine = SuperSTACEngine::new(storage); - - let result = engine.search(SearchQuery { + let query = SearchQuery { collections: vec!["sentinel-2-l2a".into()], + bbox: Some(vec![6.0, 45.0, 7.0, 46.0].try_into()?), + datetime: Some( + "2024-06-01T00:00:00Z/2024-06-30T23:59:59Z".into(), + ), + limit: Some(5), ids: None, intersects: None, - bbox: None, - datetime: Some("2024-01-01T00:00:00Z/2024-01-31T23:59:59Z".into()), - limit: Some(10), sortby: None, - }).await; - + }; + let result = engine.search(query).await; engine.shutdown().await; - let response = result?; - for entry in response.items { + let result = result?; + for entry in result.items { println!("{} from {:?}", entry.item.id, entry.seen_in); } - for failure in response.metadata.failures { + for failure in result.metadata.failures { eprintln!("{}: {}", failure.catalog_id, failure.reason); } Ok(()) } ``` +Run `cargo run` from the project root. The example prints item IDs, source catalogs, and any catalog failures. + + Search starts the engine if needed. Explicit `engine.start().await?` is useful when you want to finish startup before serving application requests. `shutdown().await` cancels background health tasks. ## Discovery @@ -78,12 +91,23 @@ Use `SuperSTACEngine::new` when the engine can own the boxed storage backend. Us | `superstac-config` | YAML loading and backend population. | | `superstac-search` | Query translation, concurrent execution, normalization, aggregation. | | `superstac-engine` | Lifecycle, health, discovery, source selection. | +| `superstac-geoparquet` | Save catalog metadata as GeoParquet, search local inventories, and compact or clean up datasets. | | `superstac-cli` | Terminal interface over the engine. | +For local inventories or automatic selection between saved data and live catalogs, enable the engine's `geoparquet` feature. Replace the `superstac-engine` dependency above with: + +```toml +superstac-engine = { version = "0.3", features = ["geoparquet"] } +``` + +The engine re-exports the ingestion and maintenance helpers, so most applications do not need a separate `superstac-geoparquet` dependency. Add that crate directly when working with its backend or dataset APIs. Follow the [complete Rust inventory example](/superstac/docs/guides/geoparquet#rust) to save and search a dataset. + Generate API documentation for the exact checkout with: ```bash -cargo doc --no-deps -p superstac-core -p superstac-config -p superstac-search -p superstac-engine --open +cargo doc --no-deps --features superstac-engine/geoparquet \ + -p superstac-core -p superstac-config -p superstac-search \ + -p superstac-engine -p superstac-geoparquet --open ``` -Only memory storage is implemented. The `Sqlite` and `Postgres` variants currently construct memory backends; they do not provide persistence. +The catalog registry currently uses memory storage. The `Sqlite` and `Postgres` variants also construct memory backends; they do not persist the registry. GeoParquet inventories are stored on disk separately. diff --git a/docs/content/docs/v0.3/start/installation.md b/docs/content/docs/v0.3/start/installation.md deleted file mode 100644 index ed83fd5..0000000 --- a/docs/content/docs/v0.3/start/installation.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: Installation -description: Install SuperSTAC for Python, Rust, or the command line. ---- - -Choose Python for notebooks and scripts, Rust to add search to an application, or the CLI to work from your terminal. - -## Python - -```bash -python -m pip install "superstac>=0.3,<0.4" -``` - -You need **Python 3.9 or newer**. - -Verify the import: - -```bash -python -c "from superstac import Client, AsyncClient; print(Client, AsyncClient)" -``` - -To build from source, you need **Rust 1.88 or newer** and Python development tools. Use this if no wheel is available for your platform: - -```bash -git clone https://github.com/spatialnode/superstac -cd superstac/crates/python -git checkout v0.3.0 -python -m venv .venv -source .venv/bin/activate -python -m pip install maturin -maturin develop --release -``` - -On Windows, activate the environment with `.venv\Scripts\Activate.ps1` in PowerShell. - -## Rust - -Add the crates that your application uses: - -```bash -cargo add superstac-core@0.3 superstac-config@0.3 superstac-search@0.3 superstac-engine@0.3 -cargo add tokio --features macros,rt-multi-thread -``` - -These docs target SuperSTAC 0.3. See the [Rust guide](/docs/v0.3/rust/overview/). - -## Command-line tool - -Build and install the CLI from the repository: - -```bash -git clone https://github.com/spatialnode/superstac -cd superstac -git checkout v0.3.0 -cargo install --path crates/cli -superstac --help -``` - -Or build without installing: - -```bash -cargo build --release -p superstac-cli -./target/release/superstac --help -``` - -The executable is named `superstac`; the Rust package is `superstac-cli`. - -## Run a search - -[Run a first search](/docs/v0.3/start/quickstart/) against two catalogs, or read the [CLI reference](/docs/v0.3/cli/overview/) for flags and output formats. diff --git a/docs/content/docs/v0.3/start/installation.mdx b/docs/content/docs/v0.3/start/installation.mdx new file mode 100644 index 0000000..efaf1f1 --- /dev/null +++ b/docs/content/docs/v0.3/start/installation.mdx @@ -0,0 +1,143 @@ +--- +title: Installation +description: Install SuperSTAC with your preferred package manager, then run a search. +--- + +import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; + +Choose the interface you want to use. You only need to install one. + +| If you work in… | Start here | +| --- | --- | +| A notebook or Python script | [Python](#python) | +| A browser app | [JavaScript / TypeScript](#javascript--typescript) | +| A Rust application | [Rust](#rust) | +| A terminal | [Command line](#command-line) | + +Just exploring? [Try the playground](/docs/v0.3/wasm/playground) without installing anything. + +## Python + +Use Python 3.9 or newer. Choose your package manager: + + + + +```bash +pip install superstac +``` + + + + +```bash +uv add superstac +``` + + + + +```bash +poetry add superstac +``` + + + + +In your active Conda environment: + +```bash +pip install superstac +``` + + + + +```text +%pip install superstac +``` + + + + +Then [run your first search](/docs/v0.3/start/quickstart) or [open the notebook](/docs/v0.3/python/notebook). + +
+No compatible wheel? Build from source + +This path requires Rust 1.88 or newer and Python development tools. + +```bash +git clone https://github.com/spatialnode/superstac +cd superstac +git checkout v0.3.0 +cd crates/python +python -m venv .venv +``` + +Activate with `source .venv/bin/activate` on macOS/Linux or `.venv\Scripts\Activate.ps1` in Windows PowerShell, then: + +```bash +python -m pip install maturin +maturin develop --release +``` + +
+ +## JavaScript / TypeScript + +The browser package contains compiled WebAssembly and TypeScript declarations. You do not need Rust to use it. + +```bash +npm install @spatialnode/superstac-wasm +``` + +The [browser guide](/docs/v0.3/wasm/overview#install) includes pnpm, Yarn, and Bun commands and a complete search example. + +## Rust + +Use Rust 1.88 or newer. In your application directory: + +```bash +cargo add superstac-core@0.3 superstac-config@0.3 +cargo add superstac-search@0.3 superstac-engine@0.3 +cargo add tokio --features macros,rt-multi-thread +``` + +For saved GeoParquet inventories, also enable: + +```bash +cargo add superstac-engine@0.3 --features geoparquet +``` + +Follow the [complete Rust example](/docs/v0.3/rust/overview) to create a project, load catalogs, and run a search. + +## Command line + +The CLI currently installs from source and requires Rust 1.88 or newer: + +```bash +git clone https://github.com/spatialnode/superstac +cd superstac +git checkout v0.3.0 +cargo install --path crates/cli --features geoparquet +superstac --version +``` + +This includes inventory commands such as `ingest` and `compact`. Omit `--features geoparquet` if you only need live catalog searches. + +Save the [sample configuration](/superstac/versions/v0.3/examples/superstac.yml) as `superstac.yml` in your working directory, then follow the [CLI quickstart](/docs/v0.3/start/quickstart). + +
+Build the CLI without installing it + +From the repository root: + +```bash +cargo build --release -p superstac-cli --features geoparquet +./target/release/superstac --help +``` + +On Windows, run `target\release\superstac.exe --help`. + +
diff --git a/docs/content/docs/v0.3/start/quickstart.mdx b/docs/content/docs/v0.3/start/quickstart.mdx index acc35aa..c82892e 100644 --- a/docs/content/docs/v0.3/start/quickstart.mdx +++ b/docs/content/docs/v0.3/start/quickstart.mdx @@ -1,120 +1,182 @@ --- title: Your first search -description: Register two catalogs, search Sentinel-2 imagery, and inspect a combined response. +description: Search two catalogs, inspect the results, and keep the scene metadata. --- import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; -[Install SuperSTAC](/docs/v0.3/start/installation/) first. This example searches Sentinel-2 imagery in Earth Search and Microsoft Planetary Computer. - -You can also [run the Python notebook](/docs/v0.3/python/notebook) in Colab or Jupyter to see the results on a map and download them. - -## 1. Register your catalogs - -For the CLI, save this file as `superstac.yml` in your working directory, or [download it](/superstac/versions/v0.3/examples/superstac.yml). The Python example below defines its catalogs directly, so you can skip the file unless you want to share a configuration. - -```yaml title="superstac.yml" -providers: [] - -catalogs: - - id: earth-search - url: https://earth-search.aws.element84.com/v1 - - id: microsoft - url: https://planetarycomputer.microsoft.com/api/stac/v1 - -settings: - health_check_strategy: hourly - healthy_status_code_range: [200, 299] - auto_fix_duplicate_catalog_id: true - auto_fix_duplicate_provider_id: true - log_level: info - logging_enabled: true - search_healthy_catalogs_only: true - deduplicate_items: true - unify_response: true - max_concurrent_catalogs: 8 - per_catalog_timeout_seconds: 30 - max_retry_attempts: 2 - retry_initial_backoff_ms: 100 - retry_max_backoff_ms: 2000 - max_items_per_catalog: 1000 -``` +Search Sentinel-2 imagery over the French Alps in June 2024. Choose your language below; each example queries Earth Search and Microsoft Planetary Computer. -> **Use the complete YAML** -> -> Keep `catalogs`, `providers`, and the required fields in `settings`, even if you leave `providers` empty. Python dictionaries can omit settings that YAML requires; see [configuration](/docs/v0.3/reference/configuration) for details. +[Install your chosen interface](/docs/v0.3/start/installation) first. Prefer a visual walkthrough? [Try the playground](/docs/v0.3/wasm/playground) or [open the Python notebook](/docs/v0.3/python/notebook). -## 2. Search an area and time range +## Run the search - + -```python +Save this as `search.py`. Run `python search.py`, `uv run search.py`, or `poetry run python search.py`, depending on your environment. + +```python title="search.py" +import json +from pathlib import Path from superstac import Client -client = Client(catalogs=[ - {"id": "earth-search", "url": "https://earth-search.aws.element84.com/v1"}, - {"id": "microsoft", "url": "https://planetarycomputer.microsoft.com/api/stac/v1"}, -]) +catalogs = [ + { + "id": "earth-search", + "url": "https://earth-search.aws.element84.com/v1", + }, + { + "id": "microsoft", + "url": "https://planetarycomputer.microsoft.com/api/stac/v1", + }, +] +client = Client(catalogs=catalogs) try: - search = client.search( + result = client.search( collections=["sentinel-2-l2a"], - bbox=[6.0, 49.0, 7.0, 50.0], - datetime="2024-01-01T00:00:00Z/2024-01-31T23:59:59Z", + bbox=[6, 45, 7, 46], + datetime="2024-06-01T00:00:00Z/2024-06-30T23:59:59Z", limit=5, ) - for item in search.items(): - print(item["id"], item["collection"]) - print(search.metadata) + for item in result.items(): + print(item["id"], item["properties"].get("datetime")) + for failure in result.metadata["failures"]: + print("Catalog failed:", failure["catalog_id"], failure["reason"]) + Path("items.geojson").write_text( + json.dumps(result.to_geojson(), indent=2), encoding="utf-8" + ) + print(f"Saved {len(result)} scene records to items.geojson") finally: client.shutdown() ``` -To load the YAML file, create the client with `client = Client.from_yaml("superstac.yml")`. + + + +Use this in the browser entry point of your app after [installing the WASM package](/docs/v0.3/wasm/overview#install). It writes scene IDs, source catalogs, and GeoJSON to the browser console. For a complete page with a button and visible results, use the [browser app example](/docs/v0.3/wasm/overview#put-it-on-a-page). + +```js title="search.js" +import init, { SuperSTAC } from '@spatialnode/superstac-wasm'; + +await init(); +const client = new SuperSTAC({ + catalogs: [ + { + id: 'earth-search', + url: 'https://earth-search.aws.element84.com/v1', + }, + { + id: 'microsoft', + url: 'https://planetarycomputer.microsoft.com/api/stac/v1', + }, + ], +}); + +try { + const result = await client.search({ + collections: ['sentinel-2-l2a'], + bbox: [6, 45, 7, 46], + datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z', + limit: 5, + }); + for (const { item, seen_in } of result.items) { + console.log(item.id, seen_in); + } + for (const failure of result.metadata.failures) { + console.warn(failure.catalog_id, failure.reason); + } + const geojson = { + type: 'FeatureCollection', + features: result.items.map(({ item }) => item), + }; + console.log(geojson); +} finally { + client.free(); +} +``` + + + + +Create an application and add the [Rust dependencies](/docs/v0.3/rust/overview#dependencies). Save the [sample configuration](/superstac/versions/v0.3/examples/superstac.yml) as `superstac.yml` in the project root, then put this in `src/main.rs` and run `cargo run`. + +```rust title="src/main.rs" +use superstac_config::init_from_yaml; +use superstac_core::models::storage::Storage; +use superstac_engine::SuperSTACEngine; +use superstac_search::query::SearchQuery; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let storage = init_from_yaml(Storage::Memory, "superstac.yml")?; + let engine = SuperSTACEngine::new(storage); + let query = SearchQuery { + collections: vec!["sentinel-2-l2a".into()], + bbox: Some(vec![6.0, 45.0, 7.0, 46.0].try_into()?), + datetime: Some( + "2024-06-01T00:00:00Z/2024-06-30T23:59:59Z".into(), + ), + limit: Some(5), + ids: None, + intersects: None, + sortby: None, + }; + let result = engine.search(query).await; + engine.shutdown().await; + let result = result?; + for entry in result.items { + println!("{} from {:?}", entry.item.id, entry.seen_in); + } + for failure in result.metadata.failures { + eprintln!("{}: {}", failure.catalog_id, failure.reason); + } + Ok(()) +} +``` - + + +Save the [sample configuration](/superstac/versions/v0.3/examples/superstac.yml) as `superstac.yml` in the directory where you run the command. ```bash -superstac search \ - -c sentinel-2-l2a \ - -b 6.0,49.0,7.0,50.0 \ - -d 2024-01-01T00:00:00Z/2024-01-31T23:59:59Z \ - -l 5 +superstac --json search \ + --collection sentinel-2-l2a \ + --bbox=6,45,7,46 \ + --datetime 2024-06-01T00:00:00Z/2024-06-30T23:59:59Z \ + --limit 5 > results.json ``` -Inspect the full response as JSON: +Open `results.json` to see the items and search metadata. With [jq](https://jqlang.org/) installed: ```bash -superstac --json search -c sentinel-2-l2a -l 5 +jq '.metadata' results.json +jq '{type: "FeatureCollection", features: [.items[].item]}' \ + results.json > items.geojson ``` -The first search also checks whether catalogs are reachable and which collections they offer. It can take longer than later searches. +## What to expect -## 3. Understand the response +- **Up to five items per catalog.** Two catalogs can return up to ten items before duplicate IDs are removed. +- **Source coverage can differ.** Different providers do not always return the same scenes or use the same IDs. +- **Failures are part of the response.** Check `metadata.failures` even when the call succeeds. Zero items can mean either no matches or unavailable catalogs. +- **These are scene records.** GeoJSON contains footprints, properties, and asset links—not image pixels. -`limit=5` asks for up to five items from each catalog. With two catalogs, you can get up to ten items before duplicate IDs are removed. More matching scenes may exist. +The native clients check health and discover collections before their first search. Browser searches go directly to the configured catalogs, which must allow browser requests. -Check `search.metadata` as well as the items. `catalogs_queried` tells you how many catalogs were searched; `catalogs_failed` and `failures` tell you what went wrong. If no items come back, these fields help you distinguish an empty search from an unavailable catalog. - -## 4. Export the items - -```python -import json -from superstac import Client - -client = Client.open("https://earth-search.aws.element84.com/v1") -try: - search = client.search(collections=["sentinel-2-l2a"], limit=5) - with open("items.geojson", "w") as output: - json.dump(search.to_geojson(), output) -finally: - client.shutdown() -``` +## Make it yours -This writes item metadata, not satellite image pixels. Accessing an asset URL may require provider-specific authentication or signing. +| To… | Change… | +| --- | --- | +| Search another place | `bbox`, in west, south, east, north order | +| Choose different dates | `datetime`, as `start/end` | +| Use another dataset | `collections`; [discover IDs first](/docs/v0.3/guides/catalogs) | +| Get more records | `limit`, up to the configured per-catalog cap | +| Use consistent names | [Collection and asset aliases](/docs/v0.3/guides/aliases) | +| Reuse metadata locally | [GeoParquet inventories](/docs/v0.3/guides/geoparquet) | -Continue with [Python](/docs/v0.3/python/overview/), [Rust](/docs/v0.3/rust/overview/), or [configuration](/docs/v0.3/reference/configuration/). +For more complete workflows, see [search recipes](/docs/v0.3/guides/recipes). diff --git a/docs/content/docs/v0.3/wasm/development.md b/docs/content/docs/v0.3/wasm/development.md new file mode 100644 index 0000000..060debd --- /dev/null +++ b/docs/content/docs/v0.3/wasm/development.md @@ -0,0 +1,38 @@ +--- +title: Build from source +description: Build and test the WebAssembly binding when contributing to SuperSTAC. +--- + +This guide is for changing the Rust binding or producing your own package. To use SuperSTAC in a JavaScript app, start with the [browser guide](/docs/v0.3/wasm/overview). + +## Build the browser package + +Install Rust 1.88 or newer, then run these commands from the repository root: + +```bash +rustup target add wasm32-unknown-unknown +cargo install wasm-pack --version 0.13.1 --locked --jobs 1 +wasm-pack build crates/wasm --scope spatialnode --target web --release --locked --jobs 1 +``` + +The generated package is in `crates/wasm/pkg`. Builds use one Cargo worker to limit resource use. The package contains JavaScript, WebAssembly, TypeScript declarations, and npm metadata. + +Install the generated package from your app directory: + +```bash +npm install /path/to/superstac/crates/wasm/pkg +``` + +Your application imports `@spatialnode/superstac-wasm`, just as it would with an npm release. + +## Try a local build + +From the repository root: + +```bash +python3 -m http.server 8000 --directory crates/wasm +``` + +Open `http://localhost:8000/examples/` for a small browser example. For the full docs playground, `node docs/scripts/prepare-wasm.mjs` copies the existing package into the docs assets without compiling Rust. + +The [WASM contributor README](https://github.com/spatialnode/superstac/tree/main/crates/wasm#tests) describes API checks and browser tests. The **WASM checks** workflow also builds the package and uploads the **wasm-web** artifact for use without a local Rust toolchain. diff --git a/docs/content/docs/v0.3/wasm/meta.json b/docs/content/docs/v0.3/wasm/meta.json new file mode 100644 index 0000000..5ef63df --- /dev/null +++ b/docs/content/docs/v0.3/wasm/meta.json @@ -0,0 +1,9 @@ +{ + "title": "Browser / WASM", + "pages": [ + "playground", + "overview", + "development" + ], + "defaultOpen": true +} diff --git a/docs/content/docs/v0.3/wasm/overview.mdx b/docs/content/docs/v0.3/wasm/overview.mdx new file mode 100644 index 0000000..53d02b0 --- /dev/null +++ b/docs/content/docs/v0.3/wasm/overview.mdx @@ -0,0 +1,291 @@ +--- +title: Use SuperSTAC in the browser +description: Search STAC catalogs from JavaScript or TypeScript with the WebAssembly binding. +--- + +import { Tabs, Tab } from 'fumadocs-ui/components/tabs'; + +Search STAC APIs from your browser app and get scene records with their source catalogs. Use the same client from JavaScript or TypeScript. + +[Try the playground](/docs/v0.3/wasm/playground) to see it without installing anything. + +## Install + +You do not need Rust to use SuperSTAC in a browser app. The browser package includes the compiled WebAssembly module and TypeScript declarations. + +Install with your preferred package manager: + + + + +```bash +npm install @spatialnode/superstac-wasm +``` + + + + +```bash +pnpm add @spatialnode/superstac-wasm +``` + + + + +```bash +yarn add @spatialnode/superstac-wasm +``` + + + + +```bash +bun add @spatialnode/superstac-wasm +``` + + + + + +## Search two catalogs + +Put this in your app’s browser code. For frameworks with server rendering, initialize the client on the browser side. Your bundler must serve the package’s `.wasm` file alongside its JavaScript module. + +```js +import init, { SuperSTAC } from '@spatialnode/superstac-wasm'; + +await init(); +const client = new SuperSTAC({ + catalogs: [ + { id: 'earth-search', url: 'https://earth-search.aws.element84.com/v1' }, + { id: 'microsoft', url: 'https://planetarycomputer.microsoft.com/api/stac/v1' }, + ], +}); + +try { + const result = await client.search({ + collections: ['sentinel-2-l2a'], + bbox: [6, 45, 7, 46], // West, south, east, north, in degrees. + datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z', + limit: 5, + }); + + for (const { item, seen_in } of result.items) { + console.log(item.id, seen_in); + } + console.log(result.metadata.failures); +} finally { + client.free(); // After all pending calls have finished. +} +``` + +Initialization loads the WASM module once. `search()` returns a Promise. `limit` is per catalog, defaults to 10, and is capped at 1,000 by default. Omit `collections` to search without a collection filter. Other query fields are `ids`, `intersects`, and `sortby`; use either `bbox` or `intersects`, not both. + +## Understand the response + +`result.items` contains entries with: + +- `item`: the STAC Feature, including properties, assets, and links. +- `catalog_id`: the catalog whose copy was kept. +- `seen_in`: all catalogs that returned that item ID. + +`result.metadata` includes counts, duplicate records removed, and per-catalog failures. A request can resolve successfully even if every catalog failed, so check `metadata.failures` as well as `items`. + +Duplicate detection uses the item ID. Two records of the same acquisition with different IDs are not automatically combined. The merged results are not globally sorted. + +Inside the `try` block above, turn the returned items into a GeoJSON FeatureCollection: + +```js +const geojson = { + type: 'FeatureCollection', + features: result.items.map(({ item }) => item), +}; +``` + +## Discover collections + +```js +import init, { SuperSTAC } from '@spatialnode/superstac-wasm'; + +await init(); +const client = new SuperSTAC({ + catalogs: [{ + id: 'earth-search', + url: 'https://earth-search.aws.element84.com/v1', + }], +}); +try { + for (const catalog of await client.listCollections()) { + if (catalog.error) console.warn(catalog.catalog_id, catalog.error); + for (const entry of catalog.collections) { + console.log(entry.canonical_id, entry.collection.title); + } + } +} finally { + client.free(); +} +``` + +Each collection entry includes its `canonical_id` and original `collection` metadata. Discovery follows pagination, returns up to 10,000 collections per catalog, and reports failures separately. It does not change which catalogs future searches query. + +## Configure the client + +All settings below are optional. To tune your search, replace the constructor in the first example with this one. The values shown are the defaults: + +```js +const client = new SuperSTAC({ + catalogs: [ + { id: 'earth-search', url: 'https://earth-search.aws.element84.com/v1' }, + ], + settings: { + deduplicate_items: true, + unify_response: true, + max_concurrent_catalogs: 8, + per_catalog_timeout_seconds: 30, + max_retry_attempts: 2, + max_items_per_catalog: 1000, + }, +}); +``` + +`max_retry_attempts` includes the first attempt. Timeouts cover one catalog's paginated search attempt. Failed attempts discard their partial results. Background tabs can delay browser timers. + +Catalogs can also include `collection_aliases` and `asset_aliases`, using the [same canonical-to-provider mappings](/docs/v0.3/guides/aliases) as the other interfaces. + +## Put it on a page + +This small Vite app displays results and failures on the page. It runs searches only when you click the button and keeps GeoJSON ready to copy from the output. + +Create a folder with a `package.json` containing: + +```json title="package.json" +{ + "name": "superstac-browser-demo", + "private": true, + "type": "module", + "scripts": { "dev": "vite" } +} +``` + +Install `@spatialnode/superstac-wasm` using the instructions above, then add Vite: + + + + +```bash +npm install --save-dev vite +npm run dev +``` + + + + +```bash +pnpm add --save-dev vite +pnpm dev +``` + + + + +```bash +yarn add --dev vite +yarn dev +``` + + + + +```bash +bun add --dev vite +bun run dev +``` + + + + +Create these two files in the project root, then open the local address printed by Vite. Use a Node.js version supported by your installed Vite release. + +```html title="index.html" + + + + + + My STAC search + + +

Imagery over the French Alps

+ +

Ready to search.

+

+    
+  
+
+```
+
+```js title="main.js"
+import init, { SuperSTAC } from '@spatialnode/superstac-wasm';
+import wasmUrl from '@spatialnode/superstac-wasm/superstac_wasm_bg.wasm?url';
+
+const button = document.querySelector('#search');
+const status = document.querySelector('#status');
+const output = document.querySelector('#results');
+
+button.addEventListener('click', async () => {
+  button.disabled = true;
+  status.textContent = 'Searching…';
+  output.textContent = '';
+  let client;
+  try {
+    await init({ module_or_path: wasmUrl });
+    client = new SuperSTAC({
+      catalogs: [{
+        id: 'earth-search',
+        url: 'https://earth-search.aws.element84.com/v1',
+      }],
+    });
+    const result = await client.search({
+      collections: ['sentinel-2-l2a'],
+      bbox: [6, 45, 7, 46],
+      datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z',
+      limit: 5,
+    });
+    status.textContent =
+      `${result.items.length} scenes; ${result.metadata.catalogs_failed} catalogs failed.`;
+    output.textContent = JSON.stringify({
+      geojson: {
+        type: 'FeatureCollection',
+        features: result.items.map(({ item }) => item),
+      },
+      failures: result.metadata.failures,
+    }, null, 2);
+  } catch (error) {
+    status.textContent = `Search failed: ${error.message ?? error}`;
+  } finally {
+    client?.free();
+    button.disabled = false;
+  }
+});
+```
+
+Vite’s `?url` import gives the WASM binary a served URL. Other bundlers may handle binary assets differently. If `init()` fails, check that the `.wasm` request returns the binary rather than an HTML fallback page. In a server-rendered framework, run this code only in the browser.
+
+For TypeScript, include `DOM`, your usual ECMAScript libraries, and `ESNext.Disposable` in `tsconfig.json`. The package supplies its own API types. Vite projects also need `vite/client` types for `?url` imports.
+
+## Request identification and logs
+
+The browser binding uses the browser’s User-Agent. It does not append a SuperSTAC
+identifier or install a Rust tracing subscriber. Catalog errors are available in
+`metadata.failures`; complete browser requests can be inspected in Developer Tools.
+The [playground](/docs/v0.3/wasm/playground) adds its own activity panel with request
+response times, HTTP outcomes, and total operation time, without changing request
+headers. This instrumentation belongs to the playground, not the npm binding API.
+
+## Browser requirements
+
+Catalogs must allow browser requests through CORS. WASM cannot bypass that restriction; an application may need a server-side proxy for a catalog that disallows it. The first search uses POST `/search`; pagination supports GET and POST next links.
+
+The browser binding uses in-memory configuration. It does not include filesystem YAML loading, GeoParquet, background health monitors, asset signing, or capability-based catalog selection. Node.js and WASI are not validated targets.
+
+For TypeScript, include `ESNext.Disposable` alongside your usual libraries because the generated bindings expose `Symbol.dispose`. Calling `client.free()` explicitly works too.
diff --git a/docs/content/docs/v0.3/wasm/playground.mdx b/docs/content/docs/v0.3/wasm/playground.mdx
new file mode 100644
index 0000000..9172378
--- /dev/null
+++ b/docs/content/docs/v0.3/wasm/playground.mdx
@@ -0,0 +1,65 @@
+---
+title: Playground
+description: Search your catalogs, adjust settings, and preview scenes on a map.
+---
+
+Open the SuperSTAC playground →
+
+The map fills the playground. Search controls, scene results, and activity open in floating panels without resizing it. No installation needed. Start with Earth Search and Planetary Computer, or add a public STAC API of your own.
+
+## Set up a search
+
+1. Open **Find imagery**, then **Catalogs**. Choose **Browse STAC Index** to search the public API directory by name, place, or topic. Choose a catalog, review its details, and save it. Or use **Add by URL** to enter any browser-accessible STAC API URL. In **Catalogs**, edit, remove, or select catalogs. Providers are labels for organizing catalogs; removing a provider keeps its catalogs. Open a catalog’s editor to set collection and asset aliases. Provider editors accept an optional logo URL; initials appear when the logo is missing or unavailable.
+2. Use **Browse collections** beside the collection field to browse each selected catalog. **Find collections** is also available in Catalogs. Choose a collection to put its ID into the search form.
+3. Pan and zoom the map, then open **Find imagery** and choose **Use visible map area**, or select a preset area. Choose dates and click **Search scenes**. Close the panel with **×** to return to the map. The **Search scenes** button stays at the bottom of the panel while filters scroll. Starting a search closes the controls. You can also enter a bounding box, scene IDs, sorting, or a GeoJSON geometry. A geometry replaces the selected area.
+4. In **Settings**, adjust deduplication, alias normalization, concurrent catalogs, timeout, attempts, and the item cap. Click **Apply settings** before searching. Edits are marked as unsaved until you apply them.
+
+Settings and catalogs stay in the current tab until you reload. **Settings → Reset workspace → Restore starter workspace** restores the starter catalogs, filters, and settings. Custom catalogs must allow browser requests (CORS).
+
+## Try collection and asset aliases
+
+In **Catalogs**, expand **Try aliases with a real search** and load the example. It selects Earth Search and Planetary Computer, maps `optical` to their `sentinel-2-l2a` collections, and maps the `visual` asset to `rgb`. Run the search, then expand **Names in this result** on a scene to see the mappings. The example enables response normalization and changes the selected catalogs; your other catalogs remain available.
+
+You can edit these mappings as name pairs in each catalog’s editor. Asset aliases belong to a collection name in your app. **Edit as JSON** is available for larger configurations.
+
+The directory loads from the [STAC Index API](https://stacindex.org/api/catalogs) only when opened and is cached for the current page session. It lists public API entries, omitting static JSON catalogs and entries marked private or protected. Directory listings are not connection checks: use **Check connections** after adding a catalog. Manual URL entry remains available if the directory is unreachable.
+
+## Explore scenes
+
+Results appear in a collapsible panel over the map, keeping the map full-size. Selecting a scene hides the panel; **Show results** brings it back. **Back to results** closes the preview and reopens the scene list.
+
+Each card tries the thumbnail first, then the catalog’s rendered preview or another image if the thumbnail is missing or fails to load. It also shows capture date, cloud cover, assets, and source catalogs. **Preview on map** opens the scene on the OpenFreeMap basemap. Choose an image or GeoTIFF from **Image layer**. Image previews are positioned over the scene bounds for a quick look; their alignment is approximate. GeoTIFF previews load only when selected. HOT OpenAerialMap scenes use the catalog’s raster service to render and reproject imagery as map tiles. Other GeoTIFFs use the direct reader, which requires Web Mercator (EPSG:3857), CORS, and HTTP range support.
+
+The overview map shows the search area and available scene footprints. Click a footprint or a scene’s preview button to select it. You can also open the original scene in STAC Map or STAC Browser in a new tab; those external services choose their own basemaps. The playground does not embed either service.
+
+Preview support depends on asset encoding, CORS, HTTP range support, and any signing or authentication the provider requires. SuperSTAC does not sign asset URLs. When an asset cannot be displayed, try another asset or an external viewer. Scenes without a public record URL can still show their footprint here, but cannot open in the external viewers.
+
+By default, matching item IDs become one record with both source labels. The scene limit applies to each catalog: five scenes from each of two catalogs can produce up to ten results. Cloud cover describes the whole scene, which may be larger than your search area.
+
+If one catalog fails, results from the others still appear. The page distinguishes a failed request from a successful search with no matches. **Full search response** shows the returned JSON.
+
+## Preview aerial GeoTIFFs
+
+Open **Find imagery → Start with an aerial example** and choose **Use aerial imagery example**. Run the search, then choose **Preview on map**. The example selects one OpenAerialMap scene in Espoo, Finland, and opens its rendered GeoTIFF layer automatically. Zoom in to see more detail; **Image layer** lets you switch assets or return to the footprint.
+
+The [HOT catalog](https://api.imagery.hotosm.org/stac) supplies the scene metadata. Its [raster service](https://api.imagery.hotosm.org/raster/api.html) renders the GeoTIFF into map tiles, so the browser does not download the whole file. Thumbnails remain ordinary browser images. Availability depends on the catalog and its raster service.
+
+## Activity, timing, and catalog status
+
+Open **Activity** in the map’s status bar to see search and discovery operations, individual HTTP requests (including retries and pagination), status codes, and response times. Filter to errors or copy the log. The panel retains the latest 200 entries in memory; it does not send logs to a service. Request query values and request/response bodies are not recorded.
+
+Request time ends when response headers arrive. The operation total includes WASM initialization when needed, retries, pagination, body processing, and result rendering. These are measured browser timings, not server processing times. Map tiles and imagery are outside this catalog activity log; Developer Tools → Network shows those requests too.
+
+Catalog badges show the last observed response or search outcome, including the last request’s response time. **Connection checks → Check connections** in Catalogs makes a small collections request to each selected catalog, sequentially, with an eight-second timeout. A green badge is evidence of the last request, not a promise of continuous uptime. A red badge may mean CORS, a network error, timeout, or a catalog failure. Hover over a badge for its timestamp.
+
+These are playground activity logs, not Rust `tracing` events. The WASM package does not currently expose a tracing subscriber or request-event callback.
+
+Requests use the browser’s User-Agent, which does not identify SuperSTAC. Browsers control that header; Chrome drops overrides made through Fetch. A custom identification header would require the catalog to allow it through CORS, so the playground adds no identification header. See [MDN’s browser header restrictions](https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header).
+
+## Use the same search in your app
+
+Choose **View code** at the top of the controls. Pick **JavaScript**, **Python**, **Rust**, or **CLI** to get a colored example using your selected catalogs, applied settings, and current filters.
+
+**Copy** copies the selected file as plain text. Python and JavaScript have one script. Rust includes `src/main.rs`, `Cargo.toml`, and `superstac.yml`; CLI includes `search.sh` and `superstac.yml`. Use the **File** selector to copy each file. **Setup guide** opens the installation or usage instructions for that interface.
+
+JavaScript uses `@spatialnode/superstac-wasm`. See the [browser guide](/docs/v0.3/wasm/overview) for installation without Rust. The CLI does not support GeoJSON geometry filters: choose another language or switch the search to a bounding box. Native clients perform startup discovery and health checks, so catalog selection can differ from browser results.
diff --git a/docs/deploy/package-releases.md b/docs/deploy/package-releases.md
index 5c7d190..05ad075 100644
--- a/docs/deploy/package-releases.md
+++ b/docs/deploy/package-releases.md
@@ -28,7 +28,7 @@ platforms can build from the source distribution.
    manual runs, producing artifacts without publishing.
 3. Tag the checked commit `v` and publish its GitHub release. The workflow
    checks the tag against the workspace version.
-4. Check `Publish packages` and the docs deployment in Coolify.
+4. Check `Publish packages`. For docs changes, check the `Docs image` workflow and pull the tested image in Coolify.
    Confirm versions and documentation links on PyPI and crates.io before announcing.
 
 Crates publish in dependency order: core, config, search, engine, cli. The Python
@@ -37,7 +37,7 @@ cannot be atomic across registries. Rerun after fixing credentials or registry
 availability; existing crate versions and Python files are skipped. Source
 changes after publication require a new version and tag.
 
-Coolify deploys docs on pushes to `main`, independently of package publication. The package remains alpha software
+Pushes to `main` build and publish the tested docs image in CI, independently of package publication. Coolify pulls that image; see the [docs hosting instructions](../README.md#publish). The package remains alpha software
 even when the numeric version has no `a` suffix.
 
 ## Local verification
diff --git a/docs/lib/code-examples.ts b/docs/lib/code-examples.ts
index df20593..8e3effe 100644
--- a/docs/lib/code-examples.ts
+++ b/docs/lib/code-examples.ts
@@ -1,5 +1,93 @@
 export const examples = {
-  Python: 'from superstac import Client\n\nclient = Client(catalogs=[\n    {"id": "earth-search", "url": "https://earth-search.aws.element84.com/v1"},\n    {"id": "microsoft", "url": "https://planetarycomputer.microsoft.com/api/stac/v1"},\n])\ntry:\n    result = client.search(collections=["sentinel-2-l2a"], limit=5)\n    for item in result.items():\n        print(item["id"])\nfinally:\n    client.shutdown()',
-  Rust: 'use superstac_config::init_from_yaml;\nuse superstac_core::models::storage::Storage;\nuse superstac_engine::SuperSTACEngine;\nuse superstac_search::query::SearchQuery;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box> {\n    let engine = SuperSTACEngine::new(init_from_yaml(Storage::Memory, "superstac.yml")?);\n    let result = engine.search(SearchQuery {\n        collections: vec!["sentinel-2-l2a".into()], limit: Some(5),\n        ids: None, bbox: None, intersects: None, datetime: None, sortby: None,\n    }).await;\n    engine.shutdown().await;\n    println!("{} items", result?.metadata.total_items);\n    Ok(())\n}',
-  CLI: '# Save the quickstart configuration as superstac.yml first.\nsuperstac collections\n\nsuperstac search \\\n  -c sentinel-2-l2a \\\n  -b 6.0,49.0,7.0,50.0 \\\n  -l 5\n\nsuperstac --json search -c sentinel-2-l2a -l 5',
+  Python: `from superstac import Client
+
+catalogs = [
+    {
+        "id": "earth-search",
+        "url": "https://earth-search.aws.element84.com/v1",
+    },
+    {
+        "id": "microsoft",
+        "url": "https://planetarycomputer.microsoft.com/api/stac/v1",
+    },
+]
+client = Client(catalogs=catalogs)
+
+try:
+    result = client.search(
+        collections=["sentinel-2-l2a"],
+        limit=5,
+    )
+    for item in result.items():
+        print(item["id"])
+finally:
+    client.shutdown()`,
+  JavaScript: `import init, { SuperSTAC } from '@spatialnode/superstac-wasm';
+
+await init();
+const client = new SuperSTAC({
+  catalogs: [
+    {
+      id: 'earth-search',
+      url: 'https://earth-search.aws.element84.com/v1',
+    },
+    {
+      id: 'microsoft',
+      url: 'https://planetarycomputer.microsoft.com/api/stac/v1',
+    },
+  ],
+});
+
+try {
+  const result = await client.search({
+    collections: ['sentinel-2-l2a'],
+    limit: 5,
+  });
+  for (const { item, seen_in } of result.items) {
+    console.log(item.id, seen_in);
+  }
+  console.log(result.metadata.failures);
+} finally {
+  client.free();
+}`,
+  Rust: `use superstac_config::init_from_yaml;
+use superstac_core::models::storage::Storage;
+use superstac_engine::SuperSTACEngine;
+use superstac_search::query::SearchQuery;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+    let config = init_from_yaml(
+        Storage::Memory,
+        "superstac.yml",
+    )?;
+    let engine = SuperSTACEngine::new(config);
+    let query = SearchQuery {
+        collections: vec!["sentinel-2-l2a".into()],
+        limit: Some(5),
+        ids: None,
+        bbox: None,
+        intersects: None,
+        datetime: None,
+        sortby: None,
+    };
+
+    let result = engine.search(query).await;
+    engine.shutdown().await;
+    println!("{} items", result?.metadata.total_items);
+    Ok(())
+}`,
+  CLI: String.raw`# Save the quickstart config as superstac.yml.
+superstac collections
+
+# Search an area.
+superstac search \
+  -c sentinel-2-l2a \
+  -b 6.0,49.0,7.0,50.0 \
+  -l 5
+
+# Return the results as JSON.
+superstac --json search \
+  -c sentinel-2-l2a \
+  -l 5`,
 };
diff --git a/docs/next-env.d.ts b/docs/next-env.d.ts
index ce4e94a..a419cbe 100644
--- a/docs/next-env.d.ts
+++ b/docs/next-env.d.ts
@@ -1,7 +1,7 @@
 /// 
 /// 
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import "./.next/dev/types/routes.d.ts";
+import "./.next/dev/types/root-params.d.ts";
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/docs/next.config.mjs b/docs/next.config.mjs
index 4edd907..1508c54 100644
--- a/docs/next.config.mjs
+++ b/docs/next.config.mjs
@@ -7,6 +7,9 @@ export default withMDX({
   basePath: '/superstac',
   output: 'standalone',
   reactStrictMode: true,
+  async rewrites() {
+    return [{ source: '/playground', destination: '/playground/index.html' }];
+  },
   async redirects() {
     // Temporary redirects let existing bookmarks follow the latest release.
     const files = readdirSync(new URL(`./content/docs/${latest}/`, import.meta.url), { recursive: true });
diff --git a/docs/package.json b/docs/package.json
index a299405..4b70913 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -7,14 +7,16 @@
     "node": ">=22.19.0"
   },
   "scripts": {
-    "dev": "next dev --hostname 127.0.0.1",
+    "dev": "next dev --webpack --hostname 127.0.0.1",
     "build": "next build --webpack",
     "check": "node scripts/check-node.mjs && fumadocs-mdx && tsc --noEmit",
     "start": "next start --hostname 0.0.0.0",
     "postinstall": "node scripts/check-node.mjs && fumadocs-mdx",
     "test:links": "node scripts/check-links.mjs",
+    "test:examples": "node scripts/check-code-examples.mjs",
     "predev": "node scripts/check-node.mjs && node scripts/sync-assets.mjs",
-    "prebuild": "node scripts/check-node.mjs && node scripts/sync-assets.mjs"
+    "prebuild": "node scripts/check-node.mjs && node scripts/sync-assets.mjs",
+    "prepare:wasm": "node scripts/prepare-wasm.mjs"
   },
   "dependencies": {
     "@tailwindcss/postcss": "4.3.3",
@@ -34,5 +36,6 @@
     "@types/react-dom": "^19.0.0",
     "typescript": "^5.9.0",
     "vite": "8"
-  }
+  },
+  "packageManager": "pnpm@9.8.0"
 }
diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml
new file mode 100644
index 0000000..fa045fa
--- /dev/null
+++ b/docs/pnpm-workspace.yaml
@@ -0,0 +1,5 @@
+packages:
+  - .
+
+allowBuilds:
+  esbuild: false
diff --git a/docs/public/playground/index.html b/docs/public/playground/index.html
new file mode 100644
index 0000000..d8014b3
--- /dev/null
+++ b/docs/public/playground/index.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+SuperSTAC Playground
+
+
+
+
+
+
SuperSTACSuperSTACPlayground
+
+

Explore imagery

Choose an area. Find a scene. Preview it on the map.

+
+ +
+
Ready: French Alps · summer 2025. Select Search scenes to see imagery.
+
+

Search area

Move the map to explore. Search to see scene footprints and imagery.

+

Your search area

Loading map… You can search while it loads.

Blue outline · search area
+ + +
+ +

Ready to search

Start with Sentinel-2 over the French Alps, or choose another collection and area.

+ +
+
+ +
+ +

Use this search in your app

Your catalogs, filters, and applied settings

+
Setup guide ↗
+

+

+  

Ready to copy.

+
+ +

Find a catalog

+

Public STAC APIs listed by STAC Index. Static catalogs and catalogs requiring sign-in are excluded. Browser access varies by catalog.

+ +

+ +
+ +
+

Add catalog

+ +
Collection & asset aliases

Use familiar names in your app. SuperSTAC translates them for each catalog.

+

Collections

+

Assets

+

Example for catalogs with a sentinel-2-l2a collection and a visual asset. Save the catalog to apply your changes.

+
+
Edit as JSON
+
+
+
+

Add provider

+ + + diff --git a/docs/public/playground/modules/activity.js b/docs/public/playground/modules/activity.js new file mode 100644 index 0000000..c10a090 --- /dev/null +++ b/docs/public/playground/modules/activity.js @@ -0,0 +1,123 @@ +import { $, el } from './dom.js'; + +export const duration = ms => ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`; +export function safeRequestUrl(value) { + try { const url = new URL(value); return `${url.origin}${url.pathname}${url.search ? '?…' : ''}`; } + catch { return 'Unknown URL'; } +} + +// Observe this page's catalog fetches without changing headers, bodies, or responses. +// Map tiles, images and WASM loading are excluded. +export function activityMonitor(healthChanged) { + const originalFetch = window.fetch.bind(window); + const health = new Map(); + const entries = []; + let operation, sequence = 0; + function render() { + $('activity-count').textContent = String(entries.length); + $('activity-empty').hidden = entries.length > 0; + const filter = $('activity-filter').value; + $('activity-list').replaceChildren(); + for (const entry of entries.filter(e => filter !== 'errors' || e.failed).slice().reverse()) { + const row = el('li', undefined, `activity-entry${entry.failed ? ' failed' : ''}`); + const heading = el('div', undefined, 'activity-heading'); + heading.append(el('strong', entry.title), el('span', entry.ms === undefined ? 'In progress' : duration(entry.ms), 'request-duration')); + row.append(heading, el('p', entry.detail, 'hint')); + if (entry.url) row.append(el('code', entry.url, 'request-url')); + row.append(el('time', new Date(entry.time).toLocaleTimeString([], { hour12: false }), 'activity-time')); + $('activity-list').append(row); + } + } + function log(title, detail = '', options = {}) { + const entry = { title, detail, time: Date.now(), ...options }; entries.push(entry); + if (entries.length > 200) entries.shift(); render(); return entry; + } + function setHealth(catalog, status, ms, detail) { + if (!catalog) return; + health.set(`${catalog.id}|${catalog.url}`, { status, ms, detail, time: Date.now() }); + healthChanged(); + } + window.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const scope = operation; + if (new Headers(init?.headers ?? input?.headers).has('range') || /\.(tiff?|png|jpe?g|webp)$/i.test(new URL(url, location.href).pathname)) return originalFetch(input, init); + const host = new URL(url, location.href).hostname; + if (host === 'unpkg.com' || host.endsWith('.openfreemap.org')) return originalFetch(input, init); + const catalog = scope?.catalogs.find(c => url === c.url || url.startsWith(c.url.replace(/\/$/, '') + '/') || url.startsWith(c.url + '?')); + // Cross-host pagination made by WASM is still logged, without guessing its catalog. + if (!scope || (!catalog && !(input instanceof Request && new URL(url).origin !== location.origin))) return originalFetch(input, init); + const start = performance.now(); + const method = init?.method ?? input?.method ?? 'GET'; + const entry = log(`${method} · ${catalog?.name ?? 'Pagination request'}`, `${scope.name} · Waiting for response`, { url: safeRequestUrl(url), request: true, operation: scope.id }); + try { + const response = await originalFetch(input, init); + entry.ms = performance.now() - start; entry.failed = !response.ok; + entry.detail = `${scope.name} · HTTP ${response.status}`; entry.status = response.status; + setHealth(catalog, response.ok ? 'Responded' : 'Request failed', entry.ms, `HTTP ${response.status}`); + render(); return response; + } catch (error) { + entry.ms = performance.now() - start; entry.failed = true; + entry.detail = error.name === 'AbortError' || error.name === 'TimeoutError' ? 'Request cancelled or timed out' : 'Network or CORS error'; + setHealth(catalog, 'Request failed', entry.ms, entry.detail); render(); throw error; + } + }; + $('activity-filter').addEventListener('change', render); + $('clear-activity').addEventListener('click', () => { if (!operation) { entries.length = 0; render(); } }); + $('copy-activity').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(entries, null, 2)); $('activity-note').textContent = 'Activity copied.'; + } catch { $('activity-note').textContent = 'Copy is unavailable in this browser. You can inspect requests in Developer Tools → Network.'; } + }); + render(); + return { + log, + begin(name, catalogs) { + operation = { id: ++sequence, name, catalogs: structuredClone(catalogs), start: performance.now() }; + $('clear-activity').disabled = true; + log(name, `${catalogs.length} selected catalogs`, { operation: sequence }); + }, + finish(detail, failed = false) { + if (!operation) return; + const ms = performance.now() - operation.start; + log(`${operation.name} finished`, detail, { ms, failed, operation: operation.id }); + $('operation-time').textContent = `${operation.name}: ${duration(ms)}`; + operation = undefined; $('clear-activity').disabled = false; return ms; + }, + outcome(response, catalogs) { + for (const catalog of catalogs) { + const failure = response.metadata.failures.find(f => f.catalog_id === catalog.id); + const previous = health.get(`${catalog.id}|${catalog.url}`); + setHealth(catalog, failure ? 'Search failed' : 'Search succeeded', previous?.ms, failure ? 'See search response for details' : 'Last search completed'); + } + }, + badge(catalog) { + const value = health.get(`${catalog.id}|${catalog.url}`); + const badge = el('span', value ? `${value.status}${value.ms === undefined ? '' : ` · ${duration(value.ms)}`}` : 'Not checked', 'health-badge'); + badge.dataset.state = !value ? 'unknown' : value.status.includes('failed') ? 'error' : 'ok'; + badge.title = value ? `${value.detail} · ${new Date(value.time).toLocaleTimeString()} · Last response, not continuous uptime` : 'Run a search or check connections'; + return badge; + }, + async check(catalogs) { + // Keep manual connection checks light and sequential; no background polling. + for (const catalog of catalogs) { + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); + try { + const response = await fetch(`${catalog.url}/collections?limit=1`, { signal: controller.signal }); + if (response.ok) { + const body = await response.json(); + if (!Array.isArray(body.collections)) { + setHealth(catalog, 'Check failed', undefined, 'Response did not contain STAC collections'); + log('Connection check failed', `${catalog.name}: response did not contain STAC collections`, { failed: true, ms: 0 }); + } + } + } catch (error) { + const previous = health.get(`${catalog.id}|${catalog.url}`); + const detail = error instanceof SyntaxError ? 'The catalog did not return JSON' : error.name === 'AbortError' ? 'Connection check timed out' : 'Connection check failed'; + setHealth(catalog, 'Check failed', previous?.ms, detail); + log('Connection check failed', `${catalog.name}: ${detail}`, { failed: true, ms: 0 }); + } + finally { clearTimeout(timer); } + } + }, + }; +} diff --git a/docs/public/playground/modules/alias-editor.js b/docs/public/playground/modules/alias-editor.js new file mode 100644 index 0000000..44ca3e7 --- /dev/null +++ b/docs/public/playground/modules/alias-editor.js @@ -0,0 +1,78 @@ +import { $, el, button } from './dom.js'; +import { aliases } from './model.js'; + +export function aliasValues(collectionRows, assetRows) { + const collection_aliases = Object.create(null), asset_aliases = Object.create(null); + for (const [name, local] of collectionRows) { + if (!name && !local) continue; + if (!name || !local) throw new Error('Enter both names for each collection alias.'); + if (Object.hasOwn(collection_aliases, name)) throw new Error(`The collection name “${name}” is used twice.`); + collection_aliases[name] = local; + } + for (const [collection, name, local] of assetRows) { + if (!collection && !name && !local) continue; + if (!collection || !name || !local) throw new Error('Enter a collection and both names for each asset alias.'); + asset_aliases[collection] ??= Object.create(null); + if (Object.hasOwn(asset_aliases[collection], name)) throw new Error(`The asset name “${name}” is used twice in “${collection}”.`); + asset_aliases[collection][name] = local; + } + return { collection_aliases, asset_aliases }; +} + +export function aliasEditor() { + let rawEdited = false; + const rows = id => [...$(id).children].map(row => [...row.querySelectorAll('input')].map(input => input.value.trim())); + const readRows = () => aliasValues(rows('collection-alias-rows'), rows('asset-alias-rows')); + function explain(config) { + const preview = $('alias-preview'); preview.replaceChildren(); + for (const [name, local] of Object.entries(config.collection_aliases)) { + preview.append(el('p', `Search for “${name}” → this catalog receives “${local}”.`)); + } + for (const [collection, pairs] of Object.entries(config.asset_aliases)) for (const [name, local] of Object.entries(pairs)) { + preview.append(el('p', `In “${collection}” results, “${local}” appears as “${name}” when names are normalized.`)); + } + if (!preview.children.length) preview.append(el('p', 'Keep one set of names in your app, even when catalogs use different names.')); + } + function sync() { + rawEdited = false; + try { + const config = readRows(); + $('collection-aliases').value = JSON.stringify(config.collection_aliases, null, 2); + $('asset-aliases').value = JSON.stringify(config.asset_aliases, null, 2); + explain(config); + } catch (error) { $('alias-preview').textContent = error.message; } + } + function row(id, values) { + const node = el('div', undefined, 'alias-row'); + const labels = id === 'collection-alias-rows' ? ['Name in your app', 'Name in this catalog'] : ['Collection name in your app', 'Asset name in your app', 'Asset name in this catalog']; + values.forEach((value, index) => { + const label = el('label', labels[index]); const input = el('input'); input.value = value; + input.placeholder = (values.length === 2 ? ['optical', 'sentinel-2-l2a'] : ['optical', 'rgb', 'visual'])[index]; + input.addEventListener('input', sync); label.append(input); node.append(label); + }); + const remove = button('×', () => { node.remove(); sync(); }, 'quiet'); remove.setAttribute('aria-label', 'Remove alias'); node.append(remove); $(id).append(node); + } + function draw(config) { + $('collection-alias-rows').replaceChildren(); $('asset-alias-rows').replaceChildren(); + Object.entries(config.collection_aliases).forEach(pair => row('collection-alias-rows', pair)); + Object.entries(config.asset_aliases).forEach(([collection, pairs]) => Object.entries(pairs).forEach(pair => row('asset-alias-rows', [collection, ...pair]))); + explain(config); + } + function load(config) { + rawEdited = false; draw(config); + $('collection-aliases').value = JSON.stringify(config.collection_aliases, null, 2); + $('asset-aliases').value = JSON.stringify(config.asset_aliases, null, 2); + } + $('add-collection-alias').addEventListener('click', () => { row('collection-alias-rows', ['', '']); $('collection-alias-rows').lastElementChild.querySelector('input').focus(); }); + $('add-asset-alias').addEventListener('click', () => { row('asset-alias-rows', ['', '', '']); $('asset-alias-rows').lastElementChild.querySelector('input').focus(); }); + $('fill-alias-example').addEventListener('click', () => load({ collection_aliases: { optical: 'sentinel-2-l2a' }, asset_aliases: { optical: { rgb: 'visual' } } })); + for (const id of ['collection-aliases', 'asset-aliases']) $(id).addEventListener('input', () => { + rawEdited = true; + try { draw({ collection_aliases: aliases($('collection-aliases').value), asset_aliases: aliases($('asset-aliases').value, true) }); } + catch { $('alias-preview').textContent = 'Finish editing the JSON to see the name mappings.'; } + }); + return { + load, + read() { return rawEdited ? { collection_aliases: aliases($('collection-aliases').value), asset_aliases: aliases($('asset-aliases').value, true) } : readRows(); }, + }; +} diff --git a/docs/public/playground/modules/catalogs.js b/docs/public/playground/modules/catalogs.js new file mode 100644 index 0000000..66fedf3 --- /dev/null +++ b/docs/public/playground/modules/catalogs.js @@ -0,0 +1,102 @@ +import { $, el, button, errorMessage } from './dom.js'; +import { providerIcon } from './provider-icon.js'; +import { aliasEditor } from './alias-editor.js'; +import { identifier, catalogUrl, httpUrl } from './model.js'; + +export function catalogEditor(getState, changed, isBusy, badge) { + let editingCatalog, editingProvider; + const aliasFields = aliasEditor(); + const showError = (id, error) => { $(id).textContent = errorMessage(error); $(id).hidden = false; }; + function openCatalog(catalog, adding = false) { + if (isBusy()) return; + editingCatalog = adding ? undefined : catalog?.id; + $('catalog-form').reset(); delete $('catalog-id').dataset.manual; $('catalog-error').hidden = true; + $('catalog-dialog-title').textContent = catalog && !adding ? 'Edit catalog' : 'Add catalog'; + for (const key of ['name', 'id', 'url']) $(`catalog-${key}`).value = catalog?.[key] ?? ''; + $('catalog-provider').replaceChildren(new Option('Unassigned', ''), ...getState().providers.map(p => new Option(p.name, p.id))); + $('catalog-provider').value = catalog?.provider ?? ''; + aliasFields.load({ collection_aliases: catalog?.collection_aliases ?? {}, asset_aliases: catalog?.asset_aliases ?? {} }); + $('catalog-dialog').showModal(); + } + function openProvider(provider) { + if (isBusy()) return; + editingProvider = provider?.id; + $('provider-form').reset(); delete $('provider-id').dataset.manual; $('provider-error').hidden = true; + $('provider-dialog-title').textContent = provider ? 'Edit provider' : 'Add provider'; + $('provider-icon').value = provider?.icon ?? ''; + $('provider-name').value = provider?.name ?? ''; $('provider-id').value = provider?.id ?? ''; + $('provider-dialog').showModal(); + } + function render() { + const state = getState(); + $('catalog-list').replaceChildren(); $('active-catalogs').replaceChildren(); + for (const catalog of state.catalogs) { + if (catalog.enabled) { + const source = el('div', undefined, 'active-source'); + const info = el('div'); info.append(el('strong', catalog.name), badge(catalog)); + source.append(providerIcon(state.providers.find(p => p.id === catalog.provider)), info); $('active-catalogs').append(source); + } + const card = el('div', undefined, 'catalog-entry'); + const label = el('label', undefined, 'check'); + const check = el('input'); check.type = 'checkbox'; check.checked = catalog.enabled; check.disabled = isBusy(); + check.addEventListener('change', () => { catalog.enabled = check.checked; changed(); }); + label.append(check, providerIcon(state.providers.find(p => p.id === catalog.provider)), el('strong', catalog.name)); card.append(label); + card.append(el('p', state.providers.find(p => p.id === catalog.provider)?.name ?? 'Unassigned', 'hint'), el('p', catalog.url, 'catalog-url')); + const actions = el('div', undefined, 'toolbar'); + actions.append(button('Edit', () => openCatalog(catalog), 'quiet'), button('Remove', () => { + if (isBusy()) return; + state.catalogs = state.catalogs.filter(c => c !== catalog); changed(); + }, 'quiet')); + card.append(badge(catalog), actions); $('catalog-list').append(card); + } + if (!state.catalogs.length) $('catalog-list').append(el('p', 'Add a catalog to start searching.', 'hint')); + $('provider-list').replaceChildren(); + for (const provider of state.providers) { + const row = el('div', undefined, 'provider-entry'); row.append(providerIcon(provider), el('span', provider.name)); + const actions = el('div', undefined, 'toolbar'); + actions.append(button('Edit', () => openProvider(provider), 'quiet'), button('Remove', () => { + if (isBusy()) return; + state.providers = state.providers.filter(p => p !== provider); + state.catalogs.forEach(c => { if (c.provider === provider.id) c.provider = ''; }); changed(); + }, 'quiet')); row.append(actions); $('provider-list').append(row); + } + document.querySelectorAll('#catalog-list button, #provider-list button, #browse-index, #add-catalog, #add-provider, #discover, #check-health').forEach(b => { b.disabled = isBusy(); }); + } + $('add-catalog').addEventListener('click', () => openCatalog()); + $('add-provider').addEventListener('click', () => openProvider()); + document.querySelectorAll('[data-close]').forEach(b => b.addEventListener('click', () => $(b.dataset.close).close())); + $('catalog-form').addEventListener('submit', event => { + event.preventDefault(); if (isBusy()) return; + try { + const state = getState(); const id = identifier($('catalog-id').value.trim(), 'Catalog ID'); + if (state.catalogs.some(c => c.id === id && c.id !== editingCatalog)) throw new Error('A catalog already uses this ID. Choose another.'); + const existing = state.catalogs.find(c => c.id === editingCatalog); + const name = $('catalog-name').value.trim(); if (!name) throw new Error('Enter a catalog name.'); + const catalog = { id, name, url: catalogUrl($('catalog-url').value.trim()), provider: $('catalog-provider').value, enabled: existing?.enabled ?? true, + ...aliasFields.read() }; + if (existing) state.catalogs.splice(state.catalogs.indexOf(existing), 1, catalog); else state.catalogs.push(catalog); + $('catalog-dialog').close(); changed(); + } catch (error) { showError('catalog-error', error); } + }); + $('provider-form').addEventListener('submit', event => { + event.preventDefault(); if (isBusy()) return; + try { + const state = getState(); const id = identifier($('provider-id').value.trim(), 'Provider ID'); + const icon = $('provider-icon').value.trim(); if (icon && !httpUrl(icon)) throw new Error('Use an HTTP or HTTPS image URL.'); + const name = $('provider-name').value.trim(); if (!name) throw new Error('Enter a provider name.'); + if (state.providers.some(p => p.id === id && p.id !== editingProvider)) throw new Error('A provider already uses this ID. Choose another.'); + const existing = state.providers.find(p => p.id === editingProvider); + if (existing) { + state.catalogs.forEach(c => { if (c.provider === editingProvider) c.provider = id; }); + Object.assign(existing, { id, name, icon }); + } else state.providers.push({ id, name, icon }); + $('provider-dialog').close(); changed(); + } catch (error) { showError('provider-error', error); } + }); + for (const kind of ['catalog', 'provider']) $(kind + '-name').addEventListener('input', () => { + if ((kind === 'catalog' ? editingCatalog : editingProvider) || $(kind + '-id').dataset.manual) return; + $(kind + '-id').value = $(kind + '-name').value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + }); + for (const kind of ['catalog', 'provider']) $(kind + '-id').addEventListener('input', () => { $(kind + '-id').dataset.manual = 'true'; }); + return { render, addFromIndex: catalog => openCatalog(catalog, true) }; +} diff --git a/docs/public/playground/modules/code-examples.js b/docs/public/playground/modules/code-examples.js new file mode 100644 index 0000000..2f76403 --- /dev/null +++ b/docs/public/playground/modules/code-examples.js @@ -0,0 +1,71 @@ +import { example } from './model.js'; + +function python(value, depth = 0) { + const indent = ' '.repeat(depth), next = indent + ' '; + if (value === null) return 'None'; + if (typeof value === 'boolean') return value ? 'True' : 'False'; + if (Array.isArray(value)) return value.length ? `[\n${value.map(v => next + python(v, depth + 1)).join(',\n')},\n${indent}]` : '[]'; + if (typeof value === 'object') { + const pairs = Object.entries(value); + return pairs.length ? `{\n${pairs.map(([k, v]) => `${next}${JSON.stringify(k)}: ${python(v, depth + 1)}`).join(',\n')},\n${indent}}` : '{}'; + } + return JSON.stringify(value); +} +function yaml(value, depth = 0) { + const indent = ' '.repeat(depth); + const scalar = v => v === null || typeof v !== 'object' || Object.keys(v).length === 0; + if (Array.isArray(value)) return value.map(v => scalar(v) ? `${indent}- ${JSON.stringify(v)}` : `${indent}-\n${yaml(v, depth + 1)}`).join('\n'); + return Object.entries(value).map(([k, v]) => scalar(v) ? `${indent}${JSON.stringify(k)}: ${JSON.stringify(v)}` : `${indent}${JSON.stringify(k)}:\n${yaml(v, depth + 1)}`).join('\n'); +} +const shellQuote = value => `'${String(value).replaceAll("'", "'\\''")}'`; +function rustString(value) { + let hashes = '#'; + while (value.includes('"' + hashes)) hashes += '#'; + return `r${hashes}"${value}"${hashes}`; +} +export function codeFiles(config, query, language) { + const file = (name, syntax, content) => ({ name, syntax, content }); + if (language === 'JavaScript') return { note: 'Run in your browser app after installing the WASM package.', guide: '/superstac/docs/wasm/overview', files: [file('search.js', 'javascript', example(config, query))] }; + if (language === 'Python') { + const args = Object.entries(query).map(([key, value]) => ` ${key}=${python(value, 2)},`).join('\n'); + return { note: 'Install superstac, then run python search.py or uv run search.py.', guide: '/superstac/docs/start/installation', files: [file('search.py', 'python', `from superstac import Client\n\nconfig = ${python(config)}\nclient = Client(config)\ntry:\n result = client.search(\n${args}\n )\n for item in result.items():\n print(item["id"], item["properties"].get("datetime"))\n for failure in result.metadata["failures"]:\n print(failure["catalog_id"], failure["reason"])\nfinally:\n client.shutdown()\n`)] }; + } + const native = { providers: [], catalogs: config.catalogs, settings: { + health_check_strategy: 'hourly', healthy_status_code_range: [200, 299], auto_fix_duplicate_catalog_id: true, + auto_fix_duplicate_provider_id: true, log_level: 'info', logging_enabled: true, ...config.settings, + } }; + const configFile = file('superstac.yml', 'yaml', yaml(native) + '\n'); + if (language === 'CLI') { + if (query.intersects) throw new Error('The CLI does not support GeoJSON geometry filters. Use Python, JavaScript, or Rust, or choose a bounding box in Search.'); + const args = ['superstac --json search']; + for (const collection of query.collections ?? []) args.push(`--collection=${shellQuote(collection)}`); + for (const id of query.ids ?? []) args.push(`--id=${shellQuote(id)}`); + if (query.bbox) args.push(`--bbox=${shellQuote(query.bbox.join(','))}`); + if (query.datetime) args.push(`--datetime=${shellQuote(query.datetime)}`); + for (const sort of query.sortby ?? []) args.push(`--sortby=${shellQuote(sort)}`); + args.push(`--limit=${shellQuote(query.limit ?? 10)}`); + return { note: 'Save both files in one folder. Install the CLI, then run sh search.sh.', guide: '/superstac/docs/cli/overview', files: [file('search.sh', 'bash', `# Uses superstac.yml in the current directory.\n${args.join(' \\\n ')}\n`), configFile] }; + } + if (language !== 'Rust') throw new Error('Choose a supported language.'); + return { note: 'Save all three files with the paths shown, then run cargo run.', guide: '/superstac/docs/rust/overview', files: [ + file('src/main.rs', 'rust', `use superstac_config::init_from_yaml;\nuse superstac_core::models::storage::Storage;\nuse superstac_engine::SuperSTACEngine;\nuse superstac_search::query::SearchQuery;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box> {\n let query: SearchQuery = serde_json::from_str(\n ${rustString(JSON.stringify(query, null, 2))},\n )?;\n let storage = init_from_yaml(Storage::Memory, "superstac.yml")?;\n let engine = SuperSTACEngine::new(storage);\n let result = engine.search(query).await;\n engine.shutdown().await;\n let result = result?;\n for entry in result.items {\n println!("{} from {:?}", entry.item.id, entry.seen_in);\n }\n for failure in result.metadata.failures {\n eprintln!("{}: {}", failure.catalog_id, failure.reason);\n }\n Ok(())\n}\n`), + file('Cargo.toml', 'toml', `[package]\nname = "superstac-example"\nversion = "0.1.0"\nedition = "2021"\nrust-version = "1.88"\n\n[dependencies]\nsuperstac-core = "0.3"\nsuperstac-config = "0.3"\nsuperstac-search = "0.3"\nsuperstac-engine = "0.3"\nserde_json = "1"\ntokio = { version = "1", features = ["macros", "rt-multi-thread"] }\n`), configFile, + ] }; +} + +// Tokenize only for display. Copying always uses the original source string. +export function codeTokens(source, language) { + const hashComments = ['python', 'bash', 'yaml', 'toml'].includes(language); + const pattern = hashComments + ? /#[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:from|import|try|finally|for|in|if|print|True|False|None)\b|\b\d+(?:\.\d+)?\b/g + : /\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\b(?:import|from|const|let|await|async|try|finally|for|of|if|else|use|fn|pub|mut|match|true|false|null|Some|None|Ok)\b|\b\d+(?:\.\d+)?\b/g; + const tokens = []; let end = 0; + for (const match of source.matchAll(pattern)) { + if (match.index > end) tokens.push({ text: source.slice(end, match.index) }); + const text = match[0]; + const kind = text.startsWith('//') || (hashComments && text.startsWith('#')) ? 'comment' : /^["']/.test(text) ? 'string' : /^\d/.test(text) ? 'number' : 'keyword'; + tokens.push({ text, kind }); end = match.index + text.length; + } + if (end < source.length) tokens.push({ text: source.slice(end) }); + return tokens; +} diff --git a/docs/public/playground/modules/code-view.js b/docs/public/playground/modules/code-view.js new file mode 100644 index 0000000..18026eb --- /dev/null +++ b/docs/public/playground/modules/code-view.js @@ -0,0 +1,44 @@ +import { $, el } from './dom.js'; +import { codeFiles, codeTokens } from './code-examples.js'; + +export function codeView() { + let config, query, files = [], source = ''; + function showFile() { + const current = files[Number($('code-file').value) || 0]; + source = current?.content ?? ''; + $('code').replaceChildren(...codeTokens(source, current?.syntax).map(token => token.kind ? el('span', token.text, `token-${token.kind}`) : document.createTextNode(token.text))); + $('copy').disabled = !current; $('copy-status').textContent = ''; + $('copy').textContent = current ? `Copy ${current.name}` : 'Copy code'; + $('code').scrollTop = 0; + } + function render() { + if (!config || !query) return; + const previous = files[Number($('code-file').value) || 0]?.name; + try { + const output = codeFiles(config, query, $('code-language').value); files = output.files; + $('code-file').replaceChildren(...files.map((f, i) => new Option(f.name, String(i)))); + $('code-file').value = String(Math.max(0, files.findIndex(f => f.name === previous))); + $('code-note').textContent = output.note; $('code-guide').href = output.guide; + showFile(); + } catch (error) { + files = []; source = ''; $('code-file').replaceChildren(); $('code').textContent = error.message; + $('copy').disabled = true; $('copy').textContent = 'Copy code'; $('copy-status').textContent = ''; $('code-note').textContent = 'This query cannot be used with the selected interface.'; + $('code-guide').href = '/superstac/docs/reference/search'; + } + } + $('code-language').addEventListener('change', render); + $('code-file').addEventListener('change', showFile); + $('copy').addEventListener('click', async () => { + if (!source) return; + try { await navigator.clipboard.writeText(source); $('copy-status').textContent = 'Copied.'; } + catch { + const range = document.createRange(); range.selectNodeContents($('code')); + const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); + $('code').focus(); $('copy-status').textContent = 'Code selected. Press Ctrl+C or ⌘C to copy.'; + } + }); + return { + update(nextConfig, nextQuery) { config = nextConfig; query = nextQuery; render(); }, + error(message) { config = undefined; query = undefined; files = []; source = ''; $('code-file').replaceChildren(); $('code').textContent = message; $('copy').disabled = true; $('copy').textContent = 'Copy code'; $('copy-status').textContent = ''; $('code-note').textContent = 'Check your search fields to generate an example.'; $('code-guide').href = '/superstac/docs/reference/search'; }, + }; +} diff --git a/docs/public/playground/modules/dom.js b/docs/public/playground/modules/dom.js new file mode 100644 index 0000000..43405fb --- /dev/null +++ b/docs/public/playground/modules/dom.js @@ -0,0 +1,14 @@ +export const $ = id => document.getElementById(id); +export function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} +export function button(label, action, className = 'secondary') { + const node = el('button', label, className); node.type = 'button'; node.addEventListener('click', action); return node; +} +export function link(label, href) { + const node = el('a', label, 'external-link'); node.href = href; node.target = '_blank'; node.rel = 'noopener noreferrer'; return node; +} +export function errorMessage(error) { return error instanceof Error ? error.message : String(error); } diff --git a/docs/public/playground/modules/map.js b/docs/public/playground/modules/map.js new file mode 100644 index 0000000..51e7c96 --- /dev/null +++ b/docs/public/playground/modules/map.js @@ -0,0 +1,149 @@ +import { $ } from './dom.js'; + +const CDN = 'https://unpkg.com/maplibre-gl@6.11.2/dist/'; +const collection = features => ({ type: 'FeatureCollection', features }); +export function boxGeometry([west, south, east, north]) { + return { type: 'Polygon', coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] }; +} +export function geometryBounds(geometry) { + const points = []; + function walk(value) { + if (!Array.isArray(value)) return; + if (value.length >= 2 && typeof value[0] === 'number' && typeof value[1] === 'number') { + if (Number.isFinite(value[0]) && Number.isFinite(value[1])) points.push(value); + } else value.forEach(walk); + } + function geometryPoints(g) { walk(g?.coordinates); g?.geometries?.forEach(geometryPoints); } + geometryPoints(geometry); + if (!points.length) return null; + return points.reduce((b, p) => [Math.min(b[0], p[0]), Math.min(b[1], p[1]), Math.max(b[2], p[0]), Math.max(b[3], p[1])], [Infinity, Infinity, -Infinity, -Infinity]); +} +export function overviewMap(onArea, onScene) { + let map, ready = false, loading = false, query = {}, items = [], lastQuery = ''; + let timeout, library, cogLoading, currentPreview; + let previewVersion = 0; + const style = () => `https://tiles.openfreemap.org/styles/${document.documentElement.dataset.theme === 'dark' ? 'dark' : 'positron'}`; + function data() { + if (!ready) return; + const geometry = query.intersects ?? (query.bbox ? boxGeometry(query.bbox) : null); + map.getSource('query')?.setData(collection(geometry ? [{ type: 'Feature', properties: {}, geometry }] : [])); + map.getSource('scenes')?.setData(collection(items.flatMap((entry, index) => entry.item.geometry ? [{ type: 'Feature', properties: { index }, geometry: entry.item.geometry }] : []))); + $('map-legend').textContent = `Blue · search area${items.length ? ' / purple · scene footprints' : ''}`; + } + function fit() { + if (!ready) return; + const bounds = query.bbox ?? geometryBounds(query.intersects); + if (bounds) map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 70, duration: 0, maxZoom: 12 }); + else map.jumpTo({ center: [0, 20], zoom: 1.5 }); + } + function removePreview() { + if (!map) return; + if (map.getLayer('asset-preview')) map.removeLayer('asset-preview'); + if (map.getSource('asset-preview')) map.removeSource('asset-preview'); + } + async function cogProtocol() { + if (!cogLoading) cogLoading = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://unpkg.com/@geomatico/maplibre-cog-protocol@0.5.0/dist/index.js'; + const timer = setTimeout(() => reject(new Error('The imagery reader could not load. Try an external viewer.')), 12000); + script.onload = () => { + clearTimeout(timer); + if (!window.MaplibreCOGProtocol?.cogProtocol) { reject(new Error('The imagery reader is unavailable.')); return; } + library.addProtocol('cog', window.MaplibreCOGProtocol.cogProtocol); resolve(); + }; + script.onerror = () => { clearTimeout(timer); script.remove(); reject(new Error('The imagery reader could not load.')); }; + document.head.append(script); + }).catch(error => { cogLoading = undefined; throw error; }); + return cogLoading; + } + async function assetPreview(entry, asset) { + currentPreview = { entry, asset }; + const version = ++previewVersion; + if (!ready) { $('preview-status').textContent = 'Wait for the map to load, or open an external viewer.'; return; } + removePreview(); + const bounds = geometryBounds(entry.item.geometry) ?? (entry.item.bbox?.length === 4 ? entry.item.bbox : null); + if (bounds) map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 90, duration: 0, maxZoom: 18 }); + if (!asset) { $('preview-status').textContent = 'Scene footprint. Choose an image asset to preview it.'; return; } + try { + $('preview-status').dataset.state = 'loading'; $('preview-status').textContent = 'Loading asset…'; + if (asset.tilejson) { + map.addSource('asset-preview', { type: 'raster', url: asset.tilejson, tileSize: 256, + attribution: 'OpenAerialMap' }); + $('preview-status').textContent = 'GeoTIFF rendered by OpenAerialMap. Zoom in to explore the imagery.'; + } else if (asset.cog) { + await cogProtocol(); if (version !== previewVersion || !ready) return; + map.addSource('asset-preview', { type: 'raster', url: `cog://${asset.href}`, tileSize: 256 }); + $('preview-status').textContent = 'GeoTIFF preview. Availability depends on CORS, range requests, and the file’s encoding.'; + } else { + if (!bounds) throw new Error('This image has no scene bounds. Open the asset from the scene card.'); + const [w, s, e, n] = bounds; + map.addSource('asset-preview', { type: 'image', url: asset.href, coordinates: [[w, n], [e, n], [e, s], [w, s]] }); + $('preview-status').textContent = 'Image placed over scene bounds for a quick look. Alignment is approximate.'; + } + map.addLayer({ id: 'asset-preview', type: 'raster', source: 'asset-preview', paint: { 'raster-opacity': .85, 'raster-fade-duration': 0 } }, 'scene-line'); + } catch (error) { if (version === previewVersion) { $('preview-status').dataset.state = 'error'; $('preview-status').textContent = error.message; } } + } + function failure() { + clearTimeout(timeout); loading = false; ready = false; + map?.remove(); map = undefined; + $('map-fallback').hidden = false; $('retry-map').hidden = false; + $('use-map-area').disabled = true; $('fit-map').disabled = true; + $('map-message').textContent = 'The map could not load. You can still search with the area controls and open scenes in STAC Map.'; + } + async function start() { + if (loading || ready) return; loading = true; $('retry-map').hidden = true; + $('map-message').textContent = 'Loading map… You can search while it loads.'; + try { + if (!document.querySelector('[data-map-style]')) { + const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = CDN + 'maplibre-gl.css'; css.dataset.mapStyle = ''; document.head.append(css); + } + // The module is independent of WASM. Search still works if WebGL/CDN access fails. + let importTimer; + const lib = await Promise.race([ + import(CDN + 'maplibre-gl.mjs'), + new Promise((_, reject) => { importTimer = setTimeout(() => reject(new Error('Map load timed out')), 12000); }), + ]).finally(() => clearTimeout(importTimer)); + library = lib; lib.setWorkerCount(1); + map = new lib.Map({ container: 'overview-map', style: style(), center: [6.5, 45.5], zoom: 6, attributionControl: true, renderWorldCopies: false, maxTileCacheSize: 64, fadeDuration: 0 }); + map.addControl(new lib.NavigationControl({ showCompass: false }), 'top-right'); + timeout = setTimeout(failure, 15000); + map.on('style.load', () => { + clearTimeout(timeout); ready = true; loading = false; + $('map-fallback').hidden = true; $('use-map-area').disabled = false; $('fit-map').disabled = false; + for (const id of ['query', 'scenes']) map.addSource(id, { type: 'geojson', data: collection([]) }); + map.addLayer({ id: 'query-fill', type: 'fill', source: 'query', paint: { 'fill-color': '#081b99', 'fill-opacity': .08 } }); + map.addLayer({ id: 'query-line', type: 'line', source: 'query', paint: { 'line-color': '#435be3', 'line-width': 2, 'line-dasharray': [3, 2] } }); + map.addLayer({ id: 'scene-fill', type: 'fill', source: 'scenes', paint: { 'fill-color': '#7c3aed', 'fill-opacity': .18 } }); + map.addLayer({ id: 'scene-line', type: 'line', source: 'scenes', paint: { 'line-color': '#7c3aed', 'line-width': 1.5 } }); + map.addLayer({ id: 'scene-points', type: 'circle', source: 'scenes', filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': '#7c3aed' } }); + data(); fit(); if (currentPreview) assetPreview(currentPreview.entry, currentPreview.asset); + }); + for (const layer of ['scene-fill', 'scene-points']) { + map.on('click', layer, event => { const index = Number(event.features?.[0]?.properties.index); if (items[index]) onScene(index); }); + map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; }); + map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; }); + } + map.on('sourcedata', event => { if (event.sourceId === 'asset-preview' && event.isSourceLoaded) $('preview-status').dataset.state = 'ready'; }); + map.on('error', event => { + if (event.sourceId === 'asset-preview') { $('preview-status').dataset.state = 'error'; $('preview-status').textContent = 'This asset could not be displayed. Try another asset or an external viewer.'; } + else $('map-legend').textContent = 'Some map tiles could not load. Search is still available.'; + }); + } catch { failure(); } + } + $('retry-map').addEventListener('click', start); + $('fit-map').addEventListener('click', fit); + $('use-map-area').addEventListener('click', () => { + if (!ready) return; + const b = map.getBounds(); + onArea([Math.max(-180, b.getWest()), Math.max(-90, b.getSouth()), Math.min(180, b.getEast()), Math.min(90, b.getNorth())].map(n => Number(n.toFixed(5)))); + }); + return { + start, + preview: assetPreview, + clearPreview() { previewVersion++; currentPreview = undefined; removePreview(); fit(); }, + query(value) { const signature = JSON.stringify([value.bbox, value.intersects]); query = value; if (signature !== lastQuery) { lastQuery = signature; data(); fit(); } }, + scenes(value) { items = value; data(); }, + resize() { if (map) requestAnimationFrame(() => map.resize()); }, + theme() { if (ready) { ready = false; map.setStyle(style()); } }, + }; +} diff --git a/docs/public/playground/modules/model.js b/docs/public/playground/modules/model.js new file mode 100644 index 0000000..61f7542 --- /dev/null +++ b/docs/public/playground/modules/model.js @@ -0,0 +1,68 @@ +// Plain data and validation, separate from rendering and the WASM boundary. +export const AREAS = { + alps: { name: 'French Alps', bbox: [6, 45, 7, 46] }, + barcelona: { name: 'Barcelona coast', bbox: [1.9, 41.2, 2.4, 41.6] }, + nairobi: { name: 'Nairobi', bbox: [36.6, -1.5, 37, -1.1] }, +}; +export const DEFAULT_SETTINGS = { + deduplicate_items: true, unify_response: true, max_concurrent_catalogs: 8, + per_catalog_timeout_seconds: 30, max_retry_attempts: 2, max_items_per_catalog: 1000, +}; +export function defaults() { + return { + providers: [{ id: 'element84', name: 'Element 84' }, { id: 'microsoft', name: 'Microsoft' }], + catalogs: [ + { id: 'earth-search', name: 'Earth Search', provider: 'element84', url: 'https://earth-search.aws.element84.com/v1', enabled: true, collection_aliases: {}, asset_aliases: {} }, + { id: 'planetary-computer', name: 'Planetary Computer', provider: 'microsoft', url: 'https://planetarycomputer.microsoft.com/api/stac/v1', enabled: true, collection_aliases: {}, asset_aliases: {} }, + ], settings: { ...DEFAULT_SETTINGS }, + }; +} +export function identifier(value, label = 'ID') { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(`${label} can contain letters, numbers, hyphens, and underscores.`); + return value; +} +export function httpUrl(value, base) { + if (typeof value !== 'string' || !value.trim()) return null; + try { const u = new URL(value, base); return ['http:', 'https:'].includes(u.protocol) ? u.href : null; } + catch { return null; } +} +export function catalogUrl(value) { + const parsed = new URL(value); + if (!httpUrl(value) || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error('Use an HTTP or HTTPS catalog URL without credentials, query parameters, or a fragment.'); + return parsed.href.replace(/\/$/, ''); +} +export function aliases(text, nested = false) { + const value = JSON.parse(text.trim() || '{}'); + const record = obj => obj && typeof obj === 'object' && !Array.isArray(obj); + if (!record(value) || !Object.values(value).every(v => nested ? record(v) && Object.values(v).every(s => typeof s === 'string') : typeof v === 'string')) { + throw new Error(nested ? 'Asset aliases must map a collection to an object of name pairs.' : 'Collection aliases must be an object of name pairs.'); + } + return value; +} +export function configFor(state) { + const selected = state.catalogs.filter(c => c.enabled); + if (!selected.length) throw new Error('Select at least one catalog in Catalogs & providers.'); + return { catalogs: selected.map(({ id, url, collection_aliases, asset_aliases }) => ({ id, url, collection_aliases, asset_aliases })), settings: { ...state.settings } }; +} +export function splitList(text) { return text.split(/[,\n]/).map(v => v.trim()).filter(Boolean); } +export function queryFor(values) { + const query = { collections: splitList(values.collections), limit: Number(values.limit) }; + if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 500) throw new Error('Choose between 1 and 500 scenes per catalog.'); + if (values.from && values.to && values.from > values.to) throw new Error('The end date must be on or after the start date.'); + if (values.from || values.to) query.datetime = `${values.from ? values.from + 'T00:00:00Z' : '..'}/${values.to ? values.to + 'T23:59:59Z' : '..'}`; + if (values.geometry.trim()) { + const geometry = JSON.parse(values.geometry); + if (!geometry || !['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon', 'GeometryCollection'].includes(geometry.type)) throw new Error('Use a GeoJSON geometry, such as a Polygon, rather than a Feature.'); + query.intersects = geometry; + } else if (values.area !== 'anywhere') { + const bbox = values.area === 'custom' ? values.bbox.map(v => v.trim() === '' ? NaN : Number(v)) : AREAS[values.area]?.bbox; + if (!bbox || bbox.length !== 4 || bbox.some(v => !Number.isFinite(v)) || bbox[0] < -180 || bbox[2] > 180 || bbox[1] < -90 || bbox[3] > 90 || bbox[0] >= bbox[2] || bbox[1] >= bbox[3]) throw new Error('Enter a valid bounding box: west < east, south < north, with longitude −180 to 180 and latitude −90 to 90.'); + query.bbox = bbox; + } + const ids = splitList(values.ids); if (ids.length) query.ids = ids; + const sortby = splitList(values.sortby); if (sortby.length) query.sortby = sortby; + return query; +} +export function example(config, query) { + return `import init, { SuperSTAC } from '@spatialnode/superstac-wasm';\n\nawait init();\n\n// Configure the catalogs your application searches.\nconst client = new SuperSTAC(${JSON.stringify(config, null, 2)});\n\ntry {\n const result = await client.search(${JSON.stringify(query, null, 2).replaceAll('\n', '\n ')});\n\n for (const { item, seen_in } of result.items) {\n console.log(item.id, item.properties.datetime, seen_in);\n }\n\n // A catalog can fail while others still return scenes.\n for (const failure of result.metadata.failures) {\n console.warn(failure.catalog_id, failure.reason);\n }\n} finally {\n client.free();\n}`; +} diff --git a/docs/public/playground/modules/provider-icon.js b/docs/public/playground/modules/provider-icon.js new file mode 100644 index 0000000..7945f54 --- /dev/null +++ b/docs/public/playground/modules/provider-icon.js @@ -0,0 +1,13 @@ +import { el } from './dom.js'; +import { httpUrl } from './model.js'; + +export function providerIcon(provider) { + const icon = el('span', undefined, 'provider-icon'); icon.setAttribute('aria-hidden', 'true'); + const fallback = () => icon.replaceChildren(el('span', (provider?.name ?? 'Catalog').split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase())); + fallback(); + if (httpUrl(provider?.icon)) { + const img = el('img'); img.src = provider.icon; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.loading = 'lazy'; + img.addEventListener('error', fallback, { once: true }); icon.replaceChildren(img); + } + return icon; +} diff --git a/docs/public/playground/modules/results.js b/docs/public/playground/modules/results.js new file mode 100644 index 0000000..5885f5e --- /dev/null +++ b/docs/public/playground/modules/results.js @@ -0,0 +1,145 @@ +import { $, el, button, link } from './dom.js'; +import { httpUrl } from './model.js'; +import { itemUrl, thumbnailCandidates, viewerLinks, previewAssets } from './viewers.js'; + +export function resultView(overview) { + let response, selected, page = 0; + const pageSize = 12; + let previewEntry, assets = []; + function closeMap() { + previewEntry = undefined; overview.clearPreview(); + $('close-map').hidden = true; $('viewer-links').hidden = true; $('preview-controls').hidden = true; + $('viewer-title').textContent = 'Search area'; overview.resize(); + $('viewer-caption').textContent = 'Move the map to explore. Search to see scene footprints and imagery.'; + } + function preview(entry, href) { + if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); + document.dispatchEvent(new Event('playground:preview')); + previewEntry = entry; $('close-map').hidden = false; collapseResults(true); + $('viewer-title').textContent = entry.item.id; + $('viewer-caption').textContent = 'Scene and asset preview · OpenFreeMap'; + const links = viewerLinks(href); + $('viewer-links').replaceChildren(...(links ? [link('Open in STAC Map ↗', links.map), link('Open in STAC Browser ↗', links.browser)] : [])); + $('viewer-links').hidden = !links; + const base = href ?? selected.catalogs.find(c => c.id === entry.catalog_id)?.url + '/'; + const catalog = selected.catalogs.find(c => c.id === entry.catalog_id); + assets = previewAssets(entry.item, base, selected.unify ? catalog?.asset_aliases?.[entry.item.collection] : {}); + $('preview-asset').replaceChildren(new Option('Footprint only', ''), ...assets.map(a => new Option(`${a.title}${a.cog ? ' · GeoTIFF' : ' · image'}`, a.id))); + const first = assets.find(a => a.tilejson && a.id === 'visual') ?? assets.find(a => a.tilejson) ?? assets.find(a => a.thumbnail) ?? assets.find(a => !a.cog); + $('preview-asset').value = first?.id ?? ''; + $('preview-controls').hidden = false; overview.preview(entry, first); + $('viewer').scrollIntoView({ behavior: 'instant', block: 'nearest' }); + $('close-map').focus({ preventScroll: true }); + } + $('preview-asset').addEventListener('change', () => { + if (previewEntry) overview.preview(previewEntry, assets.find(a => a.id === $('preview-asset').value)); + }); + function cards() { + $('scenes').replaceChildren(); + for (const entry of response.items.slice(page * pageSize, (page + 1) * pageSize)) { + const { item } = entry; const href = itemUrl(entry, selected.catalogs); + const base = href ?? selected.catalogs.find(c => c.id === entry.catalog_id)?.url + '/'; + const card = el('article', undefined, 'scene'); + const picture = el('div', undefined, 'scene-picture'); + const previews = thumbnailCandidates(item, base); + let nextPreview = 0; + const fallback = () => picture.replaceChildren(el('span', 'No preview available', 'hint')); + if (previews.length) { + const img = el('img'); img.alt = `Preview of ${item.id}`; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; + img.addEventListener('error', () => { + if (nextPreview < previews.length) img.src = previews[nextPreview++]; + else fallback(); + }); + img.src = previews[nextPreview++]; picture.append(img); + } else fallback(); + card.append(picture); + const body = el('div', undefined, 'scene-body'); + const captured = item.properties?.datetime ?? item.properties?.start_datetime; const date = new Date(captured); + body.append(el('h3', captured && Number.isFinite(date.getTime()) ? new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: 'UTC' }).format(date) : 'Capture date unavailable'), el('p', item.id, 'scene-id')); + const cloud = item.properties?.['eo:cloud_cover']; const assets = Object.entries(item.assets ?? {}); + body.append(el('p', `${typeof cloud === 'number' && Number.isFinite(cloud) ? `${Math.round(cloud)}% cloud cover` : 'Cloud cover not reported'} · ${assets.length} assets`, 'hint')); + const sourceCatalog = selected.catalogs.find(c => c.id === entry.catalog_id); + if (selected.unify && sourceCatalog) { + const localCollection = sourceCatalog.collection_aliases?.[item.collection]; + const mappings = Object.entries(sourceCatalog.asset_aliases?.[item.collection] ?? {}).filter(([name, local]) => item.assets?.[name] && name !== local); + if (localCollection || mappings.length) { + const names = el('details', undefined, 'result-aliases'); names.append(el('summary', 'Names in this result')); + if (localCollection) names.append(el('p', `${localCollection} → ${item.collection}`, 'hint')); + for (const [name, local] of mappings) names.append(el('p', `${local} → ${name}`, 'hint')); + body.append(names); + } + } + const sources = el('div', undefined, 'sources'); + for (const id of entry.seen_in) sources.append(el('span', selected.catalogs.find(c => c.id === id)?.name ?? id, 'source')); + body.append(sources); + if (href || item.geometry || previewAssets(item, base).length) { + const links = viewerLinks(href); const actions = el('div', undefined, 'scene-actions'); + actions.append(button('Preview on map', () => preview(entry, href), 'primary')); + body.append(actions); + if (links) { + const viewers = el('details'); viewers.append(el('summary', 'Open in another viewer')); + const choices = el('div', undefined, 'scene-actions'); + choices.append(link('STAC Map ↗', links.map), link('STAC Browser ↗', links.browser)); + viewers.append(choices); body.append(viewers); + } + } else body.append(el('p', 'This scene has no public record link for the map or browser.', 'hint')); + if (assets.length) { + const details = el('details'); details.append(el('summary', 'Scene assets')); + const list = el('ul', undefined, 'asset-list'); + for (const [name, asset] of assets) { + const row = el('li'); const assetUrl = httpUrl(asset.href, base); + row.append(assetUrl ? link(asset.title ?? name, assetUrl) : el('span', asset.title ?? name)); + if (asset.roles?.length) row.append(el('span', asset.roles.join(', '), 'hint')); + list.append(row); + } + details.append(list); body.append(details); + } + card.append(body); $('scenes').append(card); + } + if (!response.items.length) { + $('scenes').append(el('p', response.metadata.catalogs_succeeded ? 'No scenes matched. Try a wider date range or another area.' : 'No catalog returned results. Check the catalog details above or try another catalog.', 'hint'), button('Adjust search', () => document.dispatchEvent(new Event('playground:edit-search')))); + } + const pages = Math.ceil(response.items.length / pageSize); + $('pagination').hidden = pages <= 1; $('previous').disabled = page === 0; $('next').disabled = page >= pages - 1; + $('page-label').textContent = `Page ${page + 1} of ${pages}`; + } + function collapseResults(collapsed) { + if (!collapsed) document.dispatchEvent(new Event('playground:results-open')); + $('result-body').hidden = collapsed; $('toggle-results').textContent = collapsed ? 'Show results' : 'Hide results'; + $('toggle-results').setAttribute('aria-expanded', String(!collapsed)); + $('result-content').classList.toggle('collapsed', collapsed); + } + $('toggle-results').addEventListener('click', () => collapseResults(!$('result-body').hidden)); + $('previous').addEventListener('click', () => { if (page > 0) { page--; cards(); } }); + $('next').addEventListener('click', () => { if ((page + 1) * pageSize < response.items.length) { page++; cards(); } }); + $('close-map').addEventListener('click', () => { closeMap(); collapseResults(false); $('toggle-results').focus({ preventScroll: true }); }); + return { + collapse: () => collapseResults(true), + preview(index) { + const entry = response?.items[index]; if (!entry) return; + const href = itemUrl(entry, selected.catalogs); + preview(entry, href); + }, + clear() { response = undefined; selected = undefined; overview.scenes([]); closeMap(); $('result-content').hidden = true; $('empty').hidden = false; $('scenes').replaceChildren(); $('response').textContent = ''; }, + render(result, snapshot) { + response = result; selected = snapshot; page = 0; overview.scenes(result.items); closeMap(); + const { metadata, items } = result; + $('empty').hidden = true; $('result-content').hidden = false; collapseResults(false); + $('result-title').textContent = `${items.length} ${items.length === 1 ? 'scene' : 'scenes'} found`; + $('result-query').textContent = `${snapshot.areaName} · ${snapshot.query.collections.join(', ') || 'All collections'} · ${snapshot.query.datetime ? snapshot.query.datetime.split('/').map(date => date === '..' ? 'Any date' : new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(date))).join(' – ') : 'Any date'}`; + $('result-version').textContent = `SuperSTAC ${metadata.superstac_version}`; + $('summary').replaceChildren(el('span', `${metadata.catalogs_succeeded} of ${metadata.catalogs_queried} catalogs responded`), el('span', `${metadata.duplicates_removed} duplicate ${metadata.duplicates_removed === 1 ? 'record' : 'records'} combined`)); + $('catalog-status').replaceChildren(); + for (const catalog of snapshot.catalogs.filter(c => c.enabled)) { + const failure = metadata.failures.find(f => f.catalog_id === catalog.id); + const count = items.filter(entry => entry.seen_in.includes(catalog.id)).length; + const note = el('div', undefined, `catalog-note${failure ? ' failure' : ''}`); + note.append(el('span', failure ? `${catalog.name} could not complete this search.` : `${catalog.name}: ${count} ${count === 1 ? 'scene' : 'scenes'}`)); + if (failure) { const details = el('details'); details.append(el('summary', 'Error details'), el('pre', failure.reason)); note.append(details); } + $('catalog-status').append(note); + } + if (metadata.unsupported_collections?.length) $('catalog-status').append(el('p', `Unsupported collections: ${metadata.unsupported_collections.join(', ')}`, 'hint')); + cards(); $('response').textContent = JSON.stringify(result, null, 2); + }, + }; +} diff --git a/docs/public/playground/modules/stac-index.js b/docs/public/playground/modules/stac-index.js new file mode 100644 index 0000000..190427d --- /dev/null +++ b/docs/public/playground/modules/stac-index.js @@ -0,0 +1,76 @@ +import { $, el, button } from './dom.js'; +import { catalogUrl } from './model.js'; + +export const INDEX_URL = 'https://stacindex.org/api/catalogs'; +export const urlKey = value => { try { return catalogUrl(value).replace(/\/+$/, ''); } catch { return ''; } }; + +export function publicApis(data) { + if (!Array.isArray(data)) throw new Error('Unexpected catalog directory response.'); + const seen = new Set(); + return data.flatMap(entry => { + if (!entry || entry.isApi !== true || entry.isPrivate === true || entry.access !== 'public') return []; + const url = typeof entry.url === 'string' ? urlKey(entry.url) : ''; + if (!url || /\.json$/i.test(new URL(url).pathname) || seen.has(url)) return []; + seen.add(url); + const name = typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : new URL(url).hostname; + const summary = typeof entry.summary === 'string' ? entry.summary.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\s+/g, ' ').trim().slice(0, 360) : ''; + return [{ name, url, summary, slug: typeof entry.slug === 'string' ? entry.slug : name }]; + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +export function directoryCatalog(entry, existing) { + const base = entry.slug.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '') || 'catalog'; + let id = base, suffix = 2; + while (existing.some(c => c.id === id)) id = `${base}-${suffix++}`; + return { id, name: entry.name, url: entry.url, provider: '', collection_aliases: {}, asset_aliases: {} }; +} + +export function catalogDirectory(getCatalogs, add, isBusy) { + let entries, controller, limit = 20; + function render() { + if (!entries) return; + const query = $('index-search').value.trim().toLowerCase(); + const matches = entries.filter(e => `${e.name} ${e.summary} ${e.url}`.toLowerCase().includes(query)); + const existing = new Set(getCatalogs().map(c => urlKey(c.url))); + $('index-list').replaceChildren(); + for (const entry of matches.slice(0, limit)) { + const card = el('article', undefined, 'index-entry'); + card.append(el('h3', entry.name), el('p', entry.summary, 'hint'), el('p', entry.url, 'catalog-url')); + const added = existing.has(entry.url); + const choose = button(added ? 'Already added' : 'Choose catalog', () => { + if (isBusy()) return; + $('index-dialog').close(); add(directoryCatalog(entry, getCatalogs())); + }); + choose.disabled = added || isBusy(); card.append(choose); $('index-list').append(card); + } + $('index-status').textContent = matches.length ? `${Math.min(limit, matches.length)} of ${matches.length} catalogs` : 'No catalogs match. Try another name or topic.'; + $('index-more').hidden = matches.length <= limit; + } + async function load() { + controller?.abort(); const request = new AbortController(); controller = request; + const timer = setTimeout(() => request.abort(), 12000); + $('index-status').textContent = 'Loading catalogs…'; $('index-retry').hidden = true; + $('index-list').replaceChildren(); $('index-more').hidden = true; + try { + const response = await fetch(INDEX_URL, { signal: request.signal, credentials: 'omit' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (request.signal.aborted) return; + entries = publicApis(data); render(); + } catch { + if (controller === request && $('index-dialog').open) { + $('index-status').textContent = 'STAC Index could not load. Try again, or close this window and add a catalog by URL.'; + $('index-retry').hidden = false; + } + } finally { clearTimeout(timer); } + } + $('browse-index').addEventListener('click', () => { + if (isBusy()) return; + $('index-dialog').showModal(); $('index-search').focus(); + if (entries) render(); else load(); + }); + $('index-dialog').addEventListener('close', () => controller?.abort()); + $('index-retry').addEventListener('click', load); + $('index-search').addEventListener('input', () => { limit = 20; render(); }); + $('index-more').addEventListener('click', () => { limit += 20; render(); }); +} diff --git a/docs/public/playground/modules/viewers.js b/docs/public/playground/modules/viewers.js new file mode 100644 index 0000000..42cbca7 --- /dev/null +++ b/docs/public/playground/modules/viewers.js @@ -0,0 +1,59 @@ +import { httpUrl } from './model.js'; +export function itemUrl(entry, catalogs) { + const catalog = catalogs.find(c => c.id === entry.catalog_id); + const link = entry.item.links?.find(l => l.rel === 'self') ?? entry.item.links?.find(l => l.rel === 'canonical'); + return link?.href ? httpUrl(link.href, catalog ? catalog.url + '/' : undefined) : null; +} +export function viewerLinks(href) { + if (!httpUrl(href)) return null; + const map = new URL('https://developmentseed.org/stac-map/'); + map.searchParams.set('href', href); + // STAC Browser's external route accepts a full URL after /external/. + return { map: map.href, browser: `https://radiantearth.github.io/stac-browser/#/external/${href}` }; +} +// A thumbnail label alone does not establish a browser-displayable format. +function isTiff(asset) { + let href = asset.href ?? ''; + try { href = decodeURIComponent(href); } catch { /* Keep malformed URLs unchanged. */ } + return /tiff|geotiff/i.test(asset.type ?? '') || /\.tiff?(?:[?#]|$)/i.test(href); +} +function isBrowserImage(asset) { + return !isTiff(asset) && (/^image\/(jpeg|png|webp|gif|avif)(?:\s*;|$)/i.test(asset.type ?? '') || + /\.(jpe?g|png|webp|gif|avif)(?:[?#]|$)/i.test(asset.href ?? '')); +} + +export function thumbnailCandidates(item, base) { + const assets = Object.entries(item.assets ?? {}); + const rendered = ([key, asset]) => key === 'rendered_preview' || asset.roles?.includes('overview'); + const isThumbnail = ([key, asset]) => key === 'thumbnail' || asset.roles?.includes('thumbnail'); + const pictures = assets.filter(([, asset]) => asset && isBrowserImage(asset)); + const ordered = [...pictures.filter(isThumbnail), ...pictures.filter(rendered), ...pictures]; + const links = (item.links ?? []).filter(link => link?.rel === 'preview' && isBrowserImage(link)); + return [...new Set([...ordered.map(([, asset]) => httpUrl(asset.href, base)), ...links.map(link => httpUrl(link.href, base))].filter(Boolean))]; +} + +export function thumbnail(item, base) { + return thumbnailCandidates(item, base)[0] ?? null; +} + +export function hotTileJSON(base, assetId) { + const href = httpUrl(base); + if (!href) return null; + const record = new URL(href); + if (record.origin !== 'https://api.imagery.hotosm.org' || !/^\/stac\/collections\/[^/]+\/items\/[^/]+\/?$/.test(record.pathname)) return null; + const path = record.pathname.replace(/^\/stac/, '/raster').replace(/\/$/, ''); + const url = new URL(`${path}/WebMercatorQuad/tilejson.json`, record.origin); + url.searchParams.set('assets', assetId); url.searchParams.set('tilesize', '256'); url.searchParams.set('tile_format', 'png'); + return url.href; +} + +export function previewAssets(item, base, assetAliases = {}) { + return Object.entries(item.assets ?? {}).flatMap(([id, asset]) => { + const href = httpUrl(asset.href, base); + if (!href) return []; + const cog = isTiff(asset); + const picture = isBrowserImage(asset); + const tilejson = cog ? hotTileJSON(base, assetAliases[id] ?? id) : null; + return cog || picture ? [{ id, href, cog, tilejson, title: asset.title ?? id, thumbnail: !cog && (id === 'thumbnail' || asset.roles?.includes('thumbnail')) }] : []; + }); +} diff --git a/docs/public/playground/playground.css b/docs/public/playground/playground.css new file mode 100644 index 0000000..2b48693 --- /dev/null +++ b/docs/public/playground/playground.css @@ -0,0 +1,392 @@ +/* Palette shared with app/global.css; compact controls follow shadcn/mapcn patterns. */ +:root { + color-scheme: light; + --bg: #f7f8fb; + --panel: #fff; + --text: #02051f; + --muted: #626977; + --line: #e0e3eb; + --accent: #081b99; + --accent-text: #fff; + --soft: #ebedfd; + --error: #b42335; + --success: #21734b; + --shadow: 0 4px 20px #02051f0a; + font: 13px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; + color: var(--text); + background: var(--bg); +} +:root[data-theme=dark] { + color-scheme: dark; + --bg: #0b0c0f; + --panel: #111419; + --text: #ebedfd; + --muted: #b9bec9; + --line: #282d39; + --accent: #b8c1f2; + --accent-text: #02051f; + --soft: #222732; + --error: #ffa5af; + --success: #86d6ac; + --shadow: 0 4px 20px #0002; +} +* { box-sizing: border-box; } +body { margin: 0; -webkit-font-smoothing: antialiased; } +[hidden] { display: none !important; } +a { color: var(--accent); text-underline-offset: 3px; } +button, input, select, textarea { font: inherit; } +button { cursor: pointer; border: 1px solid transparent; border-radius: 7px; padding: 8px 12px; font-weight: 550; line-height: 1.4; white-space: nowrap; } +button:disabled { cursor: default; opacity: .5; } +button:hover:not(:disabled) { filter: brightness(.96); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.primary { background: var(--accent); color: var(--accent-text); } +.secondary { background: var(--panel); border-color: var(--line); color: var(--text); box-shadow: 0 1px 2px #00000005; } +.quiet { background: transparent; color: var(--muted); padding: 5px 7px; } +.full { width: 100%; } +.topbar { height: 58px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); background: var(--panel); } +.brand { display: inline-flex; align-items: center; gap: 18px; color: var(--muted); text-decoration: none; font-size: 12px; } +.brand img { width: 120px; height: auto; } +.brand span { border-left: 1px solid var(--line); padding-left: 18px; } +.logo-dark, [data-theme=dark] .logo-light { display: none; } +[data-theme=dark] .logo-dark { display: block; } +.topbar nav { display: flex; align-items: center; gap: 24px; font-size: 12px; } +.topbar nav a { color: var(--muted); text-decoration: none; } +main { height: calc(100dvh - 58px); display: flex; flex-direction: column; padding: 0 20px 12px; } +.intro { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 0; flex-shrink: 0; } +h1 { font-family: 'Crimson Pro', Georgia, serif; font-weight: 500; font-size: 28px; line-height: 1.1; letter-spacing: -.7px; margin: 0; } +.intro p { margin: 4px 0 0; font-size: 11px; color: var(--muted); } +h2 { font-size: 15px; font-weight: 600; margin: 0 0 6px; letter-spacing: -.2px; } +h3 { font-size: 13px; margin: 0 0 6px; } +p { margin: 6px 0 12px; } +.workspace { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 16px; min-height: 0; flex: 1; } +.controls-overlay { min-height: 0; overflow-y: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); scrollbar-width: thin; } +.controls-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 16px 18px 12px; } +.controls-heading strong { font-size: 12px; } +.controls-heading .hint { font-size: 10px; } +.tabs { display: flex; padding: 4px; margin: 0 14px 2px; gap: 3px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; } +.tabs button { flex: 1; background: transparent; color: var(--muted); padding: 7px 4px; font-size: 12px; } +.tabs button[aria-selected=true] { background: var(--panel); color: var(--text); box-shadow: 0 1px 3px #00000014; } +[role=tabpanel] { padding: 18px; } +fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } +label { display: block; margin-bottom: 14px; font-size: 11px; font-weight: 550; } +input:not([type=checkbox]), select, textarea { display: block; width: 100%; min-width: 0; padding: 8px 10px; margin-top: 5px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 6px; font-size: 12px; font-weight: 400; } +textarea { resize: vertical; line-height: 1.5; } +input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); flex-shrink: 0; } +.check { display: flex; align-items: center; gap: 8px; font-weight: 400; } +.check strong { min-width: 0; overflow-wrap: anywhere; } +.two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.two-col label, .two-col input { min-width: 0; } +.hint { color: var(--muted); font-size: 11px; line-height: 1.55; font-weight: 400; } +label .hint { display: block; margin-top: 5px; } +details { border-top: 1px solid var(--line); padding-top: 10px; margin-top: 12px; } +summary { font-size: 11px; font-weight: 550; cursor: pointer; } +details[open]>summary { margin-bottom: 12px; } +#code-details { padding: 16px 18px; margin: 0; background: var(--bg); } +pre { font: 11px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; overflow: auto; max-height: 300px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); } +.error { font-size: 12px; color: var(--error); overflow-wrap: anywhere; } +.toolbar { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.toolbar button { font-size: 11px; } +.catalog-entry { padding: 12px; margin-bottom: 10px; border: 1px solid var(--line); border-radius: 9px; } +.catalog-entry .check { margin: 0; } +.catalog-entry p { margin: 5px 0; } +.catalog-url { font-size: 10px; color: var(--muted); overflow-wrap: anywhere; } +.catalog-entry .toolbar { margin-top: 8px; } +.provider-icon { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 7px; background: var(--soft); color: var(--accent); font: 600 10px ui-sans-serif, sans-serif; overflow: hidden; } +.provider-icon img { width: 100%; height: 100%; object-fit: contain; } +.health-badge { display: inline-flex; gap: 5px; align-items: center; font-size: 10px; color: var(--muted); margin-top: 5px; } +.health-badge::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } +.health-badge[data-state=ok] { color: var(--success); } +.health-badge[data-state=error] { color: var(--error); } +.provider-entry { display: flex; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--line); } +.provider-entry>span:not(.provider-icon) { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 11px; } +.provider-entry .toolbar { gap: 0; flex-wrap: nowrap; } +.section-title { margin-top: 24px; } +#add-provider { margin-top: 12px; } +.collection-list { display: grid; gap: 5px; max-height: 260px; overflow-y: auto; } +.collection-button { white-space: normal; text-align: left; background: var(--soft); color: var(--text); font-size: 11px; overflow-wrap: anywhere; } +.collection-button span { display: block; } +.collection-group { overflow-wrap: anywhere; } +.results { min-width: 0; min-height: 0; position: relative; display: flex; flex-direction: column; gap: 10px; } +.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 28px; } +#status { font-size: 11px; color: var(--muted); overflow-wrap: anywhere; } +.statusbar .toolbar { flex-wrap: nowrap; flex-shrink: 0; } +#operation-time { font: 10px ui-monospace, monospace; } +#activity-count { display: inline-flex; justify-content: center; min-width: 18px; margin-left: 4px; border-radius: 4px; background: var(--soft); font-size: 10px; } +.viewer { position: relative; flex: 1; min-height: 250px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: var(--soft); } +.viewer-toolbar { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 80px); display: flex; align-items: start; gap: 12px; padding: 10px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); } +.viewer-toolbar>div { min-width: 0; } +.viewer-toolbar h2 { font-size: 12px; overflow-wrap: anywhere; margin-bottom: 2px; } +.viewer-toolbar p { margin: 0; font-size: 10px; } +.viewer-toolbar button { flex-shrink: 0; } +.map-placeholder, #overview-map { position: absolute; inset: 0; } +#map-fallback { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 90px 30px; text-align: center; background: var(--bg); } +#map-fallback h3 { font: 24px Georgia, serif; } +#map-fallback p { max-width: 330px; color: var(--muted); font-size: 12px; } +.map-tools { position: absolute; z-index: 2; bottom: 28px; left: 12px; right: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; pointer-events: none; } +.map-tools button { pointer-events: auto; font-size: 11px; box-shadow: var(--shadow); } +#map-legend { margin-left: auto; font-size: 10px; background: var(--panel); padding: 5px 8px; border-radius: 5px; } +.viewer-links { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: 7px 12px; display: flex; gap: 16px; background: var(--panel); font-size: 10px; } +.empty { display: none; } +#result-content { position: absolute; z-index: 3; top: 88px; right: 14px; bottom: 74px; width: min(360px, calc(100% - 28px)); overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: var(--shadow); display: flex; flex-direction: column; } +.results-heading { display: flex; align-items: start; justify-content: space-between; gap: 10px; } +.results-heading h2 { font-size: 14px; } +.results-heading p { margin-bottom: 8px; } +.summary { display: flex; flex-wrap: wrap; gap: 6px; } +.summary span { font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 5px; padding: 3px 6px; } +#catalog-status { display: flex; gap: 10px; flex-wrap: wrap; margin: 8px 0 12px; } +.catalog-note { font-size: 10px; color: var(--muted); min-width: 0; max-width: 100%; } +.catalog-note.failure { color: var(--error); } +.catalog-note pre { white-space: pre-wrap; overflow-wrap: anywhere; } +.scenes { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; } +.scene { min-width: 0; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; } +.scene-picture { height: 110px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--soft); } +.scene-picture img { width: 100%; height: 100%; object-fit: cover; } +.scene-body { padding: 12px; } +.scene-id { font: 10px/1.5 ui-monospace, monospace; color: var(--muted); overflow-wrap: anywhere; margin: 4px 0 7px; } +.scene-body>.hint { font-size: 10px; } +.sources { display: flex; flex-wrap: wrap; gap: 4px; } +.source { padding: 2px 5px; background: var(--soft); border-radius: 4px; font-size: 9px; } +.scene-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 10px; margin-top: 12px; } +.scene-actions .primary { width: 100%; font-size: 11px; } +.asset-list { list-style: none; padding: 0; margin: 0; max-height: 180px; overflow: auto; font-size: 11px; } +.asset-list li { display: flex; flex-direction: column; padding: 5px 0; overflow-wrap: anywhere; } +.pagination { display: flex; justify-content: center; align-items: center; gap: 14px; margin: 16px 0; font-size: 11px; } +.activity-panel { position: absolute; top: 38px; bottom: 0; right: 0; width: min(440px, 100%); z-index: 5; overflow: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); padding: 18px; box-shadow: -12px 0 40px #02051f18; } +.activity-toolbar { display: flex; align-items: start; gap: 16px; } +.activity-toolbar>div { flex: 1; } +.activity-toolbar button { font-size: 20px; } +.activity-panel .toolbar select { width: auto; margin: 0; } +#activity-list { list-style: none; padding: 0; margin: 18px 0 0; } +.activity-entry { padding: 12px 0; border-bottom: 1px solid var(--line); } +.activity-heading { display: flex; align-items: start; gap: 8px; justify-content: space-between; font-size: 11px; } +.activity-entry p { margin: 4px 0; } +.request-duration { font: 10px ui-monospace, monospace; white-space: nowrap; color: var(--muted); } +.request-url { display: block; overflow-wrap: anywhere; font-size: 10px; color: var(--muted); } +.activity-time { display: block; margin-top: 5px; font: 9px ui-monospace, monospace; color: var(--muted); } +.activity-entry.failed strong { color: var(--error); } +footer { flex-shrink: 0; font-size: 9px; color: var(--muted); padding-top: 8px; } +dialog { width: min(500px, calc(100% - 32px)); max-height: calc(100dvh - 40px); overflow: auto; padding: 24px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--text); box-shadow: 0 20px 80px #02051f30; } +dialog::backdrop { background: #02051f60; backdrop-filter: blur(3px); } +.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +#toggle-controls { display: none; } +@media (max-width: 1150px) { + .workspace { grid-template-columns: 290px minmax(0,1fr); gap: 12px; } + + #operation-time { display: none; } +} +@media (max-width: 760px) { + .topbar { padding: 0 14px; height: 52px; } + .brand { gap: 10px; } + .brand img { width: 100px; } + .brand span { padding-left: 10px; font-size: 10px; } + .topbar nav { gap: 4px; } + .topbar nav>a { display: none; } + main { height: auto; min-height: calc(100dvh - 52px); padding: 0 10px 10px; } + h1 { font-size: 25px; } + .intro { display: block; padding: 16px 4px 12px; } + .intro>.toolbar { margin-top: 12px; } + #toggle-controls { display: inline-block; } + .workspace { display: flex; flex-direction: column; gap: 12px; } + .controls-overlay { display: none; overflow: visible; } + body[data-controls=open] .controls-overlay { display: block; } + .results { display: block; } + .statusbar { margin-bottom: 8px; } + .statusbar .toolbar { flex-shrink: 0; } + .viewer { height: 70dvh; min-height: 440px; } + .viewer-toolbar { max-width: calc(100% - 64px); top: 8px; left: 8px; padding: 8px; } + .map-tools { bottom: 35px; left: 8px; right: 8px; } + .map-tools button { font-size: 10px; padding: 7px 8px; } + #map-legend { margin-left: 0; font-size: 9px; } + #result-content { top: auto; bottom: 54px; left: 10px; right: 10px; width: auto; max-height: 34dvh; } + .scenes { grid-template-columns: minmax(0,1fr); } + .scene { display: grid; grid-template-columns: 90px minmax(0,1fr); } + .scene-picture { height: 100%; min-height: 120px; } + .scene-actions .primary { width: auto; } + .results-heading>span { display: none; } + .activity-panel { position: fixed; top: 58px; bottom: 10px; right: 10px; width: calc(100% - 20px); z-index: 10; } + .viewer-links { gap: 10px; font-size: 9px; } +} +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } } + +.sources-heading { display: flex; align-items: center; justify-content: space-between; font-size: 10px; color: var(--muted); margin-bottom: 8px; } +.sources-heading button { font-size: 10px; padding: 0; color: var(--accent); } +#active-catalogs { display: grid; gap: 8px; margin-bottom: 18px; padding-bottom: 16px; border-bottom: 1px solid var(--line); } +.active-source { display: flex; align-items: center; gap: 9px; } +.active-source>div { min-width: 0; display: flex; flex-direction: column; } +.active-source strong { font-size: 11px; font-weight: 550; overflow-wrap: anywhere; } +.active-source .health-badge { margin-top: 0; font-size: 9px; } + +#preview-controls { position: absolute; z-index: 2; left: 12px; top: 85px; width: min(320px, calc(100% - 72px)); padding: 10px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); } +#preview-controls label { margin: 0; font-size: 10px; } +#preview-controls p { margin: 6px 0 0; font-size: 10px; } +.viewer:has(#preview-controls:not([hidden])) .map-tools { bottom: 58px; } + +.result-overlay-bar { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 9px 12px; border-bottom: 1px solid var(--line); flex-shrink: 0; font-size: 11px; font-weight: 600; } +.result-overlay-bar button { font-size: 11px; } +#result-body { padding: 12px; overflow-y: auto; scrollbar-width: thin; min-height: 0; } +#result-content.collapsed { bottom: auto; width: auto; min-width: 190px; } +#result-content.collapsed .result-overlay-bar { border-bottom: 0; } +.alias-section-heading { display: flex; justify-content: space-between; align-items: center; margin: 14px 0 8px; } +.alias-section-heading h3 { margin: 0; } +.alias-section-heading button { font-size: 10px; } +.alias-row { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)) 24px; align-items: center; gap: 8px; } +.alias-row label { font-size: 10px; margin-bottom: 8px; } +#asset-alias-rows .alias-row { grid-template-columns: repeat(3,minmax(0,1fr)) 24px; } +.alias-row input { font-size: 11px; padding: 7px; } +.alias-preview { padding: 10px 12px; margin: 12px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--soft); font-size: 11px; overflow-wrap: anywhere; } +.alias-preview p:last-child { margin-bottom: 0; } +.alias-demo p { font-size: 11px; color: var(--muted); } +.result-aliases { padding: 8px; border: 1px solid var(--line); border-radius: 6px; margin: 8px 0; background: var(--soft); } +@media(max-width:760px) { + #result-content.collapsed { top: auto; bottom: 54px; left: auto; } + #asset-alias-rows .alias-row { grid-template-columns: minmax(0,1fr) minmax(0,1fr) 24px; border-bottom: 1px solid var(--line); padding: 8px 0; } + #asset-alias-rows .alias-row label:first-child { grid-column: 1 / 3; } + #asset-alias-rows .alias-row button { grid-column: 3; grid-row: 1 / 3; } +} + +#index-dialog { width: min(680px, calc(100vw - 32px)); } +.index-list { display: grid; gap: 12px; margin-block: 16px; } +.index-entry { border: 1px solid var(--line); border-radius: 10px; padding: 14px; } +.index-entry h3 { margin: 0 0 6px; } +.index-entry p { margin-block: 6px 12px; overflow-wrap: anywhere; } +.index-entry .catalog-url { font-size: 12px; } + +/* The map owns the workspace; every tool floats above it. */ +body { overflow: hidden; } +main { position: relative; height: calc(100dvh - 58px); padding: 0; overflow: hidden; } +.workspace { position: absolute; inset: 0; display: block; } +.results { position: absolute; inset: 0; display: block; } +.viewer { position: absolute; inset: 0; height: auto; min-height: 0; border: 0; border-radius: 0; } +.intro { position: absolute; z-index: 4; top: 14px; left: 14px; right: 56px; padding: 0; display: flex; pointer-events: none; } +.intro>* { pointer-events: auto; } +.workspace-title { padding: 10px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); } +.workspace-title h1 { font-family: inherit; font-size: 18px; font-weight: 600; letter-spacing: -.3px; } +.intro>.toolbar { margin-left: auto; } +.intro>.toolbar { margin: 0 0 0 auto; gap: 6px; padding: 5px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); } +#toggle-controls { display: inline-block; } +.controls-overlay { position: absolute; z-index: 4; top: 88px; right: 56px; bottom: 96px; width: 330px; display: flex; flex-direction: column; overflow: hidden; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); } +.controls-heading { flex-shrink: 0; padding: 10px 14px; border-bottom: 1px solid var(--line); } +.controls-heading button { font-size: 11px; } +.controls-body { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding-top: 12px; } +.statusbar { position: absolute; z-index: 3; bottom: 30px; left: 14px; max-width: calc(100% - 28px); margin: 0; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); } +#status { max-width: 480px; max-height: 48px; overflow-y: auto; } +.viewer-toolbar { top: 88px; left: 14px; max-width: min(360px, calc(100% - 80px)); } +.viewer:not(:has(#preview-controls:not([hidden]))) .viewer-toolbar { display: none; } +#preview-controls { top: 176px; left: 14px; } +body[data-controls=open] .viewer-toolbar, body[data-controls=open] #preview-controls { visibility: hidden; } +.map-tools, .viewer:has(#preview-controls:not([hidden])) .map-tools { bottom: 88px; left: 14px; right: 14px; } +body[data-controls=open] .map-tools { right: 490px; } +#result-content { top: 88px; right: 56px; bottom: 86px; width: 420px; } +#result-content.collapsed { top: 72px; bottom: auto; } +.activity-panel { top: 72px; right: 14px; bottom: 96px; } +#result-body { overscroll-behavior: contain; } +@media (max-width: 900px) { + .controls-overlay { width: min(350px, calc(100% - 28px)); } + body[data-controls=open] .map-tools { left: 14px; right: 14px; } + #result-content { top: 72px; right: 56px; left: auto; width: min(350px, calc(100% - 70px)); max-height: none; bottom: 140px; } + #result-content.collapsed { top: 72px; right: 56px; bottom: auto; left: auto; width: auto; min-width: 170px; } +} +@media (max-width: 760px) { + main { height: calc(100dvh - 52px); min-height: 0; } + .intro { top: 10px; left: 10px; right: 50px; } + .workspace-title { padding: 8px 10px; } + .workspace-title h1 { font-size: 14px; } + .workspace-title p { display: none; } + .intro>.toolbar { flex-wrap: nowrap; width: fit-content; max-width: 100%; } + .intro .toolbar button { font-size: 10px; padding: 7px; } + + .controls-overlay, body[data-controls=open] .controls-overlay { display: flex; top: 64px; left: auto; right: 10px; bottom: 120px; width: calc(100% - 60px); } + #result-content { top: 64px; right: 10px; width: calc(100% - 20px); bottom: 86px; } + #result-content.collapsed { top: 64px; } + .statusbar { bottom: 28px; left: 10px; max-width: calc(100% - 20px); } + #status { font-size: 10px; max-height: 45px; } + .map-tools, .viewer:has(#preview-controls:not([hidden])) .map-tools, body[data-controls=open] .map-tools { bottom: 100px; left: 10px; right: 10px; } + #map-legend { display: none; } + .viewer-toolbar { top: 80px; left: 10px; } + #preview-controls { top: 171px; left: 10px; } + .activity-panel { position: absolute; top: 64px; bottom: 100px; right: 10px; width: calc(100% - 20px); } +} + +body[data-controls=open] #result-content { visibility: hidden; } +@media (max-width: 900px) { + #result-content.collapsed { top: auto; bottom: 140px; right: 14px; } +} + +/* One entry point on the map; secondary actions stay with their inputs. */ +.intro>.toolbar { padding: 0; border: 0; background: transparent; } +#toggle-controls { padding: 11px 18px; font-size: 13px; box-shadow: var(--shadow); } +#toggle-controls[aria-expanded=true] { background: var(--panel); color: var(--text); border-color: var(--line); } +#close-controls { font-size: 20px; line-height: 1; padding: 6px 10px; } +.area-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: -4px; } +.area-actions button { font-size: 10px; padding: 6px 8px; } +.map-tools, .viewer:has(#preview-controls:not([hidden])) .map-tools { bottom: 30px; pointer-events: none; } +#map-legend { margin-left: auto; max-width: 240px; } +.statusbar { border: 0; box-shadow: none; } +@media (max-width: 900px) { #map-legend { display: none; } } + +/* Keep actions in reach while filters scroll independently. */ +.controls-overlay { width: 420px; bottom: 86px; } +.controls-heading { padding: 12px 18px; } +.controls-heading strong { font-size: 15px; } +.controls-overlay>.tabs { flex-shrink: 0; margin: 12px 16px 0; } +.controls-overlay .tabs button { font-size: 14px; padding: 9px 6px; } +.controls-body { flex: 1; padding-top: 0; } +.controls-overlay [role=tabpanel] { padding: 20px; } +.controls-overlay label { font-size: 13px; } +.controls-overlay input:not([type=checkbox]), .controls-overlay select, .controls-overlay textarea { font-size: 14px; padding: 10px 12px; } +.controls-overlay .hint, .controls-overlay summary { font-size: 12px; } +.controls-overlay .active-source strong { font-size: 13px; } +.controls-overlay .area-actions button, .controls-overlay .toolbar button { font-size: 12px; } +.controls-actions { flex-shrink: 0; padding: 14px 20px; background: var(--panel); border-top: 1px solid var(--line); box-shadow: 0 -6px 16px #00000005; } +.controls-actions p:empty { display: none; } +.controls-actions p { margin: 0 0 8px; max-height: 54px; overflow-y: auto; } +.controls-actions .primary { font-size: 14px; padding: 12px 16px; } +#code-dialog { width: min(960px, calc(100vw - 32px)); height: min(800px, calc(100dvh - 40px)); padding: 0; overflow: hidden; } +#code-dialog[open] { display: flex; flex-direction: column; } +.code-heading, .code-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 24px; flex-shrink: 0; } +.code-heading { border-bottom: 1px solid var(--line); } +.code-heading h2 { font-size: 20px; } +.code-heading p, .code-actions p { margin: 4px 0 0; font-size: 13px; } +#code { flex: 1; min-height: 0; max-height: none; margin: 0; border: 0; border-radius: 0; padding: 24px; font-size: 14px; line-height: 1.8; tab-size: 2; user-select: text; } +.code-actions { border-top: 1px solid var(--line); } +#copy { font-size: 14px; padding: 12px 24px; } +@media (max-width: 760px) { + .controls-overlay, body[data-controls=open] .controls-overlay { width: calc(100% - 20px); bottom: 86px; } + .controls-overlay [role=tabpanel] { padding: 16px; } + .controls-overlay input:not([type=checkbox]), .controls-overlay select, .controls-overlay textarea { font-size: 16px; } + .controls-actions { padding: 12px 16px; } + .code-heading, .code-actions { padding: 14px 16px; } + #code { padding: 16px; font-size: 13px; } +} + +.field-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-size: 13px; font-weight: 550; } +.field-heading button { color: var(--accent); font-size: 12px; } +#panel-search>.alias-demo { margin: 18px 0 0; padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--soft); } +.search-sources #active-catalogs { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; } +.controls-overlay .controls-body { scroll-padding: 16px; } +.controls-overlay input[type=checkbox] { width: 18px; height: 18px; } +.scene-body h3 { font-size: 15px; } +.scene-body>.hint, .scene-actions, .result-overlay-bar, .result-overlay-bar button { font-size: 12px; } +.scene-actions .primary { padding: 10px 12px; font-size: 13px; } + +.code-options { display: flex; align-items: end; gap: 16px; padding: 14px 24px 8px; flex-shrink: 0; } +.code-options label { margin: 0; flex: 1; font-size: 12px; } +.code-options select { font-size: 14px; } +.code-options a { font-size: 12px; padding-bottom: 8px; } +#code-note { padding: 0 24px; margin: 4px 0 12px; font-size: 12px; flex-shrink: 0; } +#code .token-comment { color: #596579; } +#code .token-string { color: #146039; } +#code .token-keyword { color: #7b278a; font-weight: 550; } +#code .token-number { color: #0756a0; } +[data-theme=dark] #code .token-comment { color: #9da9b9; } +[data-theme=dark] #code .token-string { color: #a6dfb5; } +[data-theme=dark] #code .token-keyword { color: #d5b3ff; } +[data-theme=dark] #code .token-number { color: #94c8ff; } +@media (max-width: 540px) { + .code-options { padding: 12px 16px 8px; flex-wrap: wrap; gap: 10px; } + .code-options a { width: 100%; padding: 0; } + #code-note { padding-inline: 16px; } + #copy { padding: 10px 12px; font-size: 12px; } +} diff --git a/docs/public/playground/playground.js b/docs/public/playground/playground.js new file mode 100644 index 0000000..ceac2e6 --- /dev/null +++ b/docs/public/playground/playground.js @@ -0,0 +1,220 @@ +import { $, el, button, errorMessage } from './modules/dom.js'; +import { defaults, DEFAULT_SETTINGS, AREAS, configFor, queryFor } from './modules/model.js'; +import { codeView } from './modules/code-view.js'; +import { catalogDirectory } from './modules/stac-index.js'; +import { catalogEditor } from './modules/catalogs.js'; +import { resultView } from './modules/results.js'; +import { activityMonitor } from './modules/activity.js'; +import { overviewMap } from './modules/map.js'; + +let state = defaults(), busy = false, wasm; +const code = codeView(); +const activity = activityMonitor(() => catalogs.render()); +const overview = overviewMap(bbox => { + if (busy) return; + $('area').value = 'custom'; $('geometry').value = ''; + ['west', 'south', 'east', 'north'].forEach((id, i) => { $(id).value = bbox[i]; }); + tab('search'); updateCode(); +}, index => results.preview(index)); +const results = resultView(overview); +const catalogs = catalogEditor(() => state, changed, () => busy, activity.badge); +catalogDirectory(() => state.catalogs, catalogs.addFromIndex, () => busy); +function values() { + return { ...Object.fromEntries(['collections', 'area', 'from', 'to', 'limit', 'ids', 'sortby', 'geometry'].map(id => [id, $(id).value])), bbox: ['west', 'south', 'east', 'north'].map(id => $(id).value) }; +} +function updateCode() { + try { const query = queryFor(values()); overview.query(query); code.update(configFor(state), query); } + catch (error) { code.error(errorMessage(error)); } + $('copy-status').textContent = ''; + $('bbox-fields').hidden = $('area').value !== 'custom'; + const selected = state.catalogs.filter(c => c.enabled); + $('selected-catalogs').textContent = selected.length ? `Searching ${selected.map(c => c.name).join(' and ')}.` : 'No catalogs selected. Choose one in Catalogs.'; +} +function changed() { catalogs.render(); updateCode(); $('discovery').replaceChildren(); activity.log('Workspace updated', `${state.catalogs.length} catalogs · ${state.providers.length} providers`, { ms: 0 }); } +function tab(name, focus = false) { + controlsPanel(true); + document.querySelector('.controls-body').scrollTop = 0; + $('search-actions').hidden = name !== 'search'; + $('settings-actions').hidden = name !== 'settings'; + document.querySelectorAll('[data-tab]').forEach(b => { + const active = b.dataset.tab === name; b.setAttribute('aria-selected', String(active)); b.tabIndex = active ? 0 : -1; + $(`panel-${b.dataset.tab}`).hidden = !active; if (active && focus) b.focus(); + }); +} +$('browse-collections').addEventListener('click', () => { + if (busy) return; + tab('catalogs', true); $('discover').click(); +}); +document.addEventListener('playground:edit-search', () => tab('search', true)); +$('manage-catalogs').addEventListener('click', () => tab('catalogs', true)); +function controlsPanel(open, focus = false) { + $('workspace-controls').hidden = !open; + document.body.dataset.controls = open ? 'open' : 'closed'; + $('toggle-controls').setAttribute('aria-expanded', String(open)); + if (open && matchMedia('(max-width: 900px)').matches) results.collapse(); + if (focus) (open ? document.querySelector('[data-tab][aria-selected="true"]') : $('toggle-controls')).focus({ preventScroll: true }); +} +$('toggle-controls').addEventListener('click', () => { + if ($('workspace-controls').hidden) tab('search', true); + else controlsPanel(false, true); +}); +$('close-controls').addEventListener('click', () => controlsPanel(false, true)); +$('workspace-controls').addEventListener('keydown', event => { + if (event.key === 'Escape') { event.stopPropagation(); controlsPanel(false, true); } +}); +document.addEventListener('playground:preview', () => controlsPanel(false)); +document.addEventListener('playground:results-open', () => { + controlsPanel(false); +}); +const tabs = [...document.querySelectorAll('[data-tab]')]; +tabs.forEach((b, i) => { + b.addEventListener('click', () => tab(b.dataset.tab)); + b.addEventListener('keydown', event => { + const next = { ArrowRight: (i + 1) % tabs.length, ArrowLeft: (i + tabs.length - 1) % tabs.length, Home: 0, End: tabs.length - 1 }[event.key]; + if (next !== undefined) { event.preventDefault(); tab(tabs[next].dataset.tab, true); } + }); +}); +function lock(value) { + busy = value; + for (const id of ['search-button', 'apply-settings', 'reset-settings', 'browse-collections']) $(id).disabled = value; + $('query-controls').disabled = value; $('settings-controls').disabled = value; $('reset').disabled = value; + $('try-aerial').disabled = value; $('try-aliases').disabled = value; $('results').setAttribute('aria-busy', String(value)); catalogs.render(); +} +async function clientFor(config) { + if (!wasm) { + const module = await import('/superstac/wasm/superstac_wasm.js'); await module.default(); wasm = module; + } + return new wasm.SuperSTAC(config); +} +$('search-form').addEventListener('input', updateCode); +$('search-form').addEventListener('submit', async event => { + event.preventDefault(); if (busy) return; + let config, query; + try { config = configFor(state); query = queryFor(values()); $('validation').hidden = true; } + catch (error) { $('validation').textContent = errorMessage(error); $('validation').hidden = false; return; } + const snapshot = { unify: state.settings.unify_response, catalogs: structuredClone(state.catalogs), query, areaName: query.intersects ? 'Custom geometry' : AREAS[$('area').value]?.name ?? ($('area').value === 'custom' ? 'Custom area' : 'Anywhere') }; + activity.begin('Search', snapshot.catalogs.filter(c => c.enabled)); + controlsPanel(false); $('toggle-controls').focus({ preventScroll: true }); + lock(true); $('search-button').textContent = 'Searching…'; $('toggle-controls').textContent = 'Searching…'; $('status').textContent = 'Searching your selected catalogs…'; + let client; + try { + client = await clientFor(config); const response = await client.search(query); results.render(response, snapshot); activity.outcome(response, snapshot.catalogs.filter(c => c.enabled)); + $('toggle-results').focus({ preventScroll: true }); + activity.finish(`${response.items.length} scenes · ${response.metadata.catalogs_failed} catalogs failed`, response.metadata.catalogs_failed > 0); + $('status').textContent = response.metadata.catalogs_succeeded === 0 ? 'No catalogs could complete this search. See the details below.' : response.metadata.catalogs_failed ? 'Search finished. Some catalogs could not return results; see the details below.' : 'Search finished.'; + } catch (error) { activity.finish('Search could not complete. See the status message.', true); $('status').textContent = `The search could not finish. ${errorMessage(error)}`; } + finally { client?.free(); lock(false); $('search-button').textContent = 'Search scenes →'; $('toggle-controls').textContent = 'Find imagery'; } +}); +$('discover').addEventListener('click', async () => { + if (busy) return; let client, config; + try { config = configFor(state); } + catch (error) { $('discovery').textContent = errorMessage(error); return; } + activity.begin('Collection discovery', state.catalogs.filter(c => c.enabled)); + lock(true); $('discovery').textContent = 'Finding collections…'; + try { + client = await clientFor(config); const response = await client.listCollections(); + $('discovery').replaceChildren(); $('discovery').scrollIntoView({ block: 'nearest' }); const suggestions = new Set(); + for (const result of response) { + const group = el('details', undefined, 'collection-group'); group.open = response.length === 1; + group.append(el('summary', `${state.catalogs.find(c => c.id === result.catalog_id)?.name ?? result.catalog_id} · ${result.collections.length} collections`)); + if (result.error) group.append(el('p', result.error, 'error')); + const list = el('div', undefined, 'collection-list'); + for (const collection of result.collections) { + suggestions.add(collection.canonical_id); + const choose = button(collection.collection.title ?? collection.canonical_id, () => { + $('collections').value = collection.canonical_id; tab('search', true); updateCode(); + }, 'collection-button'); + choose.append(el('span', collection.canonical_id, 'hint')); list.append(choose); + } + group.append(list); $('discovery').append(group); + } + if (!$('discovery').children.length) $('discovery').textContent = 'No collections were found. Check the selected catalogs or add another catalog.'; + $('discovery').scrollIntoView({ block: 'nearest' }); + activity.finish(`${response.reduce((sum, r) => sum + r.collections.length, 0)} collections found`, response.some(r => r.error)); + $('collection-suggestions').replaceChildren(...[...suggestions].map(id => new Option(id, id))); + } catch (error) { activity.finish('Collection discovery failed', true); $('discovery').textContent = `Collections could not load. ${errorMessage(error)}`; } + finally { client?.free(); lock(false); } +}); +$('try-aerial').addEventListener('click', () => { + if (busy) return; + const url = 'https://api.imagery.hotosm.org/stac'; + state.catalogs.forEach(c => { c.enabled = false; }); + let catalog = state.catalogs.find(c => c.url.replace(/\/$/, '') === url); + if (!catalog) { + let id = 'hot-openaerialmap'; + while (state.catalogs.some(c => c.id === id)) id += '-example'; + catalog = { id, name: 'HOT OpenAerialMap', url, provider: '', collection_aliases: {}, asset_aliases: {} }; + state.catalogs.push(catalog); + } + catalog.enabled = true; + $('search-form').reset(); $('collections').value = ''; $('area').value = 'anywhere'; + $('from').value = ''; $('to').value = ''; $('sortby').value = ''; $('limit').value = '1'; + $('ids').value = '65cdcbd8-34f8-42d3-a758-1ebb69d3b149'; + changed(); tab('search', true); + $('status').textContent = 'Aerial example ready. Search, then choose Preview on map to explore the GeoTIFF.'; +}); +$('try-aliases').addEventListener('click', () => { + if (busy) return; + state.catalogs.forEach(c => { c.enabled = false; }); + for (const sample of defaults().catalogs) { + let catalog = state.catalogs.find(c => c.url === sample.url); + if (!catalog) { + while (state.catalogs.some(c => c.id === sample.id)) sample.id += '-example'; + catalog = sample; state.catalogs.push(catalog); + } + catalog.enabled = true; catalog.collection_aliases.optical = 'sentinel-2-l2a'; + catalog.asset_aliases.optical = { ...catalog.asset_aliases.optical, rgb: 'visual' }; + } + state.settings.unify_response = true; showSettings(); $('collections').value = 'optical'; + changed(); tab('search', true); + $('status').textContent = 'Alias example ready. Search for optical; returned images will use the name rgb.'; +}); +$('check-health').addEventListener('click', async () => { + if (busy) return; + const selected = state.catalogs.filter(c => c.enabled); + if (!selected.length) { $('discovery').textContent = 'Select a catalog to check its connection.'; return; } + lock(true); activity.begin('Connection check', selected); $('discovery').textContent = 'Checking selected catalogs…'; + try { await activity.check(selected); $('discovery').textContent = 'Connection checks finished. Each catalog shows its last response.'; } + finally { activity.finish('See each catalog’s last response'); lock(false); } +}); +function activityPanel(open) { + $('activity-panel').hidden = !open; $('open-activity').setAttribute('aria-expanded', String(open)); + if (open) $('close-activity').focus(); else $('open-activity').focus(); +} +$('open-activity').addEventListener('click', () => activityPanel($('activity-panel').hidden)); +$('close-activity').addEventListener('click', () => activityPanel(false)); +$('activity-panel').addEventListener('keydown', event => { if (event.key === 'Escape') activityPanel(false); }); +$('settings-form').addEventListener('input', () => { + $('settings-status').textContent = 'Unsaved changes. Apply settings to use them in searches and code.'; + $('apply-settings').textContent = 'Apply changes'; +}); +function showSettings() { + $('apply-settings').textContent = 'Apply settings'; + for (const [key, value] of Object.entries(state.settings)) { if (typeof value === 'boolean') $(key).checked = value; else $(key).value = value; } +} +$('settings-form').addEventListener('submit', event => { + event.preventDefault(); if (busy) return; + state.settings = Object.fromEntries(Object.entries(DEFAULT_SETTINGS).map(([key, value]) => [key, typeof value === 'boolean' ? $(key).checked : Number($(key).value)])); + $('apply-settings').textContent = 'Apply settings'; + updateCode(); activity.log('Settings applied', 'The next search will use these settings.', { ms: 0 }); $('settings-status').textContent = 'Settings applied to your next search.'; +}); +$('reset-settings').addEventListener('click', () => { + if (busy) return; state.settings = { ...DEFAULT_SETTINGS }; showSettings(); updateCode(); $('settings-status').textContent = 'Default settings restored.'; +}); +$('reset').addEventListener('click', () => { + if (busy) return; state = defaults(); $('search-form').reset(); showSettings(); changed(); results.clear(); + $('validation').hidden = true; $('settings-status').textContent = ''; $('status').textContent = 'Defaults restored. Ready to search.'; tab('search'); +}); +$('open-code').addEventListener('click', () => { + updateCode(); $('code-dialog').showModal(); $('copy').focus(); +}); +let theme; +try { theme = localStorage.getItem('superstac-playground-theme'); } catch { /* Storage may be unavailable. */ } +document.documentElement.dataset.theme = theme ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); +$('theme').addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.theme = next; + overview.theme(); + try { localStorage.setItem('superstac-playground-theme', next); } catch { /* The theme still applies to this page. */ } +}); +changed(); +overview.start(); diff --git a/docs/public/versions/v0.3/examples/clearer-scenes.py b/docs/public/versions/v0.3/examples/clearer-scenes.py new file mode 100644 index 0000000..b646315 --- /dev/null +++ b/docs/public/versions/v0.3/examples/clearer-scenes.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path +from superstac import Client + +area = { + "type": "Polygon", + "coordinates": [[[6, 45], [7, 45], [7, 46], [6, 46], [6, 45]]], +} +client = Client.open("https://earth-search.aws.element84.com/v1") +try: + result = client.search( + collections=["sentinel-2-l2a"], + intersects=area, + datetime="2024-06-01T00:00:00Z/2024-06-30T23:59:59Z", + sortby=["-datetime"], + limit=20, + ) + # Filter only the records returned by this search. + clearer = [ + item for item in result.items() + if isinstance(item["properties"].get("eo:cloud_cover"), (int, float)) + and item["properties"]["eo:cloud_cover"] <= 20 + ] + Path("clearer-scenes.geojson").write_text(json.dumps({ + "type": "FeatureCollection", + "features": clearer, + }, indent=2), encoding="utf-8") + print(f"Kept {len(clearer)} of {len(result)} returned records") + for failure in result.metadata["failures"]: + print("Catalog failed:", failure) + + # Look up a real ID from the results instead of guessing one. + if clearer: + item = clearer[0] + lookup = client.search( + collections=[item["collection"]], ids=[item["id"]], limit=1 + ) + for found in lookup.items(): + for name, asset in found.get("assets", {}).items(): + print(name, asset.get("type"), asset.get("href")) + print("Lookup failures:", lookup.metadata["failures"]) +finally: + client.shutdown() diff --git a/docs/public/versions/v0.3/examples/search.js b/docs/public/versions/v0.3/examples/search.js new file mode 100644 index 0000000..587a790 --- /dev/null +++ b/docs/public/versions/v0.3/examples/search.js @@ -0,0 +1,37 @@ +import init, { SuperSTAC } from '@spatialnode/superstac-wasm'; + +await init(); +const client = new SuperSTAC({ + catalogs: [ + { + id: 'earth-search', + url: 'https://earth-search.aws.element84.com/v1', + }, + { + id: 'microsoft', + url: 'https://planetarycomputer.microsoft.com/api/stac/v1', + }, + ], +}); + +try { + const result = await client.search({ + collections: ['sentinel-2-l2a'], + bbox: [6, 45, 7, 46], + datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z', + limit: 5, + }); + for (const { item, seen_in } of result.items) { + console.log(item.id, seen_in); + } + for (const failure of result.metadata.failures) { + console.warn(failure.catalog_id, failure.reason); + } + const geojson = { + type: 'FeatureCollection', + features: result.items.map(({ item }) => item), + }; + console.log(geojson); +} finally { + client.free(); +} diff --git a/docs/public/versions/v0.3/examples/search.py b/docs/public/versions/v0.3/examples/search.py new file mode 100644 index 0000000..bdb5d22 --- /dev/null +++ b/docs/public/versions/v0.3/examples/search.py @@ -0,0 +1,32 @@ +import json +from pathlib import Path +from superstac import Client + +catalogs = [ + { + "id": "earth-search", + "url": "https://earth-search.aws.element84.com/v1", + }, + { + "id": "microsoft", + "url": "https://planetarycomputer.microsoft.com/api/stac/v1", + }, +] +client = Client(catalogs=catalogs) +try: + result = client.search( + collections=["sentinel-2-l2a"], + bbox=[6, 45, 7, 46], + datetime="2024-06-01T00:00:00Z/2024-06-30T23:59:59Z", + limit=5, + ) + for item in result.items(): + print(item["id"], item["properties"].get("datetime")) + for failure in result.metadata["failures"]: + print("Catalog failed:", failure["catalog_id"], failure["reason"]) + Path("items.geojson").write_text( + json.dumps(result.to_geojson(), indent=2), encoding="utf-8" + ) + print(f"Saved {len(result)} scene records to items.geojson") +finally: + client.shutdown() diff --git a/docs/scripts/check-code-examples.mjs b/docs/scripts/check-code-examples.mjs new file mode 100644 index 0000000..8e6d9ac --- /dev/null +++ b/docs/scripts/check-code-examples.mjs @@ -0,0 +1,53 @@ +// Pure data checks: no browser, WASM build, or catalog requests. +import assert from 'node:assert/strict'; +import { codeFiles, codeTokens } from '../public/playground/modules/code-examples.js'; +import { configFor, defaults } from '../public/playground/modules/model.js'; + +const config = configFor(defaults()); +config.catalogs[0].collection_aliases = { optical: 'sentinel-2-l2a' }; +config.catalogs[0].asset_aliases = { 'sentinel-2-l2a': { rgb: 'visual' } }; +const query = { + collections: ['optical'], bbox: [6, 45, 7, 46], + datetime: '2024-06-01T00:00:00Z/2024-06-30T23:59:59Z', + limit: 5, sortby: ['-datetime'], ids: ['scene'], +}; +const original = JSON.stringify({ config, query }); +const names = { + JavaScript: ['search.js'], Python: ['search.py'], + Rust: ['src/main.rs', 'Cargo.toml', 'superstac.yml'], + CLI: ['search.sh', 'superstac.yml'], +}; +for (const [language, expected] of Object.entries(names)) { + const output = codeFiles(config, query, language); + assert.deepEqual(output.files.map(file => file.name), expected); + assert.ok(output.guide.startsWith('/superstac/docs/')); + for (const file of output.files) { + const tokens = codeTokens(file.content, file.syntax); + assert.equal(tokens.map(token => token.text).join(''), file.content); + assert.ok(tokens.some(token => token.kind)); + } + assert.match(output.files[0].content, /optical/); +} +assert.equal(JSON.stringify({ config, query }), original, 'Generating code must not change the search'); +const nativeConfig = codeFiles(config, query, 'Rust').files[2].content; +assert.equal(nativeConfig, codeFiles(config, query, 'CLI').files[1].content); +assert.match(nativeConfig, /"collection_aliases":/); +assert.match(nativeConfig, /"asset_aliases":/); +assert.match(nativeConfig, /"deduplicate_items": true/); +assert.match(codeFiles(config, query, 'JavaScript').files[0].content, /@spatialnode\/superstac-wasm/); +assert.match(codeFiles(config, query, 'Python').files[0].content, /"deduplicate_items": True/); +assert.match(codeFiles(config, query, 'CLI').files[0].content, /--sortby='-datetime'/); + +const geometry = { collections: [], intersects: { type: 'Point', coordinates: [6, 45] } }; +assert.throws(() => codeFiles(config, geometry, 'CLI'), /does not support GeoJSON/); +for (const language of ['JavaScript', 'Python', 'Rust']) { + assert.match(codeFiles(config, geometry, language).files[0].content, /Point/); +} +const unusual = { ...query, ids: ['it\'s "# a scene