From 3f6d26c028e3b369541a5bd40f4d54914defb36b Mon Sep 17 00:00:00 2001 From: mathleur Date: Thu, 12 Mar 2026 15:48:12 +0100 Subject: [PATCH 1/4] wasm server for stac catalogue --- Cargo.toml | 1 + qubed/Cargo.toml | 3 +- qubed/src/qube.rs | 21 + qubed_wasm/Cargo.toml | 18 + qubed_wasm/build.sh | 20 + qubed_wasm/src/lib.rs | 448 ++++++++++++++++++ stac_server/main.py | 654 +++++---------------------- stac_server/static/app.js | 26 +- stac_server/static/catalogue_wasm.js | 82 ++++ stac_server/templates/index.html | 6 + 10 files changed, 734 insertions(+), 545 deletions(-) create mode 100644 qubed_wasm/Cargo.toml create mode 100755 qubed_wasm/build.sh create mode 100644 qubed_wasm/src/lib.rs create mode 100644 stac_server/static/catalogue_wasm.js diff --git a/Cargo.toml b/Cargo.toml index 6abd00f..2f4eb16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,5 +6,6 @@ members = [ "qubed_meteo", "py_qubed", "py_qubed_meteo", + "qubed_wasm", ] diff --git a/qubed/Cargo.toml b/qubed/Cargo.toml index 41f2d80..ed9e500 100644 --- a/qubed/Cargo.toml +++ b/qubed/Cargo.toml @@ -14,7 +14,8 @@ smallbitvec = "2.6.0" tiny-str = "0.10.0" tiny-vec = "0.10.0" chrono = "0.4" -rayon = "1.7" +# rayon is not available on wasm32; gate it behind a feature flag +rayon = { version = "1.7", optional = true } [lib] path = "src/lib.rs" diff --git a/qubed/src/qube.rs b/qubed/src/qube.rs index 381d5a7..f65fbda 100644 --- a/qubed/src/qube.rs +++ b/qubed/src/qube.rs @@ -213,6 +213,27 @@ impl Qube { map } + /// Returns the dimension name of every leaf node (i.e. the "frontier" keys + /// that appear at the deepest traversed level, used by the WASM catalogue to + /// determine which key should be shown next when no explicit ordering is known). + pub fn leaf_dimensions(&self) -> Vec { + let paths = self.leaf_node_ids_paths(); + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for path in &paths { + if let Some(&leaf_id) = path.last() { + if let Some(node) = self.nodes.get(leaf_id) { + if let Some(dim_str) = self.dimension_str(&node.dim) { + if seen.insert(dim_str.to_string()) { + result.push(dim_str.to_string()); + } + } + } + } + } + result + } + pub fn remove_node(&mut self, id: NodeIdx) -> Result<(), String> { let node = self.nodes.remove(id).ok_or_else(|| format!("Node {:?} not found", id))?; diff --git a/qubed_wasm/Cargo.toml b/qubed_wasm/Cargo.toml new file mode 100644 index 0000000..952a869 --- /dev/null +++ b/qubed_wasm/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "qubed_wasm" +version = "0.1.0" +edition = "2024" +description = "WebAssembly bindings for the qubed catalogue browser" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +qubed = { path = "../qubed" } +wasm-bindgen = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true diff --git a/qubed_wasm/build.sh b/qubed_wasm/build.sh new file mode 100755 index 0000000..f61b808 --- /dev/null +++ b/qubed_wasm/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the WASM catalogue module and copy it to the static folder. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUT_DIR="$REPO_ROOT/stac_server/static/wasm" + +echo "Building qubed_wasm for wasm32-unknown-unknown …" +cd "$SCRIPT_DIR" +wasm-pack build \ + --target web \ + --release \ + --out-dir "$OUT_DIR" + +echo "" +echo "✓ Built to $OUT_DIR" +echo " qubed_wasm_bg.wasm : $(du -sh "$OUT_DIR/qubed_wasm_bg.wasm" | cut -f1)" +echo "" +echo "Restart your FastAPI server for changes to take effect." diff --git a/qubed_wasm/src/lib.rs b/qubed_wasm/src/lib.rs new file mode 100644 index 0000000..a7af500 --- /dev/null +++ b/qubed_wasm/src/lib.rs @@ -0,0 +1,448 @@ +/// qubed_wasm – WebAssembly catalogue browser +/// +/// This crate ports the catalogue-browsing logic from the Python FastAPI server +/// (`stac_server/main.py`) so that it can run client-side in a browser. +/// +/// The entry-point exposed to JavaScript is the `WasmCatalogue` class. +/// The host page should: +/// 1. Instantiate a `WasmCatalogue`. +/// 2. Fetch each data JSON file from the server and call `load()` / `append()`. +/// 3. Fetch `/api/v2/language` (JSON) and call `set_language()`. +/// 4. Call `stac(request_json)` in place of every `GET /api/v2/stac/` request. +use qubed::{Coordinates, Qube, select::SelectMode}; +use serde_json::{Value as JsonValue, json}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use wasm_bindgen::prelude::*; + +// --------------------------------------------------------------------------- +// Static key-ordering tables (ported from stac_server/key_ordering.py) +// --------------------------------------------------------------------------- + +fn climate_dt_keys() -> Vec<&'static str> { + vec![ + "class", + "dataset", + "activity", + "experiment", + "generation", + "model", + "realization", + "expver", + "stream", + "date", + "resolution", + "type", + "levtype", + "time", + "levelist", + "param", + ] +} + +fn extremes_dt_keys() -> Vec<&'static str> { + vec![ + "class", + "dataset", + "expver", + "stream", + "date", + "time", + "type", + "levtype", + "step", + "levelist", + "param", + "frequency", + "direction", + ] +} + +fn on_demands_dt_keys() -> Vec<&'static str> { + vec![ + "class", + "dataset", + "expver", + "stream", + "date", + "time", + "type", + "georef", + "levtype", + "step", + "number", + "levelist", + "param", + "frequency", + "direction", + "ident", + "instrument", + "channel", + ] +} + +fn default_keys() -> Vec<&'static str> { + vec![ + "class", + "dataset", + "stream", + "activity", + "resolution", + "expver", + "experiment", + "generation", + "model", + "realization", + "type", + "date", + "time", + "datetime", + "levtype", + "levelist", + "step", + "param", + ] +} + +fn dataset_key_orders() -> HashMap<&'static str, Vec<&'static str>> { + let mut m = HashMap::new(); + m.insert("climate-dt", climate_dt_keys()); + m.insert("extremes-dt", extremes_dt_keys()); + m.insert("on-demand-extremes-dt", on_demands_dt_keys()); + m.insert("default", default_keys()); + m +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// A single axis entry returned by `follow_query`. +struct AxisInfo { + key: String, + values: Vec, + on_frontier: bool, +} + +/// A parsed request: each key maps to one or more values. +type Request = BTreeMap>; + +/// Parse the JSON request fed from JavaScript. +/// +/// Accepts `{key: "value"}` or `{key: ["v1","v2"]}` or `{key: 123}`. +fn parse_request(json_str: &str) -> Result { + let v: JsonValue = serde_json::from_str(json_str).map_err(|e| format!("invalid JSON: {e}"))?; + let obj = v.as_object().ok_or("request must be a JSON object")?; + let mut out = BTreeMap::new(); + for (k, val) in obj { + let values = match val { + JsonValue::Array(arr) => { + arr.iter().map(|x| json_value_to_string(x)).collect::, _>>()? + } + other => vec![json_value_to_string(other)?], + }; + // Split comma-separated values (mirrors Python parse_request) + let values: Vec = values + .into_iter() + .flat_map(|v| v.split(',').map(|s| s.to_string()).collect::>()) + .collect(); + out.insert(k.clone(), values); + } + Ok(out) +} + +fn json_value_to_string(v: &JsonValue) -> Result { + match v { + JsonValue::String(s) => Ok(s.clone()), + JsonValue::Number(n) => Ok(n.to_string()), + JsonValue::Bool(b) => Ok(b.to_string()), + other => Err(format!("unsupported value type: {other}")), + } +} + +/// Encode a request as a URL query string (e.g. `class=d&dataset=climate-dt`). +fn request_to_query_string(req: &Request) -> String { + req.iter().map(|(k, vals)| format!("{}={}", k, vals.join(","))).collect::>().join("&") +} + +/// Convert a `Request` into the `Vec<(&str, Coordinates)>` expected by `Qube::select`. +fn request_to_selection(req: &Request) -> Vec<(String, Coordinates)> { + req.iter().map(|(k, vals)| (k.clone(), Coordinates::from_string(&vals.join("/")))).collect() +} + +/// Port of the Python `follow_query(request, qube)` function. +/// +/// Returns `(follow_selection_qube, axes)`. +fn follow_query( + request: &Request, + qube: &mut Qube, + language: &HashMap, + key_orders: &HashMap<&'static str, Vec<&'static str>>, +) -> (Qube, Vec) { + let selection_owned = request_to_selection(request); + let selection: Vec<(&str, Coordinates)> = + selection_owned.iter().map(|(k, c)| (k.as_str(), c.clone())).collect(); + + // --- 1. Full select: all data reachable after applying the request --- + let rel_qube = qube.select(&selection, SelectMode::Default).unwrap_or_else(|_| Qube::new()); + let full_axes: BTreeMap = { + let mut rq = rel_qube; // all_unique_dim_coords takes &mut self + rq.all_unique_dim_coords() + }; + + // --- 2. Follow-selection: tree only up to where the selection ends --- + let mut s = + qube.select(&selection, SelectMode::FollowSelection).unwrap_or_else(|_| Qube::new()); + s.compress(); + + let seen_keys: HashSet<&str> = request.keys().map(|k| k.as_str()).collect(); + + // --- 3. Determine key ordering --- + let dataset_key_ordering: Option> = if let Some(dataset_vals) = + request.get("dataset") + { + let ds_name = if dataset_vals.len() == 1 { dataset_vals[0].as_str() } else { "default" }; + let ordering = key_orders.get(ds_name).or_else(|| key_orders.get("default")); + ordering.map(|keys| keys.iter().map(|k| k.to_string()).collect()) + } else { + None + }; + + // --- 4. Available keys (un-selected keys in the ordering, or leaf dims) --- + let full_axes_key_set: HashSet<&str> = full_axes.keys().map(|k| k.as_str()).collect(); + + let available_keys: Vec = if let Some(ref ordering) = dataset_key_ordering { + ordering.iter().filter(|k| full_axes_key_set.contains(k.as_str())).cloned().collect() + } else { + s.leaf_dimensions() + }; + + // --- 5. Frontier: the first available key that hasn't been seen yet --- + let frontier_key: Option = + available_keys.iter().find(|k| !seen_keys.contains(k.as_str())).cloned(); + + // --- 6. Build return axes --- + let axes: Vec = full_axes + .iter() + .map(|(key, coords)| { + let on_frontier = + frontier_key.as_deref() == Some(key.as_str()) && !seen_keys.contains(key.as_str()); + + let coord_str = coords.to_string(); + let mut values: Vec = if coord_str.is_empty() { + vec![] + } else { + coord_str.split('/').map(|s| s.to_string()).collect() + }; + + // Sort numerically if all values are integers, otherwise lexicographically + if values.iter().all(|v| v.parse::().is_ok()) { + values.sort_by_key(|v| v.parse::().unwrap_or(0)); + } else { + values.sort(); + } + + AxisInfo { key: key.clone(), values, on_frontier } + }) + .collect(); + + // Ensure language descriptions are available for axes keys not in the request + let _ = language; // consumed in stac() where descriptions are built + + (s, axes) +} + +/// Build the STAC link object for one axis (mirrors Python `make_link`). +fn make_link_json( + axis: &AxisInfo, + request_params: &str, + language: &HashMap, +) -> JsonValue { + let key_name = &axis.key; + let href_template = format!( + "/stac?{}{}{key_name}={{{key_name}}}", + request_params, + if request_params.is_empty() { "" } else { "&" }, + ); + + let empty_obj = json!({}); + let lang_entry = language.get(key_name.as_str()).unwrap_or(&empty_obj); + let description = + lang_entry.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(); + + let values_from_language: HashMap = lang_entry + .get("values") + .and_then(|v| v.as_object()) + .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + + let value_descriptions: serde_json::Map = axis + .values + .iter() + .filter_map(|v| values_from_language.get(v).map(|desc| (v.clone(), desc.clone()))) + .collect(); + + json!({ + "title": key_name, + "uriTemplate": href_template, + "rel": "child", + "type": "application/json", + "variables": { + key_name: { + "description": description, + "enum": axis.values, + "value_descriptions": value_descriptions, + "on_frontier": axis.on_frontier, + } + } + }) +} + +// --------------------------------------------------------------------------- +// Public WASM class +// --------------------------------------------------------------------------- + +#[wasm_bindgen] +pub struct WasmCatalogue { + qube: Qube, + /// Mars language: key → { description, values: { value → description } } + language: HashMap, + key_orders: HashMap<&'static str, Vec<&'static str>>, +} + +#[wasm_bindgen] +impl WasmCatalogue { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + WasmCatalogue { + qube: Qube::new(), + language: HashMap::new(), + key_orders: dataset_key_orders(), + } + } + + /// Load the first data file (arena JSON string). Replaces any previous data. + pub fn load(&mut self, arena_json: &str) -> Result<(), JsValue> { + let v: JsonValue = serde_json::from_str(arena_json) + .map_err(|e| JsValue::from_str(&format!("JSON parse error: {e}")))?; + self.qube = Qube::from_arena_json(v) + .map_err(|e| JsValue::from_str(&format!("Qube load error: {e}")))?; + Ok(()) + } + + /// Append an additional data file (arena JSON string) into the catalogue. + pub fn append(&mut self, arena_json: &str) -> Result<(), JsValue> { + let v: JsonValue = serde_json::from_str(arena_json) + .map_err(|e| JsValue::from_str(&format!("JSON parse error: {e}")))?; + let mut other = Qube::from_arena_json(v) + .map_err(|e| JsValue::from_str(&format!("Qube load error: {e}")))?; + self.qube.append(&mut other); + Ok(()) + } + + /// Provide the MARS language metadata as a JSON string. + /// + /// Expected format (mirrors YAML structure from `config/language/language.yaml`): + /// ```json + /// { "class": { "description": "...", "values": { "d": "Destination Earth", ... } }, ... } + /// ``` + pub fn set_language(&mut self, language_json: &str) -> Result<(), JsValue> { + let v: JsonValue = serde_json::from_str(language_json) + .map_err(|e| JsValue::from_str(&format!("JSON parse error: {e}")))?; + self.language = v + .as_object() + .ok_or_else(|| JsValue::from_str("language must be a JSON object"))? + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + Ok(()) + } + + /// Returns `true` if the catalogue contains no data. + pub fn is_empty(&self) -> bool { + self.qube.is_empty() + } + + /// Equivalent to `GET /api/v2/stac/?`. + /// + /// `request_json` – a JSON object where each key maps to a single value string + /// or an array of value strings, e.g. `{"class":"d","date":["20200101","20200102"]}`. + /// + /// Returns a JSON string containing the full STAC response (same schema as the + /// Python server); the caller can `JSON.parse()` it. + pub fn stac(&mut self, request_json: &str) -> Result { + let request = parse_request(request_json) + .map_err(|e| JsValue::from_str(&format!("request parse error: {e}")))?; + + let (q, axes) = follow_query(&request, &mut self.qube, &self.language, &self.key_orders); + + let end_of_traversal = !axes.iter().any(|a| a.on_frontier); + + // --- Final objects (datacubes at end of traversal) --- + let final_object: Vec = if end_of_traversal { + q.to_datacubes() + .iter() + .map(|dc| { + let obj: serde_json::Map = dc + .coordinates() + .iter() + .map(|(dim, coords)| (dim.clone(), json!(coords.to_string()))) + .collect(); + json!(obj) + }) + .collect() + } else { + vec![] + }; + + // --- Build links --- + let request_params = request_to_query_string(&request); + let links: Vec = + axes.iter().map(|axis| make_link_json(axis, &request_params, &self.language)).collect(); + + // --- Build descriptions (for renderRequestBreakdown in app.js) --- + let all_keys: HashSet<&str> = + axes.iter().map(|a| a.key.as_str()).chain(request.keys().map(|k| k.as_str())).collect(); + + let mut descriptions = serde_json::Map::new(); + for key in all_keys { + let vals = request.get(key).cloned().unwrap_or_default(); + let lang = self.language.get(key); + let description = lang + .and_then(|l| l.get("description")) + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + let value_descriptions = + lang.and_then(|l| l.get("values")).cloned().unwrap_or_else(|| json!({})); + descriptions.insert( + key.to_string(), + json!({ + "key": key, + "values": vals, + "description": description, + "value_descriptions": value_descriptions, + }), + ); + } + + // --- Assemble response --- + let id = + if request.is_empty() { "root".to_string() } else { format!("/stac?{request_params}") }; + + let response = json!({ + "type": "Catalog", + "stac_version": "1.0.0", + "id": id, + "description": "STAC collection representing potential children of this request", + "links": links, + "final_object": final_object, + "debug": { + "descriptions": descriptions, + "qube": q.to_ascii(), + } + }); + + serde_json::to_string(&response) + .map_err(|e| JsValue::from_str(&format!("serialisation error: {e}"))) + } +} diff --git a/stac_server/main.py b/stac_server/main.py index 21f2299..15f6b4e 100644 --- a/stac_server/main.py +++ b/stac_server/main.py @@ -1,34 +1,28 @@ from .key_ordering import dataset_key_orders -import base64 import json import logging import os -import subprocess -import sys -from io import BytesIO, StringIO from pathlib import Path from typing import Mapping import yaml from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.responses import FileResponse, HTMLResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from pydantic import BaseModel from qubed import PyQube -# from qubed.formatters import node_tree_to_html logger = logging.getLogger("uvicorn.error") log_level = os.environ.get("LOG_LEVEL", "INFO").upper() if log_level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: logger.setLevel(log_level) - logger.info(f"Set log level to {log_level}") else: logger.warning(f"Invalid LOG_LEVEL {log_level}, defaulting to INFO") logger.setLevel(logging.INFO) -# load yaml config from configmap or default path + +# Load yaml config from configmap or default path config_path = os.environ.get( "CONFIG_PATH", f"{Path(__file__).parents[1]}/config/config.yaml" ) @@ -62,55 +56,13 @@ allow_headers=["*"], ) - -@app.on_event("startup") -async def startup_event(): - """Install required packages on startup.""" - required_packages = [ - "covjsonkit", - "earthkit-plots", - "xarray", - "matplotlib", - "numpy", - ] - logger.info("Checking and installing required packages on startup...") - - for package in required_packages: - try: - # Try to import to check if already installed - __import__(package.replace("-", "_")) - logger.info(f"{package} is already installed") - except ImportError: - logger.info(f"Installing {package}...") - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", package], - capture_output=True, - text=True, - timeout=120, - ) - if result.returncode == 0: - logger.info(f"Successfully installed {package}") - else: - logger.warning(f"Failed to install {package}: {result.stderr}") - except Exception as e: - logger.warning(f"Error installing {package}: {e}") - - logger.info("Package installation check complete") - - app.mount( "/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static" ) templates = Jinja2Templates(directory=Path(__file__).parent / "templates") -# qube = Qube.empty() +# Load qube data qube = PyQube() -mars_language = {} - -print("HERE WHAT") -print(config.get("data_files", [])) - for i, data_file in enumerate(config.get("data_files", [])): data_path = prefix / data_file if not data_path.exists(): @@ -118,32 +70,18 @@ async def startup_event(): continue logger.info(f"Loading data from {data_path}") with open(data_path, "r") as f: - # PyQube.from_arena_json expects a JSON string, not a Python dict new_qube = PyQube.from_arena_json(json.dumps(json.load(f))) - print(new_qube.to_ascii()) - - print("WHAT IS i") - print(i) - - if i==0: - print("WENT HERE??") + if i == 0: qube = new_qube - print(qube.to_ascii()) - logger.info(f"Initialized qube from {data_path}") else: - print("WHAT DID WE DO HERE??") qube.append(new_qube) - print(qube.to_ascii()) - logger.info(f"Appended data from {data_path}") logger.info(f"Loaded {data_path}. Now have {len(qube)} nodes.") -print("WHAT'S THE FINAL QUBE???") -print(qube.to_ascii()) - +# Load MARS language metadata +mars_language = {} with open(Path(__file__).parents[1] / "config/language/language.yaml", "r") as f: mars_language = yaml.safe_load(f) - logger.info("Ready to serve requests!") @@ -152,21 +90,15 @@ async def get_body_json(request: Request): def parse_request(request: Request) -> dict[str, str | list[str]]: - # Convert query parameters to dictionary format request_dict = dict(request.query_params) for key, value in request_dict.items(): - # Convert comma-separated values into lists if "," in value: request_dict[key] = value.split(",") - return request_dict def validate_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)): - logger.info( - f"Validating API key: {credentials.scheme} {credentials.credentials}, correct key is {api_key.strip()}" - ) - if credentials.credentials != api_key.strip(): + if credentials.credentials != api_key: raise HTTPException(status_code=403, detail="Incorrect API Key") return credentials @@ -183,308 +115,114 @@ async def deprecated(): @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): - index_config = { + return templates.TemplateResponse(request, "landing.html", { "title": os.environ.get("TITLE", "Qubed Catalogue Browser"), - } - - return templates.TemplateResponse(request, "landing.html", index_config) + }) @app.get("/browse", response_class=HTMLResponse) async def browse_catalogue(request: Request): - index_config = { + return templates.TemplateResponse(request, "index.html", { "api_url": os.environ.get("API_URL", "/api/v2/"), "title": os.environ.get("TITLE", "Qubed Catalogue Browser"), "message": "", "last_database_update": "", - } + }) - return templates.TemplateResponse(request, "index.html", index_config) +# --------------------------------------------------------------------------- +# WASM support endpoints – let the browser load catalogue data directly +# --------------------------------------------------------------------------- -@app.get("/api/v2/get/") -async def get( - request: dict[str, str | list[str]] = Depends(parse_request), -): - return qube.to_json() +@app.get("/api/v2/data_files") +async def get_data_files(): + """Return a list of URLs the WASM client can fetch to load the catalogue data.""" + return [ + f"/api/v2/arena_json/{data_file}" + for data_file in config.get("data_files", []) + if (prefix / data_file).exists() + ] + + +@app.get("/api/v2/arena_json/{file_path:path}") +async def get_arena_json(file_path: str): + """Serve a single catalogue data file as arena JSON for the WASM client.""" + data_path = prefix / file_path + if not data_path.exists(): + raise HTTPException(status_code=404, detail=f"Data file {file_path} not found") + with open(data_path, "r") as f: + return json.load(f) +@app.get("/api/v2/language") +async def get_language(): + """Return MARS language metadata as JSON for the WASM client.""" + return mars_language + + +# --------------------------------------------------------------------------- +# Admin endpoint – push new/updated data into the running catalogue +# --------------------------------------------------------------------------- + @app.post("/api/v2/union/") async def union( credentials: HTTPAuthorizationCredentials = Depends(validate_api_key), body_json=Depends(get_body_json), ): global qube - # body_json is a parsed dict; pass a JSON string to the Rust binding - qube = qube | PyQube.from_arena_json(json.dumps(body_json)) - return qube.to_json() - - -@app.post("/api/v2/polytope/query") -async def query_polytope( - body_json=Depends(get_body_json), -): - """ - Query the Destination Earth Polytope data extraction service with MARS requests. - Expects a JSON body with: - - 'requests': array of MARS request objects - - 'credentials': object with 'user_email' and 'user_key' fields - - Connects to: polytope.lumi.apps.dte.destination-earth.eu - Collection: destination-earth - """ - try: - import earthkit.data - except ImportError: - raise HTTPException( - status_code=500, - detail="earthkit.data is not installed. Please install it with 'pip install earthkit-data'", - ) - - requests = body_json.get("requests", []) - if not requests: - raise HTTPException(status_code=400, detail="No requests provided") - - # Get credentials from request body - credentials = body_json.get("credentials", {}) - user_email = credentials.get("user_email") - user_key = credentials.get("user_key") - - if not user_email or not user_key: - raise HTTPException( - status_code=400, - detail="Credentials required: provide user_email and user_key", - ) - - # Prepare kwargs for polytope connection - polytope_kwargs = { - "stream": False, - "address": "polytope.lumi.apps.dte.destination-earth.eu", - "user_email": user_email, - "user_key": user_key, - } + qube.append(PyQube.from_arena_json(json.dumps(body_json))) + return {"nodes": len(qube)} - logger.info(f"Querying Polytope with user email: {user_email}") - - results = [] - successful = 0 - failed = 0 - - for idx, mars_request in enumerate(requests): - try: - logger.info(f"Querying Polytope for request {idx + 1}/{len(requests)}") - logger.debug(f"Request: {mars_request}") - - # Query Polytope service - ds = earthkit.data.from_source( - "polytope", "destination-earth", mars_request, **polytope_kwargs - ) - - # Get JSON representation of the data - try: - ds_json = ds._json() - logger.info(f"Successfully extracted JSON from request {idx + 1}") - except Exception as json_error: - logger.warning( - f"Could not extract JSON from request {idx + 1}: {json_error}" - ) - ds_json = None - - # Get some basic info about the result - data_info = ( - f"Retrieved {len(ds)} fields" - if hasattr(ds, "__len__") - else "Data retrieved" - ) - - result_entry = { - "success": True, - "request_index": idx, - "message": data_info, - "data_size": str(len(ds)) if hasattr(ds, "__len__") else None, - "mars_request": mars_request, - } - - # Add JSON data if available - if ds_json is not None: - result_entry["json_data"] = ds_json - - results.append(result_entry) - successful += 1 - logger.info(f"Request {idx + 1} successful: {data_info}") - - except Exception as e: - error_msg = str(e) - logger.error(f"Request {idx + 1} failed: {error_msg}") - results.append( - { - "success": False, - "request_index": idx, - "error": error_msg, - "mars_request": mars_request, - } - ) - failed += 1 - - return { - "total": len(requests), - "successful": successful, - "failed": failed, - "results": results, - } +# --------------------------------------------------------------------------- +# Catalogue query endpoints (server-side fallback for WASM) +# --------------------------------------------------------------------------- def follow_query(request: dict[str, str | list[str]], qube: PyQube): - # TODO: implement a selection mode that only shows the pruned tree with the selected request keys rel_qube = qube.select(request, None, None) - print("WHAT IS THE REQUEST HERE??") - print(request) - print("WHAT IS THE REL_QUBE HERE??") - print(rel_qube.to_ascii()) - print("WHAT WAS THE ORIGINAL QUBE HERE??") - print(qube.to_ascii()) - - # full_axes = rel_qube.axes_info() full_axes = rel_qube.all_unique_dim_coords() seen_keys = list(request.keys()) - dataset_key_ordering = None - # Also compute the selected tree just to the point where our selection ends s = qube.select(request, "follow_selection", None) s.compress() - # print("WHAT IS THE QUBE HERE") - # print("LOOK NOW HERE") - - # print(s.to_ascii()) if seen_keys and "dataset" in seen_keys: - if ( - not isinstance(request["dataset"], list) - and request["dataset"] in dataset_key_orders.keys() - ): - dataset_key_ordering = dataset_key_orders[request["dataset"]] - elif isinstance(request["dataset"], list) and len(request["dataset"]) == 1: - dataset_key_ordering = dataset_key_orders[request["dataset"][0]] - else: - dataset_key_ordering = dataset_key_orders["default"] + ds = request["dataset"] + ds_name = ds if not isinstance(ds, list) else (ds[0] if len(ds) == 1 else None) + dataset_key_ordering = dataset_key_orders.get(ds_name) or dataset_key_orders["default"] if dataset_key_ordering is None: available_keys = {node.key for _, node in s.leaf_nodes()} else: - available_keys = [ - key for key in dataset_key_ordering if key in list(full_axes.keys()) - ] + available_keys = [key for key in dataset_key_ordering if key in full_axes] frontier_keys = next((x for x in available_keys if x not in seen_keys), []) return_axes = [] for key, info in full_axes.items(): - return_axes_key = { + entry = { "key": key, - # "dtype": list(info.dtypes)[0], "on_frontier": (key in frontier_keys) and (key not in seen_keys), } - # print("WHAT IS INFO HERE") - # print(info) - # print(full_axes) - if isinstance(list(info)[0], str): - try: - int(list(info)[0]) - sorted_vals = sorted(info, key=int) - except ValueError: - sorted_vals = sorted(info) - else: - sorted_vals = sorted(info) - return_axes_key["values"] = sorted_vals - return_axes.append(return_axes_key) + vals = list(info) + try: + sorted_vals = sorted(vals, key=int) + except (ValueError, TypeError): + sorted_vals = sorted(vals) + entry["values"] = sorted_vals + return_axes.append(entry) return s, return_axes -@app.get("/api/v2/select/") -async def select( - request: Mapping[str, str | list[str]] = Depends(parse_request), -): - return qube.select(request).to_json() - - -@app.get("/api/v2/query") -async def query( - request: dict[str, str | list[str]] = Depends(parse_request), -): - _, paths = follow_query(request, qube) - return paths - - -@app.get("/api/v2/basicstac/{filters:path}") -async def basic_stac(filters: str): - pairs = filters.strip("/").split("/") - request = dict(p.split("=") for p in pairs if "=" in p) - - q, _ = follow_query(request, qube) - - def make_link(child_request): - """Take a MARS Key and information about which paths matched up to this point and use it to make a STAC Link""" - kvs = [f"{key}={value}" for key, value in child_request.items()] - href = f"/api/v2/basicstac/{'/'.join(kvs)}" - last_key, last_value = list(child_request.items())[-1] - - return { - "title": f"{last_key}={last_value}", - "href": href, - "rel": "child", - "type": "application/json", - } - - # Format the response as a STAC collection - (this_key, this_value), *_ = ( - list(request.items())[-1] if request else ("root", "root"), - None, - ) - key_info = mars_language.get(this_key, {}) - try: - values_info = dict(key_info.get("values", {})) - value_info = values_info.get( - this_value, f"No info found for value `{this_value}` found." - ) - except ValueError: - value_info = f"No info found for value `{this_value}` found." - - if this_key == "root": - value_info = "The root node" - # key_desc = key_info.get( - # "description", f"No description for `key` {this_key} found." - # ) - logger.info(f"{this_key}, {this_value}") - stac_collection = { - "type": "Catalog", - "stac_version": "1.0.0", - "id": "root" - if not request - else "/".join(f"{k}={v}" for k, v in request.items()), - "title": f"{this_key}={this_value}", - "description": value_info, - "links": [make_link(leaf) for leaf in q.leaves()], - } - - return stac_collection - - def make_link(axis, request_params): - """Take a MARS Key and information about which paths matched up to this point and use it to make a STAC Link""" key_name = axis["key"] - - href_template = f"/stac?{request_params}{'&' if request_params else ''}{key_name}={{{key_name}}}" - - values_from_language_yaml = mars_language.get(key_name, {}).get("values", {}) - value_descriptions = { - v: values_from_language_yaml[v] - for v in axis["values"] - if v in values_from_language_yaml - } - + href_template = ( + f"/stac?{request_params}{'&' if request_params else ''}{key_name}={{{key_name}}}" + ) + values_from_language = mars_language.get(key_name, {}).get("values", {}) return { "title": key_name, "uriTemplate": href_template, @@ -492,10 +230,13 @@ def make_link(axis, request_params): "type": "application/json", "variables": { key_name: { - # "type": axis["dtype"], "description": mars_language.get(key_name, {}).get("description", ""), "enum": axis["values"], - "value_descriptions": value_descriptions, + "value_descriptions": { + v: values_from_language[v] + for v in axis["values"] + if v in values_from_language + }, "on_frontier": axis["on_frontier"], } }, @@ -507,21 +248,9 @@ async def get_STAC( request: dict[str, str | list[str]] = Depends(parse_request), ): q, axes = follow_query(request, qube) - print("WHAT IS THE QUBE HERE") - print(qube) - print(request) - print(q) end_of_traversal = not any(a["on_frontier"] for a in axes) - - final_object = [] - - print("ARE WE AT THE END OF THE TRAVERSAL??") - print(end_of_traversal) - if end_of_traversal: - final_object = list(q.to_datacubes()) - print("WHAT IS THE FINAL OBJECT??") - print(final_object) + final_object = list(q.to_datacubes()) if end_of_traversal else [] kvs = [ f"{k}={','.join(v)}" if isinstance(v, list) else f"{k}={v}" @@ -529,33 +258,18 @@ async def get_STAC( ] request_params = "&".join(kvs) - # Get all possible keys from axes to ensure complete descriptions - all_axes_keys = {axis["key"] for axis in axes} - request_keys = set(request.keys()) - all_description_keys = all_axes_keys | request_keys - + all_keys = {a["key"] for a in axes} | set(request.keys()) descriptions = { key: { "key": key, - "values": values if isinstance(values, list) else [values] if isinstance(values, str) else [], + "values": request.get(key, []) if isinstance(request.get(key), list) else ([request[key]] if key in request else []), "description": mars_language.get(key, {}).get("description", ""), "value_descriptions": mars_language.get(key, {}).get("values", {}), } - for key, values in request.items() + for key in all_keys } - - # Add descriptions for axes keys that might not be in request - for key in all_description_keys: - if key not in descriptions: - descriptions[key] = { - "key": key, - "values": [], - "description": mars_language.get(key, {}).get("description", ""), - "value_descriptions": mars_language.get(key, {}).get("values", {}), - } - # Format the response as a STAC collection - stac_collection = { + return { "type": "Catalog", "stac_version": "1.0.0", "id": "root" if not request else "/stac?" + request_params, @@ -564,198 +278,54 @@ async def get_STAC( "final_object": final_object, "debug": { "descriptions": descriptions, - # "qube": node_tree_to_html( - # q, - # collapse=True, - # depth=10, - # include_css=False, - # include_js=False, - # max_summary_length=200, - # css_id="qube", - # ), "qube": q.to_ascii(), }, } - return stac_collection +@app.get("/api/v2/select/") +async def select( + request: Mapping[str, str | list[str]] = Depends(parse_request), +): + return qube.select(request).to_json() -# Pydantic models for notebook execution -class ExecuteRequest(BaseModel): - code: str - data: dict | None = None +@app.get("/api/v2/query") +async def query( + request: dict[str, str | list[str]] = Depends(parse_request), +): + _, paths = follow_query(request, qube) + return paths -class InstallPackageRequest(BaseModel): - packages: str # Space or comma-separated package names +@app.get("/api/v2/basicstac/{filters:path}") +async def basic_stac(filters: str): + pairs = filters.strip("/").split("/") + request = dict(p.split("=") for p in pairs if "=" in p) -@app.post("/api/v2/execute") -async def execute_code(request: ExecuteRequest): - """ - Execute Python code on the server with optional data context. - Allows installation of any Python package, including C extensions. - Captures matplotlib figures and returns them as base64 images. - """ - try: - # Create a namespace with the data available - namespace = {} - if request.data: - namespace["polytope_data"] = request.data + q, _ = follow_query(request, qube) - # Capture stdout and stderr - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = StringIO() - sys.stderr = StringIO() + def _make_link(child_request): + kvs = [f"{k}={v}" for k, v in child_request.items()] + last_key, last_value = list(child_request.items())[-1] + return { + "title": f"{last_key}={last_value}", + "href": f"/api/v2/basicstac/{'/'.join(kvs)}", + "rel": "child", + "type": "application/json", + } - images = [] + this_key, this_value = list(request.items())[-1] if request else ("root", "root") + key_info = mars_language.get(this_key, {}) + value_info = key_info.get("values", {}).get(this_value, f"No info found for `{this_value}`.") + if this_key == "root": + value_info = "The root node" - try: - # Set matplotlib to non-interactive backend before execution - try: - import matplotlib - - matplotlib.use("Agg") # Non-interactive backend - except ImportError: - pass - - # Execute the code - exec(request.code, namespace) - - # Capture matplotlib figures if any were created - try: - import matplotlib.pyplot as plt - - figures = [plt.figure(num) for num in plt.get_fignums()] - - for fig in figures: - # Save figure to bytes - buf = BytesIO() - fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") - buf.seek(0) - - # Convert to base64 - img_base64 = base64.b64encode(buf.read()).decode("utf-8") - images.append(img_base64) - - # Close the figure - plt.close(fig) - except ImportError: - # matplotlib not available, skip figure capture - pass - except Exception as fig_error: - # Log but don't fail if figure capture fails - sys.stderr.write(f"\nWarning: Could not capture figures: {fig_error}\n") - - # Get the output - stdout_output = sys.stdout.getvalue() - stderr_output = sys.stderr.getvalue() - - return JSONResponse( - { - "success": True, - "stdout": stdout_output, - "stderr": stderr_output, - "images": images, - } - ) - finally: - # Restore stdout and stderr - sys.stdout = old_stdout - sys.stderr = old_stderr - - except Exception as e: - return JSONResponse( - { - "success": False, - "error": str(e), - "error_type": type(e).__name__, - }, - status_code=400, - ) - - -@app.post("/api/v2/install_packages") -async def install_packages(request: InstallPackageRequest): - """ - Install Python packages using pip in the server environment. - """ - try: - # Split packages by space or comma - packages = [ - pkg.strip() - for pkg in request.packages.replace(",", " ").split() - if pkg.strip() - ] - - if not packages: - return JSONResponse( - { - "success": False, - "error": "No packages specified", - }, - status_code=400, - ) - - results = [] - for package in packages: - try: - # Run pip install - result = subprocess.run( - [sys.executable, "-m", "pip", "install", package], - capture_output=True, - text=True, - timeout=120, # 2 minute timeout per package - ) - - if result.returncode == 0: - results.append( - { - "package": package, - "success": True, - "message": f"Successfully installed {package}", - } - ) - else: - results.append( - { - "package": package, - "success": False, - "error": result.stderr, - } - ) - except subprocess.TimeoutExpired: - results.append( - { - "package": package, - "success": False, - "error": "Installation timed out after 120 seconds", - } - ) - except Exception as e: - results.append( - { - "package": package, - "success": False, - "error": str(e), - } - ) - - all_success = all(r["success"] for r in results) - - return JSONResponse( - { - "success": all_success, - "results": results, - } - ) - - except Exception as e: - return JSONResponse( - { - "success": False, - "error": str(e), - }, - status_code=500, - ) \ No newline at end of file + return { + "type": "Catalog", + "stac_version": "1.0.0", + "id": "root" if not request else "/".join(f"{k}={v}" for k, v in request.items()), + "title": f"{this_key}={this_value}", + "description": value_info, + "links": [_make_link(leaf) for leaf in q.leaves()], + } diff --git a/stac_server/static/app.js b/stac_server/static/app.js index c151f8a..01dd5fc 100644 --- a/stac_server/static/app.js +++ b/stac_server/static/app.js @@ -630,8 +630,17 @@ function renderRawSTACResponse(catalog) { // Fetch STAC catalog and display items async function fetchCatalog(request, stacUrl) { try { - const response = await fetch(stacUrl); - const catalog = await response.json(); + let catalog; + if (window.__wasmCatalogue) { + // Use the client-side Rust/WASM catalogue — no network round-trip needed. + // `request` is an ordered array of [key, [value, ...]] pairs. + const reqObj = Object.fromEntries(request); + catalog = JSON.parse(window.__wasmCatalogue.stac(JSON.stringify(reqObj))); + console.log("[wasm] WASM stac() returned catalog:", catalog); + } else { + const response = await fetch(stacUrl); + catalog = await response.json(); + } console.log("Fetched catalog:", catalog); @@ -1335,8 +1344,21 @@ function closeNotebook() { } // Call initializeViewer on page load +// Also expose it globally so catalogue_wasm.js can re-trigger it after the +// WASM catalogue finishes loading asynchronously. +window.initializeViewer = initializeViewer; initializeViewer(); +// Show server-side badge after a short delay (WASM will override if it loads) +setTimeout(() => { + const badge = document.getElementById("wasm-status"); + if (badge && !window.__wasmCatalogue) { + badge.textContent = "🌐 Server"; + badge.style.background = "#cce5ff"; + badge.style.color = "#004085"; + } +}, 1000); + // Add event listener for copy button document.addEventListener("DOMContentLoaded", () => { const copyBtn = document.getElementById("copy-mars-btn"); diff --git a/stac_server/static/catalogue_wasm.js b/stac_server/static/catalogue_wasm.js new file mode 100644 index 0000000..75aa816 --- /dev/null +++ b/stac_server/static/catalogue_wasm.js @@ -0,0 +1,82 @@ +/** + * catalogue_wasm.js + * + * Loads the Rust/WebAssembly catalogue module and makes it available to + * app.js via `window.__wasmCatalogue`. + * + * app.js's fetchCatalog() checks for window.__wasmCatalogue before doing a + * network fetch; if it exists the WASM stac() method is called instead, + * giving zero-latency, server-independent catalogue browsing. + * + * Graceful degradation: if anything fails the window.__wasmCatalogue is left + * unset and app.js falls back to the server-side /api/v2/stac/ endpoint. + */ + +import init, { WasmCatalogue } from "/static/wasm/qubed_wasm.js"; + +async function initWasm() { + try { + // 1. Initialise the WASM binary + await init(); + + const catalogue = new WasmCatalogue(); + + // 2. Ask the server which data files to load + const metaResp = await fetch("/api/v2/data_files"); + if (!metaResp.ok) { + console.warn("[wasm] /api/v2/data_files returned", metaResp.status, "— falling back to server-side STAC"); + return; + } + const dataFiles = await metaResp.json(); // array of URL strings + console.log("[wasm] Data files:", dataFiles); + + // 3. Load each data file into the catalogue + let first = true; + for (const url of dataFiles) { + const resp = await fetch(url); + if (!resp.ok) { + console.warn(`[wasm] Could not fetch ${url} (${resp.status}) – skipping`); + continue; + } + // The arena JSON endpoint returns a parsed JSON object; wasm expects a string + const arenaJson = await resp.text(); + if (first) { + catalogue.load(arenaJson); + first = false; + } else { + catalogue.append(arenaJson); + } + console.log(`[wasm] Loaded ${url}`); + } + + if (catalogue.is_empty()) { + console.warn("[wasm] Catalogue empty after loading — falling back to server-side STAC"); + return; + } + + // 4. Load MARS language metadata (descriptions, value labels) + const langResp = await fetch("/api/v2/language"); + if (langResp.ok) { + catalogue.set_language(await langResp.text()); + console.log("[wasm] Language metadata loaded"); + } else { + console.warn("[wasm] /api/v2/language not available; descriptions will be empty"); + } + + // 5. Expose the catalogue to app.js and re-run the viewer + window.__wasmCatalogue = catalogue; + console.log("[wasm] WasmCatalogue ready — client-side catalogue browsing active"); + const badge = document.getElementById("wasm-status"); + if (badge) { badge.textContent = "🦀 WASM"; badge.style.background = "#d4edda"; badge.style.color = "#155724"; } + + // Re-run the viewer so it picks up the WASM catalogue for the current URL + if (typeof window.initializeViewer === "function") { + window.initializeViewer(); + } + } catch (err) { + console.error("[wasm] Initialisation failed:", err); + // window.__wasmCatalogue remains unset → app.js uses server-side STAC + } +} + +initWasm(); diff --git a/stac_server/templates/index.html b/stac_server/templates/index.html index 45b52e4..5e1bdd0 100644 --- a/stac_server/templates/index.html +++ b/stac_server/templates/index.html @@ -94,6 +94,9 @@

{{title}}

+ +
⏳ Loading…
+
@@ -350,6 +353,9 @@

+ + \ No newline at end of file From 6ca5129d467aff25dc6a7a6dbae4441a78e64a4e Mon Sep 17 00:00:00 2001 From: mathleur Date: Thu, 12 Mar 2026 15:56:41 +0100 Subject: [PATCH 2/4] remove fastapi fallback for catalogue browsing; wait for wasm to setup --- stac_server/main.py | 244 +++++++++++---------------- stac_server/static/app.js | 28 +-- stac_server/static/catalogue_wasm.js | 14 +- 3 files changed, 126 insertions(+), 160 deletions(-) diff --git a/stac_server/main.py b/stac_server/main.py index 15f6b4e..1ea676a 100644 --- a/stac_server/main.py +++ b/stac_server/main.py @@ -178,109 +178,69 @@ async def union( # Catalogue query endpoints (server-side fallback for WASM) # --------------------------------------------------------------------------- -def follow_query(request: dict[str, str | list[str]], qube: PyQube): - rel_qube = qube.select(request, None, None) - full_axes = rel_qube.all_unique_dim_coords() - - seen_keys = list(request.keys()) - dataset_key_ordering = None - - s = qube.select(request, "follow_selection", None) - s.compress() - - if seen_keys and "dataset" in seen_keys: - ds = request["dataset"] - ds_name = ds if not isinstance(ds, list) else (ds[0] if len(ds) == 1 else None) - dataset_key_ordering = dataset_key_orders.get(ds_name) or dataset_key_orders["default"] - - if dataset_key_ordering is None: - available_keys = {node.key for _, node in s.leaf_nodes()} - else: - available_keys = [key for key in dataset_key_ordering if key in full_axes] - - frontier_keys = next((x for x in available_keys if x not in seen_keys), []) - - return_axes = [] - for key, info in full_axes.items(): - entry = { - "key": key, - "on_frontier": (key in frontier_keys) and (key not in seen_keys), - } - vals = list(info) - try: - sorted_vals = sorted(vals, key=int) - except (ValueError, TypeError): - sorted_vals = sorted(vals) - entry["values"] = sorted_vals - return_axes.append(entry) - - return s, return_axes - - -def make_link(axis, request_params): - key_name = axis["key"] - href_template = ( - f"/stac?{request_params}{'&' if request_params else ''}{key_name}={{{key_name}}}" - ) - values_from_language = mars_language.get(key_name, {}).get("values", {}) - return { - "title": key_name, - "uriTemplate": href_template, - "rel": "child", - "type": "application/json", - "variables": { - key_name: { - "description": mars_language.get(key_name, {}).get("description", ""), - "enum": axis["values"], - "value_descriptions": { - v: values_from_language[v] - for v in axis["values"] - if v in values_from_language - }, - "on_frontier": axis["on_frontier"], - } - }, - } - - -@app.get("/api/v2/stac/") -async def get_STAC( - request: dict[str, str | list[str]] = Depends(parse_request), -): - q, axes = follow_query(request, qube) - - end_of_traversal = not any(a["on_frontier"] for a in axes) - final_object = list(q.to_datacubes()) if end_of_traversal else [] - - kvs = [ - f"{k}={','.join(v)}" if isinstance(v, list) else f"{k}={v}" - for k, v in request.items() - ] - request_params = "&".join(kvs) - - all_keys = {a["key"] for a in axes} | set(request.keys()) - descriptions = { - key: { - "key": key, - "values": request.get(key, []) if isinstance(request.get(key), list) else ([request[key]] if key in request else []), - "description": mars_language.get(key, {}).get("description", ""), - "value_descriptions": mars_language.get(key, {}).get("values", {}), - } - for key in all_keys - } - - return { - "type": "Catalog", - "stac_version": "1.0.0", - "id": "root" if not request else "/stac?" + request_params, - "description": "STAC collection representing potential children of this request", - "links": [make_link(a, request_params) for a in axes], - "final_object": final_object, - "debug": { - "descriptions": descriptions, - "qube": q.to_ascii(), - }, - } +# def follow_query(request: dict[str, str | list[str]], qube: PyQube): +# rel_qube = qube.select(request, None, None) +# full_axes = rel_qube.all_unique_dim_coords() + +# seen_keys = list(request.keys()) +# dataset_key_ordering = None + +# s = qube.select(request, "follow_selection", None) +# s.compress() + +# if seen_keys and "dataset" in seen_keys: +# ds = request["dataset"] +# ds_name = ds if not isinstance(ds, list) else (ds[0] if len(ds) == 1 else None) +# dataset_key_ordering = dataset_key_orders.get(ds_name) or dataset_key_orders["default"] + +# if dataset_key_ordering is None: +# available_keys = {node.key for _, node in s.leaf_nodes()} +# else: +# available_keys = [key for key in dataset_key_ordering if key in full_axes] + +# frontier_keys = next((x for x in available_keys if x not in seen_keys), []) + +# return_axes = [] +# for key, info in full_axes.items(): +# entry = { +# "key": key, +# "on_frontier": (key in frontier_keys) and (key not in seen_keys), +# } +# vals = list(info) +# try: +# sorted_vals = sorted(vals, key=int) +# except (ValueError, TypeError): +# sorted_vals = sorted(vals) +# entry["values"] = sorted_vals +# return_axes.append(entry) + +# return s, return_axes + + +# def make_link(axis, request_params): +# key_name = axis["key"] +# href_template = ( +# f"/stac?{request_params}{'&' if request_params else ''}{key_name}={{{key_name}}}" +# ) +# values_from_language = mars_language.get(key_name, {}).get("values", {}) +# return { +# "title": key_name, +# "uriTemplate": href_template, +# "rel": "child", +# "type": "application/json", +# "variables": { +# key_name: { +# "description": mars_language.get(key_name, {}).get("description", ""), +# "enum": axis["values"], +# "value_descriptions": { +# v: values_from_language[v] +# for v in axis["values"] +# if v in values_from_language +# }, +# "on_frontier": axis["on_frontier"], +# } +# }, +# } @app.get("/api/v2/select/") @@ -290,42 +250,42 @@ async def select( return qube.select(request).to_json() -@app.get("/api/v2/query") -async def query( - request: dict[str, str | list[str]] = Depends(parse_request), -): - _, paths = follow_query(request, qube) - return paths - - -@app.get("/api/v2/basicstac/{filters:path}") -async def basic_stac(filters: str): - pairs = filters.strip("/").split("/") - request = dict(p.split("=") for p in pairs if "=" in p) - - q, _ = follow_query(request, qube) - - def _make_link(child_request): - kvs = [f"{k}={v}" for k, v in child_request.items()] - last_key, last_value = list(child_request.items())[-1] - return { - "title": f"{last_key}={last_value}", - "href": f"/api/v2/basicstac/{'/'.join(kvs)}", - "rel": "child", - "type": "application/json", - } - - this_key, this_value = list(request.items())[-1] if request else ("root", "root") - key_info = mars_language.get(this_key, {}) - value_info = key_info.get("values", {}).get(this_value, f"No info found for `{this_value}`.") - if this_key == "root": - value_info = "The root node" - - return { - "type": "Catalog", - "stac_version": "1.0.0", - "id": "root" if not request else "/".join(f"{k}={v}" for k, v in request.items()), - "title": f"{this_key}={this_value}", - "description": value_info, - "links": [_make_link(leaf) for leaf in q.leaves()], - } +# @app.get("/api/v2/query") +# async def query( +# request: dict[str, str | list[str]] = Depends(parse_request), +# ): +# _, paths = follow_query(request, qube) +# return paths + + +# @app.get("/api/v2/basicstac/{filters:path}") +# async def basic_stac(filters: str): +# pairs = filters.strip("/").split("/") +# request = dict(p.split("=") for p in pairs if "=" in p) + +# q, _ = follow_query(request, qube) + +# def _make_link(child_request): +# kvs = [f"{k}={v}" for k, v in child_request.items()] +# last_key, last_value = list(child_request.items())[-1] +# return { +# "title": f"{last_key}={last_value}", +# "href": f"/api/v2/basicstac/{'/'.join(kvs)}", +# "rel": "child", +# "type": "application/json", +# } + +# this_key, this_value = list(request.items())[-1] if request else ("root", "root") +# key_info = mars_language.get(this_key, {}) +# value_info = key_info.get("values", {}).get(this_value, f"No info found for `{this_value}`.") +# if this_key == "root": +# value_info = "The root node" + +# return { +# "type": "Catalog", +# "stac_version": "1.0.0", +# "id": "root" if not request else "/".join(f"{k}={v}" for k, v in request.items()), +# "title": f"{this_key}={this_value}", +# "description": value_info, +# "links": [_make_link(leaf) for leaf in q.leaves()], +# } diff --git a/stac_server/static/app.js b/stac_server/static/app.js index 01dd5fc..3cec404 100644 --- a/stac_server/static/app.js +++ b/stac_server/static/app.js @@ -705,6 +705,7 @@ async function fetchCatalog(request, stacUrl) { // Initialize the viewer by fetching the STAC catalog function initializeViewer() { + window.__viewerStarted = true; const stacUrl = getSTACUrlFromQuery(); const request = get_request_from_url(); @@ -1343,21 +1344,26 @@ function closeNotebook() { outputDiv.style.display = 'none'; } -// Call initializeViewer on page load -// Also expose it globally so catalogue_wasm.js can re-trigger it after the -// WASM catalogue finishes loading asynchronously. +// Expose initializeViewer globally so catalogue_wasm.js can call it once the +// WASM catalogue (or the server fallback) is ready. window.initializeViewer = initializeViewer; -initializeViewer(); -// Show server-side badge after a short delay (WASM will override if it loads) +// Show a loading spinner — catalogue_wasm.js will replace this once ready. +const _itemsEl = document.getElementById("items"); +if (_itemsEl) { + _itemsEl.innerHTML = '

⏳ Loading catalogue…

'; +} + +// Safety net: if catalogue_wasm.js hasn't triggered a render within 8s +// (e.g. the .wasm file is missing), fall back to the server-side endpoint. setTimeout(() => { - const badge = document.getElementById("wasm-status"); - if (badge && !window.__wasmCatalogue) { - badge.textContent = "🌐 Server"; - badge.style.background = "#cce5ff"; - badge.style.color = "#004085"; + if (!window.__wasmCatalogue && !window.__viewerStarted) { + console.warn("[wasm] Timed out waiting for WASM — falling back to server"); + const badge = document.getElementById("wasm-status"); + if (badge) { badge.textContent = "🌐 Server"; badge.style.background = "#cce5ff"; badge.style.color = "#004085"; } + initializeViewer(); } -}, 1000); +}, 8000); // Add event listener for copy button document.addEventListener("DOMContentLoaded", () => { diff --git a/stac_server/static/catalogue_wasm.js b/stac_server/static/catalogue_wasm.js index 75aa816..6081c01 100644 --- a/stac_server/static/catalogue_wasm.js +++ b/stac_server/static/catalogue_wasm.js @@ -63,19 +63,19 @@ async function initWasm() { console.warn("[wasm] /api/v2/language not available; descriptions will be empty"); } - // 5. Expose the catalogue to app.js and re-run the viewer + // 5. Expose the catalogue and trigger the first (and only) render via WASM window.__wasmCatalogue = catalogue; console.log("[wasm] WasmCatalogue ready — client-side catalogue browsing active"); const badge = document.getElementById("wasm-status"); if (badge) { badge.textContent = "🦀 WASM"; badge.style.background = "#d4edda"; badge.style.color = "#155724"; } - // Re-run the viewer so it picks up the WASM catalogue for the current URL - if (typeof window.initializeViewer === "function") { - window.initializeViewer(); - } + window.initializeViewer(); } catch (err) { - console.error("[wasm] Initialisation failed:", err); - // window.__wasmCatalogue remains unset → app.js uses server-side STAC + console.error("[wasm] Initialisation failed, falling back to server:", err); + // Fall back: render via the server-side /api/v2/stac/ endpoint + const badge = document.getElementById("wasm-status"); + if (badge) { badge.textContent = "🌐 Server"; badge.style.background = "#cce5ff"; badge.style.color = "#004085"; } + window.initializeViewer(); } } From d86862a346ef5a47b6b9739592560a46a05a35b8 Mon Sep 17 00:00:00 2001 From: mathleur Date: Thu, 12 Mar 2026 16:18:44 +0100 Subject: [PATCH 3/4] separate region selection and subsequent operations on the catalogue --- stac_server/static/app.js | 76 +++++++++++++++++++++---------- stac_server/templates/index.html | 78 +++++++++++++++++++------------- 2 files changed, 100 insertions(+), 54 deletions(-) diff --git a/stac_server/static/app.js b/stac_server/static/app.js index 3cec404..d2b2886 100644 --- a/stac_server/static/app.js +++ b/stac_server/static/app.js @@ -655,19 +655,38 @@ async function fetchCatalog(request, stacUrl) { const nextButton = document.getElementById("next-btn"); if (hasReachedEnd) { - // At the end: show MARS requests, hide current selection and next button - console.log("At end of traversal, rendering MARS requests"); + // Step 1: show the region selection page, hide everything else + console.log("At end of traversal, showing region selection step"); currentSelectionSection.style.display = "none"; - marsRequestsSection.style.display = "block"; + marsRequestsSection.style.display = "none"; nextButton.style.display = "none"; catalogCache = catalog; // Store catalog for re-rendering with features console.log("Descriptions available:", catalog.debug.descriptions); - renderMARSRequest(catalog.final_object, catalog.debug.descriptions); + + // Show region selection section (Step 1) + const regionSelectionSection = document.getElementById("region-selection-section"); + if (regionSelectionSection) { + regionSelectionSection.style.display = "block"; + // Reset map/buttons to initial state each time end-of-traversal is reached + const mapContainer = document.getElementById("map-container"); + const enableRegionBtn = document.getElementById("enable-region-btn"); + const skipRegionBtn = document.getElementById("skip-region-btn"); + if (mapContainer) mapContainer.style.display = "none"; + if (enableRegionBtn) enableRegionBtn.style.display = ""; + if (skipRegionBtn) skipRegionBtn.textContent = "Continue without Region →"; + // Clear any previous polygon + selectedPolygon = null; + if (drawnItems) drawnItems.clearLayers(); + const selReg = document.getElementById("selected-region"); + if (selReg) selReg.style.display = "none"; + } } else { - // Not at the end: show current selection, hide MARS requests, show next button + // Not at the end: show current selection, hide region + MARS sections, show next button currentSelectionSection.style.display = "block"; marsRequestsSection.style.display = "none"; nextButton.style.display = "flex"; + const regionSelectionSection = document.getElementById("region-selection-section"); + if (regionSelectionSection) regionSelectionSection.style.display = "none"; renderRequestBreakdown(request, catalog.debug.descriptions); } @@ -680,20 +699,6 @@ async function fetchCatalog(request, stacUrl) { renderCatalogItems(catalog.links); } - // Show region selection at the end of catalogue - const regionSelection = document.getElementById("region-selection"); - const catalogList = document.getElementById("catalog-list"); - const polytopeSection = document.getElementById("polytope-section"); - if (hasReachedEnd) { - regionSelection.style.display = "block"; - catalogList.classList.add("region-active"); - if (polytopeSection) polytopeSection.style.display = "block"; - } else { - regionSelection.style.display = "none"; - catalogList.classList.remove("region-active"); - if (polytopeSection) polytopeSection.style.display = "none"; - } - // Highlight the request and raw STAC hljs.highlightElement(document.getElementById("raw-stac")); hljs.highlightElement(document.getElementById("debug")); @@ -887,27 +892,52 @@ function displaySelectedRegion(coordinates) { } } +// Transition from Step 1 (region selection) to Step 2 (MARS requests + Polytope) +function showMARSRequestsSection() { + const regionSelectionSection = document.getElementById("region-selection-section"); + const marsRequestsSection = document.getElementById("mars-requests-section"); + const polytopeSection = document.getElementById("polytope-section"); + + if (regionSelectionSection) regionSelectionSection.style.display = "none"; + if (marsRequestsSection) { + marsRequestsSection.style.display = "block"; + // Scroll to the top of the main content + marsRequestsSection.scrollIntoView({ behavior: "smooth", block: "start" }); + } + if (polytopeSection) polytopeSection.style.display = "block"; + + if (catalogCache) { + renderMARSRequest(catalogCache.final_object, catalogCache.debug.descriptions); + } +} + // Event listeners for region selection document.addEventListener("DOMContentLoaded", () => { const enableRegionBtn = document.getElementById('enable-region-btn'); const skipRegionBtn = document.getElementById('skip-region-btn'); const clearRegionBtn = document.getElementById('clear-region-btn'); + const confirmRegionBtn = document.getElementById('confirm-region-btn'); const mapContainer = document.getElementById('map-container'); if (enableRegionBtn) { enableRegionBtn.addEventListener('click', () => { mapContainer.style.display = 'block'; enableRegionBtn.style.display = 'none'; - skipRegionBtn.textContent = 'Continue Without Region'; initializeRegionMap(); }); } if (skipRegionBtn) { skipRegionBtn.addEventListener('click', () => { - // User chose to skip region selection - could proceed to next step - console.log('User skipped region selection'); - // Here you could trigger the next action or inform the user + console.log('User skipped region selection, proceeding to MARS requests'); + showMARSRequestsSection(); + }); + } + + if (confirmRegionBtn) { + confirmRegionBtn.addEventListener('click', () => { + console.log('User confirmed region, proceeding to MARS requests with polygon:', selectedPolygon); + showMARSRequestsSection(); }); } diff --git a/stac_server/templates/index.html b/stac_server/templates/index.html index 5e1bdd0..564eb04 100644 --- a/stac_server/templates/index.html +++ b/stac_server/templates/index.html @@ -102,37 +102,6 @@

{{title}}

- - - + + + +