> {
+ // Ensure FDB config is set so the internal listing can open the DB
+ use std::path::PathBuf;
+
+ let config_path = PathBuf::from("xxx"); // Adjust this path to point to your local FDB config.yaml
+ unsafe {
+ std::env::set_var("FDB5_CONFIG_FILE", config_path.to_str().expect("Invalid config path"));
+ }
+
+ let lib_path = PathBuf::from("xxx"); // Adjust this path to point to the directory containing FDB shared libraries
+
+ unsafe {
+ std::env::set_var("DYLD_LIBRARY_PATH", lib_path.to_str().expect("Invalid path to shared libraries"));
+ }
+
+ let request_map = json!({
+ "class" : "d1",
+ "dataset": "extremes-dt",
+ "expver" : "0001",
+ "stream" : "oper",
+ "date": "20260303",
+ "time" : "0000",
+ "domain" : "g",
+ "levtype" : "sfc",
+ });
+ let start_time = Instant::now();
+
+ // Build the Qube directly from the request; the adapter will open FDB and list.
+ let qube = Qube::from_fdb_list(&request_map).expect("Failed to build Qube from FDB list");
+
+ // Stop the timer
+ let duration = start_time.elapsed();
+
+ // Print the time taken
+ println!("Time taken to construct Qube: {:?}", duration);
+
+ let file = File::create("extremes_eg.json")?;
+ serde_json::to_writer(file, &qube.to_arena_json())?;
+
+ Ok(())
+
+}
\ No newline at end of file
diff --git a/qubed_meteo/examples/read_from_fdb_list.rs b/qubed_meteo/examples/read_from_fdb_list.rs
index c780cce..a370516 100644
--- a/qubed_meteo/examples/read_from_fdb_list.rs
+++ b/qubed_meteo/examples/read_from_fdb_list.rs
@@ -26,7 +26,9 @@ fn main() {
// Build the Qube directly from the request; the adapter will open FDB and list.
let qube = Qube::from_fdb_list(&request_map).expect("Failed to build Qube from FDB list");
- println!("Qube structure:\n{}", qube.to_ascii());
+ // println!("Qube structure:\n{}", qube.to_ascii());
+
+ println!("Qube in arena json format:\n{}", qube.to_arena_json());
// Stop the timer
let duration = start_time.elapsed();
diff --git a/qubed_meteo/qube_examples/large_extremes_eg.json b/qubed_meteo/qube_examples/large_extremes_eg.json
new file mode 100644
index 0000000..60ca535
--- /dev/null
+++ b/qubed_meteo/qube_examples/large_extremes_eg.json
@@ -0,0 +1,4 @@
+{
+ "version": "1",
+ "qube": [{"children":[1],"coords":{},"dim":"root","parent":null},{"children":[2],"coords":{"strings":["d1"]},"dim":"class","parent":0},{"children":[3],"coords":{"strings":["extremes-dt"]},"dim":"dataset","parent":1},{"children":[4],"coords":{"ints":[20260303]},"dim":"date","parent":2},{"children":[5],"coords":{"strings":["0001"]},"dim":"expver","parent":3},{"children":[6],"coords":{"strings":["oper"]},"dim":"stream","parent":4},{"children":[7],"coords":{"strings":["0000"]},"dim":"time","parent":5},{"children":[8],"coords":{"strings":["sfc"]},"dim":"levtype","parent":6},{"children":[9,10,11],"coords":{"strings":["fc"]},"dim":"type","parent":7},{"children":[12],"coords":{"ints":[142,144,169,175,176,177,178,179,180,181,205,228,228216]},"dim":"param","parent":8},{"children":[13],"coords":{"ints":[228058]},"dim":"param","parent":8},{"children":[14],"coords":{"ints":[31,34,78,134,136,137,151,165,166,167,168,235,3020,228029,228050,228218,228219,228221,228235,260015]},"dim":"param","parent":8},{"children":[],"coords":{"strings":["0-1","1-2","10-11","11-12","12-13","13-14","14-15","15-16","16-17","17-18","18-19","19-20","2-3","20-21","21-22","22-23","23-24","24-25","25-26","26-27","27-28","28-29","29-30","3-4","30-31","31-32","32-33","33-34","34-35","35-36","36-37","37-38","38-39","39-40","4-5","40-41","41-42","42-43","43-44","44-45","45-46","46-47","47-48","48-49","49-50","5-6","50-51","51-52","52-53","53-54","54-55","55-56","56-57","57-58","58-59","59-60","6-7","60-61","61-62","62-63","63-64","64-65","65-66","66-67","67-68","68-69","69-70","7-8","70-71","71-72","72-73","73-74","74-75","75-76","76-77","77-78","78-79","79-80","8-9","80-81","81-82","82-83","83-84","84-85","85-86","86-87","87-88","88-89","89-90","9-10","90-91","91-92","92-93","93-94","94-95","95-96"]},"dim":"step","parent":9},{"children":[],"coords":{"strings":["0-6","12-18","18-24","24-30","30-36","36-42","42-48","48-54","54-60","6-12","60-66","66-72","72-78","78-84","84-90","90-96"]},"dim":"step","parent":10},{"children":[],"coords":{"ints":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96]},"dim":"step","parent":11}]
+}
diff --git a/qubed_meteo/qube_examples/medium_extremes_eg.json b/qubed_meteo/qube_examples/medium_extremes_eg.json
new file mode 100644
index 0000000..4bb8657
--- /dev/null
+++ b/qubed_meteo/qube_examples/medium_extremes_eg.json
@@ -0,0 +1,4 @@
+{
+ "version": "1",
+ "qube": [{"children":[1],"coords":{},"dim":"root","parent":null},{"children":[2],"coords":{"strings":["d1"]},"dim":"class","parent":0},{"children":[3],"coords":{"strings":["extremes-dt"]},"dim":"dataset","parent":1},{"children":[4],"coords":{"ints":[20260303]},"dim":"date","parent":2},{"children":[5],"coords":{"strings":["0001"]},"dim":"expver","parent":3},{"children":[6],"coords":{"strings":["oper"]},"dim":"stream","parent":4},{"children":[7],"coords":{"strings":["0000"]},"dim":"time","parent":5},{"children":[8],"coords":{"strings":["sfc"]},"dim":"levtype","parent":6},{"children":[9],"coords":{"strings":["fc"]},"dim":"type","parent":7},{"children":[10],"coords":{"ints":[31,34,78,134,136,137,151,165,166,167,168,235,3020,228029,228050,228218,228219,228221,228235,260015]},"dim":"param","parent":8},{"children":[],"coords":{"ints":[0]},"dim":"step","parent":9}]
+}
diff --git a/qubed_meteo/qube_examples/oper_fdb.json b/qubed_meteo/qube_examples/oper_fdb.json
new file mode 100644
index 0000000..807a6fd
--- /dev/null
+++ b/qubed_meteo/qube_examples/oper_fdb.json
@@ -0,0 +1,4 @@
+{
+ "version": "1",
+ "qube": [{"children":[1],"coords":{},"dim":"root","parent":null},{"children":[2,3,4,5],"coords":{"strings":["od"]},"dim":"class","parent":0},{"children":[6],"coords":{"ints":[20231102]},"dim":"date","parent":1},{"children":[7],"coords":{"ints":[20240103]},"dim":"date","parent":1},{"children":[8],"coords":{"ints":[20240118]},"dim":"date","parent":1},{"children":[9],"coords":{"ints":[20240129]},"dim":"date","parent":1},{"children":[10],"coords":{"strings":["g"]},"dim":"domain","parent":2},{"children":[11],"coords":{"strings":["g"]},"dim":"domain","parent":3},{"children":[12],"coords":{"strings":["g"]},"dim":"domain","parent":4},{"children":[13],"coords":{"strings":["g"]},"dim":"domain","parent":5},{"children":[14],"coords":{"strings":["0001"]},"dim":"expver","parent":6},{"children":[15],"coords":{"strings":["0001"]},"dim":"expver","parent":7},{"children":[16],"coords":{"strings":["0001"]},"dim":"expver","parent":8},{"children":[17],"coords":{"strings":["0001"]},"dim":"expver","parent":9},{"children":[18],"coords":{"strings":["oper"]},"dim":"stream","parent":10},{"children":[19],"coords":{"strings":["oper"]},"dim":"stream","parent":11},{"children":[20],"coords":{"strings":["oper"]},"dim":"stream","parent":12},{"children":[21],"coords":{"strings":["oper"]},"dim":"stream","parent":13},{"children":[22],"coords":{"strings":["0000"]},"dim":"time","parent":14},{"children":[23],"coords":{"strings":["0000"]},"dim":"time","parent":15},{"children":[24],"coords":{"strings":["0000"]},"dim":"time","parent":16},{"children":[25],"coords":{"strings":["0000"]},"dim":"time","parent":17},{"children":[26],"coords":{"strings":["sfc"]},"dim":"levtype","parent":18},{"children":[27],"coords":{"strings":["sfc"]},"dim":"levtype","parent":19},{"children":[28],"coords":{"strings":["sfc"]},"dim":"levtype","parent":20},{"children":[29],"coords":{"strings":["sfc"]},"dim":"levtype","parent":21},{"children":[30],"coords":{"strings":["fc"]},"dim":"type","parent":22},{"children":[31],"coords":{"strings":["fc"]},"dim":"type","parent":23},{"children":[32],"coords":{"strings":["fc"]},"dim":"type","parent":24},{"children":[33],"coords":{"strings":["fc"]},"dim":"type","parent":25},{"children":[34],"coords":{"ints":[167]},"dim":"param","parent":26},{"children":[35],"coords":{"ints":[167]},"dim":"param","parent":27},{"children":[36],"coords":{"ints":[49,167]},"dim":"param","parent":28},{"children":[37],"coords":{"ints":[167]},"dim":"param","parent":29},{"children":[],"coords":{"ints":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,93,96,99]},"dim":"step","parent":30},{"children":[],"coords":{"ints":[0,1,2]},"dim":"step","parent":31},{"children":[],"coords":{"ints":[0]},"dim":"step","parent":32},{"children":[],"coords":{"ints":[0]},"dim":"step","parent":33}]
+}
diff --git a/qubed_meteo/qube_examples/small_climate_eg.json b/qubed_meteo/qube_examples/small_climate_eg.json
new file mode 100644
index 0000000..1a17dd0
--- /dev/null
+++ b/qubed_meteo/qube_examples/small_climate_eg.json
@@ -0,0 +1,4 @@
+{
+ "version": "1",
+ "qube": [{"children":[1],"coords":{},"dim":"root","parent":null},{"children":[2],"coords":{"strings":["highresmip"]},"dim":"activity","parent":0},{"children":[3],"coords":{"strings":["d1"]},"dim":"class","parent":1},{"children":[4],"coords":{"strings":["climate-dt"]},"dim":"dataset","parent":2},{"children":[5],"coords":{"strings":["cont"]},"dim":"experiment","parent":3},{"children":[6],"coords":{"strings":["0001"]},"dim":"expver","parent":4},{"children":[7],"coords":{"ints":[1]},"dim":"generation","parent":5},{"children":[8],"coords":{"strings":["ifs-nemo"]},"dim":"model","parent":6},{"children":[9],"coords":{"ints":[1]},"dim":"realization","parent":7},{"children":[10],"coords":{"strings":["clte"]},"dim":"stream","parent":8},{"children":[11],"coords":{"ints":[1990]},"dim":"year","parent":9},{"children":[12],"coords":{"strings":["sfc"]},"dim":"levtype","parent":10},{"children":[13],"coords":{"ints":[1]},"dim":"month","parent":11},{"children":[14],"coords":{"strings":["high"]},"dim":"resolution","parent":12},{"children":[15],"coords":{"strings":["fc"]},"dim":"type","parent":13},{"children":[16],"coords":{"ints":[19900101]},"dim":"date","parent":14},{"children":[17],"coords":{"ints":[167]},"dim":"param","parent":15},{"children":[],"coords":{"strings":["0000"]},"dim":"time","parent":16}]
+}
diff --git a/qubed_meteo/qube_examples/small_extremes_eg.json b/qubed_meteo/qube_examples/small_extremes_eg.json
new file mode 100644
index 0000000..e54b1a5
--- /dev/null
+++ b/qubed_meteo/qube_examples/small_extremes_eg.json
@@ -0,0 +1,4 @@
+{
+ "version": "1",
+ "qube": [{"children":[1],"coords":{},"dim":"root","parent":null},{"children":[2],"coords":{"strings":["d1"]},"dim":"class","parent":0},{"children":[3],"coords":{"strings":["extremes-dt"]},"dim":"dataset","parent":1},{"children":[4],"coords":{"ints":[20260303]},"dim":"date","parent":2},{"children":[5],"coords":{"strings":["0001"]},"dim":"expver","parent":3},{"children":[6],"coords":{"strings":["oper"]},"dim":"stream","parent":4},{"children":[7],"coords":{"strings":["0000"]},"dim":"time","parent":5},{"children":[8],"coords":{"strings":["sfc"]},"dim":"levtype","parent":6},{"children":[9],"coords":{"strings":["fc"]},"dim":"type","parent":7},{"children":[10],"coords":{"ints":[34]},"dim":"param","parent":8},{"children":[],"coords":{"ints":[0]},"dim":"step","parent":9}]
+}
diff --git a/qubed_meteo/src/adapters/fdb.rs b/qubed_meteo/src/adapters/fdb.rs
index 4ad7b1a..63fdb18 100644
--- a/qubed_meteo/src/adapters/fdb.rs
+++ b/qubed_meteo/src/adapters/fdb.rs
@@ -20,7 +20,7 @@ impl FromFDBList for Qube {
let fdb = FDB::new(None).map_err(|e| format!("Failed to open FDB: {:?}", e))?;
let list_iter =
- fdb.list(&request, true, true).map_err(|e| format!("FDB list failed: {:?}", e))?;
+ fdb.list(&request, true, false).map_err(|e| format!("FDB list failed: {:?}", e))?;
let mut qube = Qube::new();
let root = qube.root();
@@ -72,6 +72,11 @@ impl FromFDBList for Qube {
if let Some((key, val)) = part.split_once('=') {
let vals: Vec<&str> =
val.split('/').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
+
+ // If there are no value parts (e.g. "key=") skip creating an empty child
+ if vals.is_empty() {
+ continue;
+ }
let coords = make_coords(&vals);
let child = qube
.get_or_create_child(key.trim(), parent, coords)
diff --git a/stac_server/POLYTOPE_AUTH_TESTING.md b/stac_server/POLYTOPE_AUTH_TESTING.md
new file mode 100644
index 0000000..7ccc8bf
--- /dev/null
+++ b/stac_server/POLYTOPE_AUTH_TESTING.md
@@ -0,0 +1,47 @@
+# Polytope Authentication
+
+The STAC server's Polytope integration collects authentication credentials directly from users through the web interface and queries the **Destination Earth** Polytope service.
+
+## User Flow
+
+1. Navigate through the STAC catalogue and select your data
+2. At the end of the catalogue, you'll see the "Query Data with Polytope" section
+3. Enter your Destination Earth Polytope credentials:
+ - **Email Address**: Your email address registered with Destination Earth
+ - **API Key**: Your Polytope API key
+4. Click "Query Polytope Service" to submit your data extraction requests
+
+## Getting Your Polytope API Key
+
+1. Visit [https://polytope.lumi.apps.dte.destination-earth.eu](https://polytope.lumi.apps.dte.destination-earth.eu)
+2. Log in with your Destination Earth credentials
+3. Navigate to your profile or settings
+4. Generate or copy your API key
+
+## Technical Details
+
+The service connects to the Destination Earth Polytope instance:
+- **Address**: `polytope.lumi.apps.dte.destination-earth.eu`
+- **Collection**: `destination-earth`
+
+## Security Notes
+
+- Credentials are sent securely with each request
+- Credentials are not stored on the server
+- Each user provides their own authentication
+- Credentials are only logged in a masked format for debugging
+
+## For Developers
+
+The credentials are sent in the request body as:
+```json
+{
+ "requests": [...],
+ "credentials": {
+ "user_email": "user@ecmwf.int",
+ "user_key": "your_api_key"
+ }
+}
+```
+
+The backend passes these credentials directly to `earthkit.data.from_source()` when querying Polytope.
\ No newline at end of file
diff --git a/stac_server/__init__.py b/stac_server/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stac_server/favicon.ico b/stac_server/favicon.ico
new file mode 100644
index 0000000..3663761
Binary files /dev/null and b/stac_server/favicon.ico differ
diff --git a/stac_server/key_ordering.py b/stac_server/key_ordering.py
new file mode 100644
index 0000000..a5ccb24
--- /dev/null
+++ b/stac_server/key_ordering.py
@@ -0,0 +1,84 @@
+climate_dt_keys = [
+ "class",
+ "dataset",
+ "activity",
+ "experiment",
+ "generation",
+ "model",
+ "realization",
+ "expver",
+ "stream",
+ "date",
+ "resolution",
+ "type",
+ "levtype",
+ "time",
+ "levelist",
+ "param",
+]
+
+extremes_dt_keys = [
+ "class",
+ "dataset",
+ "expver",
+ "stream",
+ "date",
+ "time",
+ "type",
+ "levtype",
+ "step",
+ "levelist",
+ "param",
+ "frequency",
+ "direction",
+]
+
+on_demands_dt_keys = [
+ "class",
+ "dataset",
+ "expver",
+ "stream",
+ "date",
+ "time",
+ "type",
+ "georef",
+ "levtype",
+ "step",
+ "number",
+ "levelist",
+ "param",
+ "frequency",
+ "direction",
+ "ident",
+ "instrument",
+ "channel",
+]
+
+default_keys = [
+ "class",
+ "dataset",
+ "stream",
+ "activity",
+ "resolution",
+ "expver",
+ "experiment",
+ "generation",
+ "model",
+ "realization",
+ "type",
+ "date",
+ "time",
+ "datetime",
+ "levtype",
+ "levelist",
+ "step",
+ "param",
+]
+
+
+dataset_key_orders = {
+ "climate-dt": climate_dt_keys,
+ "extremes-dt": extremes_dt_keys,
+ "on-demand-extremes-dt": on_demands_dt_keys,
+ "default": default_keys,
+}
\ No newline at end of file
diff --git a/stac_server/main.py b/stac_server/main.py
new file mode 100644
index 0000000..21f2299
--- /dev/null
+++ b/stac_server/main.py
@@ -0,0 +1,761 @@
+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.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
+config_path = os.environ.get(
+ "CONFIG_PATH", f"{Path(__file__).parents[1]}/config/config.yaml"
+)
+if not Path(config_path).exists():
+ raise FileNotFoundError(f"Config file not found at {config_path}")
+with open(config_path, "r") as f:
+ config = yaml.safe_load(f)
+ logger.info(f"Loaded config from {config_path}")
+
+prefix = Path(
+ os.environ.get(
+ "QUBED_DATA_PREFIX", Path(__file__).parents[1] / "qubed_meteo/qube_examples/"
+ )
+)
+
+if "API_KEY" in os.environ:
+ api_key = os.environ["API_KEY"].strip()
+ logger.info("Got api key from env key API_KEY")
+else:
+ with open("api_key.secret", "r") as f:
+ api_key = f.read().strip()
+ logger.info("Got api_key from local file 'api_key.secret'")
+
+app = FastAPI()
+security = HTTPBearer()
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ 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()
+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():
+ logger.warning(f"Data file {data_path} does not exist, skipping")
+ 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??")
+ 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())
+
+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!")
+
+
+async def get_body_json(request: Request):
+ return await request.json()
+
+
+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():
+ raise HTTPException(status_code=403, detail="Incorrect API Key")
+ return credentials
+
+
+@app.get("/favicon.ico", include_in_schema=False)
+async def favicon():
+ return FileResponse("favicon.ico")
+
+
+@app.get("/api/v1/{path:path}")
+async def deprecated():
+ raise HTTPException(status_code=410, detail="/api/v1 is now deprecated, use v2")
+
+
+@app.get("/", response_class=HTMLResponse)
+async def read_root(request: Request):
+ index_config = {
+ "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 = {
+ "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)
+
+
+@app.get("/api/v2/get/")
+async def get(
+ request: dict[str, str | list[str]] = Depends(parse_request),
+):
+ return qube.to_json()
+
+
+@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,
+ }
+
+ 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,
+ }
+
+
+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"]
+
+ 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())
+ ]
+
+ 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 = {
+ "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)
+
+ 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
+ }
+
+ return {
+ "title": key_name,
+ "uriTemplate": href_template,
+ "rel": "child",
+ "type": "application/json",
+ "variables": {
+ key_name: {
+ # "type": axis["dtype"],
+ "description": mars_language.get(key_name, {}).get("description", ""),
+ "enum": axis["values"],
+ "value_descriptions": value_descriptions,
+ "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)
+ 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)
+
+ kvs = [
+ f"{k}={','.join(v)}" if isinstance(v, list) else f"{k}={v}"
+ for k, v in request.items()
+ ]
+ 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
+
+ descriptions = {
+ key: {
+ "key": key,
+ "values": values if isinstance(values, list) else [values] if isinstance(values, str) else [],
+ "description": mars_language.get(key, {}).get("description", ""),
+ "value_descriptions": mars_language.get(key, {}).get("values", {}),
+ }
+ for key, values in request.items()
+ }
+
+ # 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 = {
+ "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": 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
+
+
+# Pydantic models for notebook execution
+class ExecuteRequest(BaseModel):
+ code: str
+ data: dict | None = None
+
+
+class InstallPackageRequest(BaseModel):
+ packages: str # Space or comma-separated package names
+
+
+@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
+
+ # Capture stdout and stderr
+ old_stdout = sys.stdout
+ old_stderr = sys.stderr
+ sys.stdout = StringIO()
+ sys.stderr = StringIO()
+
+ images = []
+
+ 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
diff --git a/stac_server/run.sh b/stac_server/run.sh
new file mode 100644
index 0000000..b3293b6
--- /dev/null
+++ b/stac_server/run.sh
@@ -0,0 +1,3 @@
+parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
+cd "$parent_path"
+API_KEY=asdf LOCAL_CACHE=True uv run fastapi dev ./main.py --port 8124 --reload
\ No newline at end of file
diff --git a/stac_server/run_prod.sh b/stac_server/run_prod.sh
new file mode 100644
index 0000000..0ca9a14
--- /dev/null
+++ b/stac_server/run_prod.sh
@@ -0,0 +1,3 @@
+parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
+cd "$parent_path"
+sudo LOCAL_CACHE=True ../../.venv/bin/fastapi dev ./main.py --port 80 --host=0.0.0.0 --reload
\ No newline at end of file
diff --git a/stac_server/static/app.js b/stac_server/static/app.js
new file mode 100644
index 0000000..c151f8a
--- /dev/null
+++ b/stac_server/static/app.js
@@ -0,0 +1,1370 @@
+function toHTML(string) {
+ return document.createRange().createContextualFragment(string)
+ .firstElementChild;
+}
+
+// Take the query string and stick it on the API URL
+function getSTACUrlFromQuery() {
+ const params = new URLSearchParams(window.location.search);
+
+ let api_url;
+ // get current window url and remove path part
+ if (window.API_URL.startsWith("http")) {
+ // Absolute URL: Use it directly
+ api_url = new URL(window.API_URL);
+ } else {
+ // Relative URL: Combine with the current window's location
+ api_url = new URL(window.location.href);
+ api_url.pathname = window.API_URL;
+ }
+
+ for (const [key, value] of params.entries()) {
+ api_url.searchParams.set(key, value);
+ }
+
+ console.log(api_url.toString());
+ return api_url.toString();
+}
+
+function get_request_from_url() {
+ // Extract the query params in order and split any with a , delimiter
+ // request is an ordered array of [key, [value1, value2, value3, ...]]
+ const url = new URL(window.location.href);
+ const params = new URLSearchParams(url.search);
+ const request = [];
+ for (const [key, value] of params.entries()) {
+ request.push([key, value.split(",")]);
+ }
+ return request;
+}
+
+function make_url_from_request(request) {
+ const url = new URL(window.location.href);
+ url.search = ""; // Clear existing params
+ const params = new URLSearchParams();
+
+ for (const [key, values] of request) {
+ params.set(key, values.join(","));
+ }
+ url.search = params.toString();
+
+ return url.toString().replace(/%2C/g, ",");
+}
+
+function goToPreviousUrl() {
+ let request = get_request_from_url();
+ request.pop();
+ console.log("Request:", request);
+ const url = make_url_from_request(request);
+ console.log("URL:", url);
+ window.location.href = make_url_from_request(request);
+}
+
+// Function to generate a new STAC URL based on current selection
+function goToNextUrl() {
+ const request = get_request_from_url();
+
+ // Get the currently selected key = value,value2,value3 pairs
+ const items = Array.from(document.querySelectorAll("div#items > div"));
+
+ let any_new_keys = false;
+ const new_keys = items.map((item) => {
+ const key = item.dataset.key;
+ const key_type = item.dataset.keyType;
+ let values = [];
+
+ const enum_checkboxes = item.querySelectorAll(
+ "input[type='checkbox']:checked"
+ );
+ if (enum_checkboxes.length > 0) {
+ values.push(
+ ...Array.from(enum_checkboxes).map((checkbox) => checkbox.value)
+ );
+ }
+
+ // Get text inputs but exclude the filter input
+ const any = item.querySelector("input[type='text']:not(.filter-input)");
+ if (any && any.value !== "") {
+ values.push(any.value);
+ }
+
+ // Keep track of whether any new keys are selected
+ if (values.length > 0) {
+ any_new_keys = true;
+ }
+
+ console.log(`Checking ${key} ${key_type} and found ${values}`);
+ return { key, values };
+ });
+
+ // if not new keys are selected, do nothing
+ if (!any_new_keys) {
+ return;
+ }
+
+ // Update the request with the new keys
+ for (const { key, values } of new_keys) {
+ if (values.length == 0) continue;
+
+ // Find the index of the existing key in the request array
+ const existingIndex = request.findIndex(
+ ([existingKey, existingValues]) => existingKey === key
+ );
+
+ if (existingIndex !== -1) {
+ // If the key already exists,
+ // and the values aren't already in there,
+ // append the values
+ request[existingIndex][1] = [...request[existingIndex][1], ...values];
+ } else {
+ // If the key doesn't exist, add a new entry
+ request.push([key, values]);
+ }
+ }
+
+ const url = make_url_from_request(request);
+ window.location.href = url;
+}
+
+async function createCatalogItem(link, itemsContainer) {
+ if (Object.entries(link.variables)[0][1].on_frontier === false) {
+ return;
+ }
+
+ const itemDiv = document.createElement("div");
+ itemDiv.className = "item loading";
+ itemDiv.textContent = "Loading...";
+ itemsContainer.appendChild(itemDiv);
+
+ try {
+ // Update the item div with real content
+ itemDiv.classList.remove("loading");
+
+ const variables = link["variables"];
+ const key = Object.keys(variables)[0];
+ const variable = variables[key];
+
+ // add data-key attribute to the itemDiv
+ itemDiv.dataset.key = link.title;
+ itemDiv.dataset.keyType = variable.type;
+
+ function capitalize(val) {
+ return String(val).charAt(0).toUpperCase() + String(val).slice(1);
+ }
+
+ itemDiv.innerHTML = `
+ ${capitalize(link.title) || "No title available"
+ }
+ *
+ Key Type: ${itemDiv.dataset.keyType || "Unknown"}
+ ${variable.description ? variable.description.slice(0, 100) : ""
+ }
+ `;
+
+ if (key === "date" && variable.enum && variable.enum.length > 30) {
+ console.log("Date picker enabled");
+ console.log("First few dates:", variable.enum.slice(0, 10));
+
+ // Create a unique ID for this date picker
+ const pickerId = `date-picker-${link.title}`;
+ const hiddenInputId = `date-input-${link.title}`;
+
+ itemDiv.appendChild(toHTML(` `));
+ itemDiv.appendChild(toHTML(` `));
+ itemDiv.appendChild(toHTML(`💡 Click a date twice to select it individually, or click two different dates to select a range.
`));
+
+ let dates = variable.enum.map(d => String(d));
+ itemDiv.querySelector("button.all").style.display = "none";
+
+ // Create a set for fast lookup (normalize to YYYY-MM-DD format)
+ const availableDatesSet = new Set(dates);
+ console.log("Available dates set size:", availableDatesSet.size);
+
+ // Parse dates from enum to get min and max dates
+ let parsedDates = dates.map(d => {
+ // Handle both formats: "YYYY-MM-DD" or "YYYYMMDD"
+ const dateStr = String(d);
+ if (dateStr.includes('-')) {
+ return new Date(dateStr);
+ } else {
+ const year = parseInt(dateStr.substring(0, 4));
+ const month = parseInt(dateStr.substring(4, 6)) - 1;
+ const day = parseInt(dateStr.substring(6, 8));
+ return new Date(year, month, day);
+ }
+ });
+
+ let minDate = new Date(Math.min(...parsedDates));
+ let maxDate = new Date(Math.max(...parsedDates));
+
+ console.log("Date range:", minDate.toISOString(), "to", maxDate.toISOString());
+
+ // Track selected dates manually for better control
+ let manuallySelectedDates = new Set();
+ let lastClickedDate = null;
+
+ let picker = new AirDatepicker(`#${pickerId}`, {
+ position: "bottom center",
+ inline: true,
+ locale: exports.default,
+ multipleDates: true,
+ multipleDatesSeparator: ",",
+ minDate: minDate,
+ maxDate: maxDate,
+ onSelect({ date, formattedDate, datepicker }) {
+ // Prevent default behavior - we'll handle selection manually
+ },
+ onRenderCell({ date, cellType }) {
+ if (cellType === "day") {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ // Check in the format that matches the input
+ let dateStr;
+ if (dates[0].includes('-')) {
+ dateStr = `${year}-${month}-${day}`;
+ } else {
+ dateStr = `${year}${month}${day}`;
+ }
+ const hasData = availableDatesSet.has(dateStr);
+
+ return {
+ classes: hasData ? "has-data" : "",
+ disabled: !hasData,
+ };
+ }
+ return {};
+ },
+ });
+
+ // Custom click handler for date cells
+ const hintElement = document.getElementById(`${pickerId}-hint`);
+
+ // Wait for datepicker to render, then attach event handler
+ setTimeout(() => {
+ const datepickerContainer = document.querySelector(`#${pickerId}`).parentElement.querySelector('.air-datepicker');
+
+ if (datepickerContainer) {
+ datepickerContainer.addEventListener('click', (e) => {
+ const cell = e.target.closest('.air-datepicker-cell.-day-');
+ if (!cell || cell.classList.contains('-disabled-')) return;
+
+ // Get the date from the cell's data attributes
+ const dayNumber = cell.getAttribute('data-date');
+ const monthNumber = cell.getAttribute('data-month');
+ const yearNumber = cell.getAttribute('data-year');
+
+ if (!dayNumber || !monthNumber || !yearNumber) return;
+
+ const cellDate = new Date(parseInt(yearNumber), parseInt(monthNumber), parseInt(dayNumber));
+
+ const formatDate = (d) => {
+ const year = d.getFullYear();
+ const month = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ if (dates[0].includes('-')) {
+ return `${year}-${month}-${day}`;
+ } else {
+ return `${year}${month}${day}`;
+ }
+ };
+
+ const clickedDateStr = formatDate(cellDate);
+
+ // Check if this date has data
+ if (!availableDatesSet.has(clickedDateStr)) return;
+
+ const isSameAsPrevious = lastClickedDate &&
+ cellDate.getTime() === lastClickedDate.getTime();
+
+ if (isSameAsPrevious) {
+ // Clicking same date twice - toggle individual date
+ if (manuallySelectedDates.has(clickedDateStr)) {
+ manuallySelectedDates.delete(clickedDateStr);
+ console.log("Removed date:", clickedDateStr);
+ if (hintElement) hintElement.textContent = `🗑️ Removed ${clickedDateStr}. Total: ${manuallySelectedDates.size} dates selected.`;
+ } else {
+ manuallySelectedDates.add(clickedDateStr);
+ console.log("Added single date:", clickedDateStr);
+ if (hintElement) hintElement.textContent = `✅ Added ${clickedDateStr}. Total: ${manuallySelectedDates.size} dates selected.`;
+ }
+ lastClickedDate = null; // Reset for next selection
+ } else if (lastClickedDate) {
+ // Two different dates clicked - create a range
+ const [startDate, endDate] = [lastClickedDate, cellDate].sort((a, b) => a - b);
+
+ console.log("Creating range from", formatDate(startDate), "to", formatDate(endDate));
+
+ let currentDate = new Date(startDate);
+ const rangeEnd = new Date(endDate);
+ let rangeCount = 0;
+
+ while (currentDate <= rangeEnd) {
+ const dateStr = formatDate(currentDate);
+ if (availableDatesSet.has(dateStr)) {
+ manuallySelectedDates.add(dateStr);
+ rangeCount++;
+ }
+ currentDate.setDate(currentDate.getDate() + 1);
+ }
+
+ console.log("Range added, total dates selected:", manuallySelectedDates.size);
+ if (hintElement) hintElement.textContent = `📅 Added range: ${rangeCount} dates. Total: ${manuallySelectedDates.size} dates selected.`;
+ lastClickedDate = null; // Reset for next selection
+ } else {
+ // First click of a potential range
+ lastClickedDate = cellDate;
+ console.log("First date clicked for range:", clickedDateStr);
+ if (hintElement) hintElement.textContent = `🎯 First date selected: ${clickedDateStr}. Click another date to create a range, or click this date again to select it individually.`;
+ return; // Don't update selection yet, wait for second click
+ }
+
+ // Update the visual selection in datepicker
+ const selectedDateObjects = Array.from(manuallySelectedDates).map(dateStr => {
+ if (dateStr.includes('-')) {
+ return new Date(dateStr);
+ } else {
+ const year = parseInt(dateStr.substring(0, 4));
+ const month = parseInt(dateStr.substring(4, 6)) - 1;
+ const day = parseInt(dateStr.substring(6, 8));
+ return new Date(year, month, day);
+ }
+ });
+
+ picker.selectDate(selectedDateObjects);
+
+ // Update hidden input
+ const hiddenInput = document.getElementById(hiddenInputId);
+ hiddenInput.value = Array.from(manuallySelectedDates).join(',');
+ console.log("Total selected dates:", manuallySelectedDates.size);
+ console.log("Selected dates:", hiddenInput.value.split(',').slice(0, 10).join(', '), '...');
+ });
+ }
+ }, 100);
+
+ console.log("Datepicker initialized");
+ } else if (variable.enum && variable.enum.length > 0) {
+ // Add filter input at the top if there are many options
+ if (variable.enum.length > 5) {
+ const filterWrapper = toHTML(`
+
+
+
+ `);
+ itemDiv.appendChild(filterWrapper);
+ }
+
+ // Add checkbox list
+ const checkbox_list = renderCheckboxList(link);
+ itemDiv.appendChild(checkbox_list);
+
+ // Add filter functionality if filter exists
+ if (variable.enum.length > 5) {
+ const filterInput = itemDiv.querySelector(`#filter-${link.title}`);
+ if (filterInput) {
+ filterInput.addEventListener('input', (e) => {
+ const filterText = e.target.value.toLowerCase();
+ const checkboxRows = checkbox_list.querySelectorAll('.checkbox-row');
+
+ checkboxRows.forEach(row => {
+ const label = row.querySelector('label');
+ const code = row.querySelector('label.code code');
+ const labelText = label ? label.textContent.toLowerCase() : '';
+ const codeText = code ? code.textContent.toLowerCase() : '';
+
+ if (labelText.includes(filterText) || codeText.includes(filterText)) {
+ row.style.display = '';
+ } else {
+ row.style.display = 'none';
+ }
+ });
+ });
+ }
+ }
+
+ itemDiv.querySelector("button.all").addEventListener("click", () => {
+ let new_state;
+ if (checkbox_list.hasAttribute("disabled")) {
+ checkbox_list.removeAttribute("disabled");
+ itemDiv.querySelectorAll("input[type='checkbox']").forEach((c) => {
+ c.removeAttribute("checked");
+ c.removeAttribute("disabled");
+ });
+ } else {
+ checkbox_list.setAttribute("disabled", "");
+ itemDiv.querySelectorAll("input[type='checkbox']").forEach((c) => {
+ c.setAttribute("checked", "true");
+ c.setAttribute("disabled", "");
+ });
+ }
+ });
+ } else {
+ const any = toHTML(` `);
+ itemDiv.appendChild(any);
+ }
+ } catch (error) {
+ console.error("Error loading item data:", error);
+ itemDiv.innerHTML = `Error loading item details: ${error}
`;
+ }
+}
+
+function renderCheckboxList(link) {
+ const variables = link["variables"];
+ const key = Object.keys(variables)[0];
+ const variable = variables[key];
+ const value_descriptions = variable.value_descriptions || {};
+
+ function renderCheckbox(key, value, desc) {
+ const id = `${key}=${value}`;
+ let more_info = desc.url
+ ? ` ? `
+ : "";
+
+ let human_label, code_label;
+ if (desc.name) {
+ human_label = `${desc.name}${more_info} `;
+ code_label = `${value} `;
+ } else {
+ human_label = `${value}${more_info} `;
+ code_label = ` `;
+ }
+
+ // Pre-check the box if there's only one option
+ const checked = variable.enum.length === 1 ? "checked" : "";
+
+ const checkbox = ` `;
+
+ return `
+
+ ${checkbox}
+ ${human_label}
+ ${code_label}
+
+ `;
+ }
+
+ const checkboxes = variable.enum
+ .map((value) => renderCheckbox(key, value, value_descriptions[value] || {}))
+ .join("");
+
+ return toHTML(`${checkboxes}
`);
+}
+
+// Render catalog items in the sidebar
+function renderCatalogItems(links) {
+ const itemsContainer = document.getElementById("items");
+ itemsContainer.innerHTML = ""; // Clear previous items
+
+ console.log("Number of Links:", links);
+ const children = links.filter(
+ (link) => link.rel === "child" || link.rel === "items"
+ );
+ console.log("Number of Children:", children.length);
+
+ children.forEach((link) => {
+ createCatalogItem(link, itemsContainer);
+ });
+}
+
+function renderRequestBreakdown(request, descriptions) {
+ const container = document.getElementById("request-breakdown");
+ const format_value = (key, value) => {
+ return `" ${value} " `;
+ };
+
+ const format_values = (key, values) => {
+ if (values.length === 1) {
+ return format_value(key, values[0]);
+ }
+ return `[ ${values.map((v) => format_value(key, v)).join(`, `)}] `;
+ };
+
+ let html =
+ `{ \n` +
+ request
+ .map(
+ ([key, values]) =>
+ ` " ${key} " : ${format_values(key, values)}, `
+ )
+ .join("\n") +
+ `\n} `;
+ container.innerHTML = html;
+}
+
+function renderMARSRequest(request, descriptions) {
+ const container = document.getElementById("final_req");
+
+ console.log("=== renderMARSRequest START ===");
+ console.log("request:", request);
+ console.log("request type:", typeof request);
+ console.log("is array?", Array.isArray(request));
+ console.log("descriptions:", descriptions);
+
+ if (!Array.isArray(request)) {
+ console.error("ERROR: request is not an array!", request);
+ container.innerHTML = `ERROR: request is not an array. Got: ${typeof request}
${JSON.stringify(request, null, 2)} `;
+ return;
+ }
+
+ if (request.length === 0) {
+ console.warn("WARNING: request array is empty");
+ container.innerHTML = `No MARS requests generated
`;
+ return;
+ }
+
+ console.log("First request item:", request[0]);
+ console.log("First item entries:", Object.entries(request[0]));
+
+ const format_value = (key, value) => {
+ // Convert value to string if it's not already
+ const stringValue = String(value);
+ const desc = descriptions?.[key]?.["value_descriptions"]?.[stringValue];
+ return `" ${stringValue} " `;
+ };
+
+ const format_values = (key, values) => {
+ console.log(`format_values called: key=${key}, values=${values}, type=${typeof values}`);
+
+ // Handle different types of values
+ if (values === null || values === undefined) {
+ return `null `;
+ }
+
+ // Check if it's an array-like structure
+ let valueArray;
+ if (Array.isArray(values)) {
+ console.log(` -> is array, length ${values.length}`);
+ valueArray = values;
+ } else if (typeof values === 'object') {
+ console.log(` -> is object, stringify`);
+ // If it's an object, just JSON stringify it
+ return `${JSON.stringify(values)} `;
+ } else {
+ console.log(` -> is scalar (${typeof values}), wrapping in array`);
+ // Scalar value - wrap in array
+ valueArray = [values];
+ }
+
+ // If array is empty, return empty array repr
+ if (valueArray.length === 0) {
+ return `[] `;
+ }
+
+ // If array has single element, just return that
+ if (valueArray.length === 1) {
+ const result = format_value(key, valueArray[0]);
+ console.log(` -> returning single value: ${result}`);
+ return result;
+ }
+
+ // Multiple values - return as array
+ return `[ ${valueArray.map((v) => format_value(key, v)).join(`, `)}] `;
+ };
+
+ // Add feature object to each request if polygon is selected
+ const requestsWithFeature = selectedPolygon ? request.map(obj => ({
+ ...obj,
+ feature: {
+ type: "polygon",
+ shape: selectedPolygon
+ }
+ })) : request;
+
+ // Store for copying
+ currentMARSRequests = requestsWithFeature;
+
+ try {
+ let html =
+ `[ \n` +
+ requestsWithFeature
+ .map((obj, objIdx) => {
+ console.log(`Rendering object ${objIdx}:`, obj);
+ const entries = Object.entries(obj);
+ return ` { \n` +
+ entries
+ .map(
+ ([key, values], idx) => {
+ const isLast = idx === entries.length - 1;
+ if (key === "feature" && values && typeof values === "object" && values.type === "polygon") {
+ // Format the feature object specially
+ const shapeStr = JSON.stringify(values.shape, null, 0);
+ return ` " feature " : { \n` +
+ ` " type " : " ${values.type} " , \n` +
+ ` " shape " : ${shapeStr} \n` +
+ ` } ${isLast ? '' : ', '}`;
+ }
+ const formattedValue = format_values(key, values);
+ return ` " ${key} " : ${formattedValue}${isLast ? '' : ', '}`;
+ }
+ )
+ .join("\n") +
+ `\n } `;
+ })
+ .join(`, \n`) +
+ `\n] `;
+ container.innerHTML = html;
+ console.log("=== renderMARSRequest COMPLETED SUCCESSFULLY ===");
+ } catch (error) {
+ console.error("=== ERROR in renderMARSRequest ===", error);
+ console.error("Stack trace:", error.stack);
+ const container = document.getElementById("final_req");
+ container.innerHTML = `Error rendering MARS requests: ${error.message}
${JSON.stringify(request, null, 2)} ${error.stack} `;
+ }
+}
+
+function renderRawSTACResponse(catalog) {
+ const itemDetails = document.getElementById("raw-stac");
+ // create new object without debug key
+ let just_stac = Object.assign({}, catalog);
+ delete just_stac.debug;
+ itemDetails.textContent = JSON.stringify(just_stac, null, 2);
+
+ const debug_container = document.getElementById("debug");
+ debug_container.textContent = JSON.stringify(catalog.debug, null, 2);
+
+ const qube_container = document.getElementById("qube");
+ qube_container.innerHTML = catalog.debug.qube;
+}
+
+// Fetch STAC catalog and display items
+async function fetchCatalog(request, stacUrl) {
+ try {
+ const response = await fetch(stacUrl);
+ const catalog = await response.json();
+
+ console.log("Fetched catalog:", catalog);
+
+ // Check if we've reached the end of the catalogue (final_object has data)
+ const hasReachedEnd = catalog.final_object && catalog.final_object.length > 0;
+
+ console.log("Has reached end:", hasReachedEnd, "final_object:", catalog.final_object);
+
+ // Get section elements
+ const currentSelectionSection = document.getElementById("current-selection-section");
+ const marsRequestsSection = document.getElementById("mars-requests-section");
+ 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");
+ currentSelectionSection.style.display = "none";
+ marsRequestsSection.style.display = "block";
+ 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);
+ } else {
+ // Not at the end: show current selection, hide MARS requests, show next button
+ currentSelectionSection.style.display = "block";
+ marsRequestsSection.style.display = "none";
+ nextButton.style.display = "flex";
+ renderRequestBreakdown(request, catalog.debug.descriptions);
+ }
+
+ // Show the raw STAC in the sidebar
+ renderRawSTACResponse(catalog);
+
+ // Render the items from the catalog
+ if (catalog.links) {
+ console.log("Fetched STAC catalog:", stacUrl, catalog.links);
+ 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"));
+ hljs.highlightElement(document.getElementById("example-python"));
+ } catch (error) {
+ console.error("Error fetching STAC catalog:", error);
+ }
+}
+
+// Initialize the viewer by fetching the STAC catalog
+function initializeViewer() {
+ const stacUrl = getSTACUrlFromQuery();
+ const request = get_request_from_url();
+
+ if (stacUrl) {
+ console.log("Fetching STAC catalog from query string URL:", stacUrl);
+ fetchCatalog(request, stacUrl);
+ } else {
+ console.error("No STAC URL provided in the query string.");
+ }
+
+ // Add event listener for the "Generate STAC URL" button
+ const generateUrlBtn = document.getElementById("next-btn");
+ generateUrlBtn.addEventListener("click", goToNextUrl);
+
+ const previousUrlBtn = document.getElementById("previous-btn");
+ previousUrlBtn.addEventListener("click", goToPreviousUrl);
+
+ // Add event listener for the "Raw STAC" button
+ const stacAnchor = document.getElementById("stac-anchor");
+ stacAnchor.href = getSTACUrlFromQuery();
+}
+
+// Copy MARS requests to clipboard
+function copyMARSRequests() {
+ const copyBtn = document.getElementById("copy-mars-btn");
+ const btnText = copyBtn.querySelector(".copy-btn-text");
+
+ // Use the stored MARS requests with feature if available
+ const jsonContent = JSON.stringify(currentMARSRequests, null, 2);
+
+ navigator.clipboard.writeText(jsonContent).then(() => {
+ // Change button text temporarily
+ btnText.textContent = "Copied!";
+ copyBtn.classList.add("copied");
+
+ // Reset after 2 seconds
+ setTimeout(() => {
+ btnText.textContent = "Copy";
+ copyBtn.classList.remove("copied");
+ }, 2000);
+ }).catch(err => {
+ console.error("Failed to copy:", err);
+ btnText.textContent = "Failed";
+ setTimeout(() => {
+ btnText.textContent = "Copy";
+ }, 2000);
+ });
+}
+
+// Download JSON data as a file
+function downloadJSON(data, filename) {
+ const jsonString = JSON.stringify(data, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+// ============================================
+// Geographic Region Selection with Map
+// ============================================
+
+let regionMap = null;
+let drawnItems = null;
+let selectedPolygon = null;
+let currentMARSRequests = []; // Store current MARS requests for copying
+let catalogCache = null; // Store catalog for re-rendering when polygon changes
+
+function initializeRegionMap() {
+ const mapElement = document.getElementById('map');
+ if (!mapElement || regionMap) return;
+
+ // Initialize map centered on the world
+ regionMap = L.map('map').setView([20, 0], 2);
+
+ // Add OpenStreetMap tile layer
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors',
+ maxZoom: 18,
+ }).addTo(regionMap);
+
+ // Initialize the FeatureGroup to store editable layers
+ drawnItems = new L.FeatureGroup();
+ regionMap.addLayer(drawnItems);
+
+ // Initialize the draw control
+ const drawControl = new L.Control.Draw({
+ position: 'topright',
+ draw: {
+ polyline: false,
+ circle: false,
+ circlemarker: false,
+ marker: false,
+ rectangle: true,
+ polygon: {
+ allowIntersection: false,
+ showArea: true,
+ shapeOptions: {
+ color: '#0066cc',
+ weight: 2,
+ fillOpacity: 0.2
+ }
+ }
+ },
+ edit: {
+ featureGroup: drawnItems,
+ remove: true
+ }
+ });
+ regionMap.addControl(drawControl);
+
+ // Handle polygon creation
+ regionMap.on('draw:created', function (e) {
+ // Clear previous polygons
+ drawnItems.clearLayers();
+
+ const layer = e.layer;
+ drawnItems.addLayer(layer);
+
+ // Get the coordinates
+ const coordinates = layer.getLatLngs()[0].map(latlng => [
+ parseFloat(latlng.lat.toFixed(6)),
+ parseFloat(latlng.lng.toFixed(6))
+ ]);
+
+ // Close the polygon by adding the first point at the end
+ coordinates.push(coordinates[0]);
+
+ selectedPolygon = coordinates;
+ displaySelectedRegion(coordinates);
+ });
+
+ // Handle polygon edit
+ regionMap.on('draw:edited', function (e) {
+ const layers = e.layers;
+ layers.eachLayer(function (layer) {
+ const coordinates = layer.getLatLngs()[0].map(latlng => [
+ parseFloat(latlng.lat.toFixed(6)),
+ parseFloat(latlng.lng.toFixed(6))
+ ]);
+ coordinates.push(coordinates[0]);
+ selectedPolygon = coordinates;
+ displaySelectedRegion(coordinates);
+ });
+ });
+
+ // Handle polygon deletion
+ regionMap.on('draw:deleted', function (e) {
+ selectedPolygon = null;
+ document.getElementById('selected-region').style.display = 'none';
+ });
+
+ // Force map to resize properly
+ setTimeout(() => {
+ regionMap.invalidateSize();
+ }, 100);
+}
+
+function displaySelectedRegion(coordinates) {
+ const selectedRegionDiv = document.getElementById('selected-region');
+ const coordinatesDisplay = document.getElementById('region-coordinates');
+
+ const regionFeature = {
+ type: "polygon",
+ shape: coordinates
+ };
+
+ coordinatesDisplay.textContent = JSON.stringify({ feature: regionFeature }, null, 2);
+ selectedRegionDiv.style.display = 'block';
+
+ // Re-render MARS requests with the feature appended
+ if (catalogCache && catalogCache.final_object) {
+ 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 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
+ });
+ }
+
+ if (clearRegionBtn) {
+ clearRegionBtn.addEventListener('click', () => {
+ if (drawnItems) {
+ drawnItems.clearLayers();
+ }
+ selectedPolygon = null;
+ document.getElementById('selected-region').style.display = 'none';
+
+ // Re-render MARS requests without the feature
+ if (catalogCache && catalogCache.final_object) {
+ renderMARSRequest(catalogCache.final_object, catalogCache.debug.descriptions);
+ }
+ });
+ }
+});
+
+// ============================================
+// Polytope Query Handler
+// ============================================
+
+async function queryPolytope() {
+ const polytopeBtn = document.getElementById('polytope-btn');
+ const polytopeBtnText = document.getElementById('polytope-btn-text');
+ const polytopeStatus = document.getElementById('polytope-status');
+ const polytopeResults = document.getElementById('polytope-results');
+ const emailInput = document.getElementById('polytope-email');
+ const keyInput = document.getElementById('polytope-key');
+
+ if (!currentMARSRequests || currentMARSRequests.length === 0) {
+ polytopeStatus.textContent = 'No MARS requests available to query.';
+ polytopeStatus.className = 'polytope-status error';
+ polytopeStatus.style.display = 'block';
+ return;
+ }
+
+ // Validate credentials
+ const email = emailInput.value.trim();
+ const apiKey = keyInput.value.trim();
+
+ if (!email || !apiKey) {
+ polytopeStatus.textContent = 'Please provide both email and API key to query Polytope.';
+ polytopeStatus.className = 'polytope-status error';
+ polytopeStatus.style.display = 'block';
+ return;
+ }
+
+ // Disable button and show loading state
+ polytopeBtn.disabled = true;
+ polytopeBtnText.textContent = 'Querying...';
+ polytopeStatus.textContent = `Submitting ${currentMARSRequests.length} request(s) to Polytope service...`;
+ polytopeStatus.className = 'polytope-status loading';
+ polytopeStatus.style.display = 'block';
+ polytopeResults.innerHTML = '';
+ polytopeResults.style.display = 'none';
+
+ try {
+ const response = await fetch('/api/v2/polytope/query', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ requests: currentMARSRequests,
+ credentials: {
+ user_email: email,
+ user_key: apiKey
+ }
+ }),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.detail || 'Failed to query Polytope service');
+ }
+
+ // Show success message
+ polytopeStatus.textContent = `Successfully submitted ${result.total} request(s). ${result.successful} succeeded, ${result.failed} failed.`;
+ polytopeStatus.className = 'polytope-status success';
+
+ // Store results globally for notebook access
+ window.polytopeResults = result.results;
+
+ // Display detailed results
+ if (result.results && result.results.length > 0) {
+ polytopeResults.innerHTML = result.results.map((res, idx) => `
+
+
+
+ ${res.success
+ ? `Data retrieved successfully${res.data_size ? ` (${res.data_size})` : ''}`
+ : `Error: ${res.error || 'Unknown error'}`
+ }
+
+ ${res.message ? `
${res.message}
` : ''}
+ ${res.success && res.json_data ? `
+
+
+ 📥 Download JSON
+
+
+
+
+
+ Open in Notebook
+
+
+ ` : ''}
+
+ `).join('');
+ polytopeResults.style.display = 'block';
+
+ // Add event listeners to download buttons
+ document.querySelectorAll('.download-json-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const idx = parseInt(e.target.getAttribute('data-request-idx'));
+ const resultData = result.results[idx];
+ if (resultData && resultData.json_data) {
+ downloadJSON(resultData.json_data, `polytope_request_${idx + 1}.json`);
+ }
+ });
+ });
+
+ // Add event listeners to notebook buttons
+ document.querySelectorAll('.open-notebook-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const idx = parseInt(e.target.closest('.open-notebook-btn').getAttribute('data-request-idx'));
+ const resultData = result.results[idx];
+ if (resultData && resultData.json_data) {
+ openInNotebook(resultData.json_data, idx);
+ }
+ });
+ });
+ }
+
+ polytopeBtnText.textContent = 'Query Complete';
+ } catch (error) {
+ console.error('Polytope query error:', error);
+ polytopeStatus.textContent = `Error: ${error.message}`;
+ polytopeStatus.className = 'polytope-status error';
+ } finally {
+ // Re-enable button after a delay
+ setTimeout(() => {
+ polytopeBtn.disabled = false;
+ polytopeBtnText.textContent = 'Query Polytope Service';
+ }, 2000);
+ }
+}
+
+// ============================================
+// JupyterLite Notebook Integration
+// ============================================
+
+let codeEditor = null;
+let currentNotebookData = null;
+
+// Server-side execution - no Pyodide initialization needed
+// Python code runs on the server with full package support
+
+function initCodeEditor() {
+ if (codeEditor) {
+ return codeEditor;
+ }
+
+ const editorElement = document.getElementById('code-editor');
+ codeEditor = CodeMirror(editorElement, {
+ value: getDefaultNotebookCode(),
+ mode: 'python',
+ theme: 'monokai',
+ lineNumbers: true,
+ indentUnit: 4,
+ tabSize: 4,
+ indentWithTabs: false,
+ lineWrapping: true,
+ });
+
+ return codeEditor;
+}
+
+function getDefaultNotebookCode() {
+ return `# Polytope Data Visualization - Request 1
+# The data is available in the 'polytope_data' variable
+
+import json
+import numpy as np
+import covjsonkit
+import earthkit.plots
+
+from covjsonkit.api import Covjsonkit
+
+decoder = Covjsonkit().decode(polytope_data)
+
+ds = decoder.to_xarray()
+
+print(ds)
+
+# Handle missing/masked values
+if '2t' in ds:
+ data = ds['2t']
+ # Replace NaN with a fill value or drop them
+ data_filled = data.where(~np.isnan(data), drop=True)
+
+ chart = earthkit.plots.Map(domain="Germany")
+ chart.point_cloud(
+ data_filled,
+ x="longitude",
+ y="latitude",
+ auto_style=True
+ )
+
+ chart.coastlines()
+ chart.borders()
+ chart.gridlines()
+
+ chart.title("{variable_name} (number={number})")
+
+ chart.legend()
+else:
+ print("Variable '2t' not found in dataset")
+ print("Available variables:", list(ds.data_vars))
+
+# chart.show() # Not needed - figure is captured automatically
+`;
+}
+
+async function openInNotebook(jsonData, requestIdx) {
+ const notebookSection = document.getElementById('notebook-section');
+
+ // Show notebook section
+ notebookSection.style.display = 'block';
+ notebookSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+
+ // Store data globally
+ currentNotebookData = jsonData;
+ window.currentNotebookRequestIdx = requestIdx;
+
+ // Initialize code editor if not already done
+ if (!codeEditor) {
+ initCodeEditor();
+ }
+
+ // Update the default code with the request index
+ const defaultCode = `# Polytope Data Visualization - Request ${requestIdx + 1}
+# The data is available in the 'polytope_data' variable
+
+import json
+import numpy as np
+import covjsonkit
+import earthkit.plots
+
+from covjsonkit.api import Covjsonkit
+
+decoder = Covjsonkit().decode(polytope_data)
+
+ds = decoder.to_xarray()
+
+print(ds)
+
+# Handle missing/masked values
+if '2t' in ds:
+ data = ds['2t']
+ # Replace NaN with a fill value or drop them
+ data_filled = data.where(~np.isnan(data), drop=True)
+
+ chart = earthkit.plots.Map(domain="Germany")
+ chart.point_cloud(
+ data_filled,
+ x="longitude",
+ y="latitude",
+ auto_style=True
+ )
+
+ chart.coastlines()
+ chart.borders()
+ chart.gridlines()
+
+ chart.title("{variable_name} (number={number})")
+
+ chart.legend()
+else:
+ print("Variable '2t' not found in dataset")
+ print("Available variables:", list(ds.data_vars))
+
+# chart.show() # Not needed - figure is captured automatically
+`;
+
+ codeEditor.setValue(defaultCode);
+}
+
+async function runPythonCode() {
+ const runBtn = document.getElementById('run-code-btn');
+ const runBtnText = document.getElementById('run-code-text');
+ const outputDiv = document.getElementById('notebook-output');
+ const outputContent = document.getElementById('output-content');
+ const outputImages = document.getElementById('output-images');
+ const loadingDiv = document.getElementById('notebook-loading-exec');
+
+ if (!currentNotebookData) {
+ outputContent.textContent = 'Error: No data available. Please query Polytope first.';
+ outputDiv.style.display = 'block';
+ return;
+ }
+
+ // Disable button and show loading
+ runBtn.disabled = true;
+ runBtnText.textContent = 'Executing...';
+ loadingDiv.style.display = 'flex';
+ outputDiv.style.display = 'none';
+
+ try {
+ // Get code from editor
+ const code = codeEditor.getValue();
+
+ // Send code to server for execution
+ const response = await fetch('/api/v2/execute', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ code: code,
+ data: currentNotebookData
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ // Display output from stdout and stderr
+ let output = result.stdout || '';
+ if (result.stderr) {
+ output += '\n\nErrors/Warnings:\n' + result.stderr;
+ }
+ outputContent.textContent = output || '(No output)';
+
+ // Display images if any
+ outputImages.innerHTML = '';
+ if (result.images && result.images.length > 0) {
+ result.images.forEach((imgBase64, idx) => {
+ const img = document.createElement('img');
+ img.src = `data:image/png;base64,${imgBase64}`;
+ img.alt = `Plot ${idx + 1}`;
+ img.className = 'output-image';
+ outputImages.appendChild(img);
+ });
+ }
+ } else {
+ // Display error
+ outputContent.textContent = `Error (${result.error_type || 'Error'}): ${result.error}`;
+ outputImages.innerHTML = '';
+ }
+
+ outputDiv.style.display = 'block';
+ loadingDiv.style.display = 'none';
+ runBtnText.textContent = 'Run Code';
+ runBtn.disabled = false;
+
+ } catch (error) {
+ console.error('Python execution error:', error);
+ outputContent.textContent = `Error communicating with server: ${error.message}`;
+ outputImages.innerHTML = '';
+ outputDiv.style.display = 'block';
+ loadingDiv.style.display = 'none';
+ runBtnText.textContent = 'Run Code';
+ runBtn.disabled = false;
+ }
+}
+
+function resetCode() {
+ if (codeEditor) {
+ const requestIdx = window.currentNotebookRequestIdx || 0;
+ const defaultCode = `# Polytope Data Visualization - Request ${requestIdx + 1}
+# The data is available in the 'polytope_data' variable
+
+import json
+import numpy as np
+import covjsonkit
+import earthkit.plots
+
+from covjsonkit.api import Covjsonkit
+
+decoder = Covjsonkit().decode(polytope_data)
+
+ds = decoder.to_xarray()
+
+print(ds)
+
+# Handle missing/masked values
+if '2t' in ds:
+ data = ds['2t']
+ # Replace NaN with a fill value or drop them
+ data_filled = data.where(~np.isnan(data), drop=True)
+
+ chart = earthkit.plots.Map(domain="Germany")
+ chart.point_cloud(
+ data_filled,
+ x="longitude",
+ y="latitude",
+ auto_style=True
+ )
+
+ chart.coastlines()
+ chart.borders()
+ chart.gridlines()
+
+ chart.title("{variable_name} (number={number})")
+
+ chart.legend()
+else:
+ print("Variable '2t' not found in dataset")
+ print("Available variables:", list(ds.data_vars))
+
+# chart.show() # Not needed - figure is captured automatically
+`;
+ codeEditor.setValue(defaultCode);
+ }
+
+ // Clear output
+ const outputDiv = document.getElementById('notebook-output');
+ outputDiv.style.display = 'none';
+}
+
+function closeNotebook() {
+ const notebookSection = document.getElementById('notebook-section');
+ notebookSection.style.display = 'none';
+
+ // Clear output
+ const outputDiv = document.getElementById('notebook-output');
+ outputDiv.style.display = 'none';
+}
+
+// Call initializeViewer on page load
+initializeViewer();
+
+// Add event listener for copy button
+document.addEventListener("DOMContentLoaded", () => {
+ const copyBtn = document.getElementById("copy-mars-btn");
+ if (copyBtn) {
+ copyBtn.addEventListener("click", copyMARSRequests);
+ }
+
+ // Add event listener for Polytope button
+ const polytopeBtn = document.getElementById('polytope-btn');
+ if (polytopeBtn) {
+ polytopeBtn.addEventListener('click', queryPolytope);
+ }
+
+ // Add event listener for close notebook button
+ const closeNotebookBtn = document.getElementById('close-notebook-btn');
+ if (closeNotebookBtn) {
+ closeNotebookBtn.addEventListener('click', closeNotebook);
+ }
+
+ // Add event listener for run code button
+ const runCodeBtn = document.getElementById('run-code-btn');
+ if (runCodeBtn) {
+ runCodeBtn.addEventListener('click', runPythonCode);
+ }
+
+ // Add event listener for reset code button
+ const resetCodeBtn = document.getElementById('reset-code-btn');
+ if (resetCodeBtn) {
+ resetCodeBtn.addEventListener('click', resetCode);
+ }
+});
\ No newline at end of file
diff --git a/stac_server/static/logo.png b/stac_server/static/logo.png
new file mode 100644
index 0000000..5c44952
Binary files /dev/null and b/stac_server/static/logo.png differ
diff --git a/stac_server/static/qube_styles.css b/stac_server/static/qube_styles.css
new file mode 100644
index 0000000..45e81f5
--- /dev/null
+++ b/stac_server/static/qube_styles.css
@@ -0,0 +1,50 @@
+pre#qube {
+ font-family: monospace;
+ white-space: pre;
+ font-family: SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;
+ font-size: 12px;
+ line-height: 1.4;
+
+ details {
+ margin-left: 0;
+ }
+
+ .qubed-level a {
+ margin-left: 10px;
+ text-decoration: none;
+ }
+
+ summary {
+ list-style: none;
+ cursor: pointer;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ text-wrap: nowrap;
+ display: block;
+ }
+
+ span.qubed-node:hover {
+ background-color: #f0f0f0;
+ }
+
+ details > summary::after {
+ content: ' ▲';
+ }
+
+ details:not([open]) > summary::after {
+ content: " ▼";
+ }
+
+ .qubed-level {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ text-wrap: nowrap;
+ display: block;
+ }
+
+ summary::-webkit-details-marker {
+ display: none;
+ content: "";
+ }
+
+}
\ No newline at end of file
diff --git a/stac_server/static/styles.css b/stac_server/static/styles.css
new file mode 100644
index 0000000..91cc9c1
--- /dev/null
+++ b/stac_server/static/styles.css
@@ -0,0 +1,1749 @@
+/* ============================================
+ Modern Professional Catalogue Styles
+ ============================================ */
+
+/* CSS Variables for consistent theming */
+:root {
+ --primary-color: #0073E6;
+ --primary-dark: #2F3842;
+ --primary-light: #009DEB;
+ --secondary-color: #009DEB;
+ --accent-color: #FF6B9D;
+
+ --bg-primary: #ffffff;
+ --bg-secondary: #F6F9FC;
+ --bg-tertiary: #F2F7FD;
+ --bg-dark: #2F3842;
+
+ --text-primary: #100F0F;
+ --text-secondary: #B1B5C3;
+ --text-light: #adb5bd;
+ --text-inverse: #ffffff;
+
+ --border-color: #dee2e6;
+ --border-light: #e9ecef;
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.12);
+ --shadow-hover: 0 8px 16px rgba(0, 0, 0, 0.15);
+
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+
+ --transition-fast: 0.15s ease;
+ --transition-base: 0.25s ease;
+ --transition-slow: 0.35s ease;
+
+ --header-height: 70px;
+ --sidebar-width: 420px;
+}
+
+/* Reset & Base Styles */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+html,
+body {
+ min-height: 100vh;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ font-size: 15px;
+ line-height: 1.6;
+ color: var(--text-primary);
+ background-color: var(--bg-secondary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ============================================
+ Header Styles
+ ============================================ */
+
+.main-header {
+ background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
+ color: var(--text-inverse);
+ box-shadow: var(--shadow-md);
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+ height: var(--header-height);
+}
+
+.header-content {
+ max-width: 1800px;
+ margin: 0 auto;
+ padding: 0 2rem;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.logo-icon {
+ font-size: 2rem;
+ line-height: 1;
+}
+
+.site-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin: 0;
+ letter-spacing: -0.02em;
+}
+
+.header-right {
+ display: flex;
+ gap: 1rem;
+}
+
+.header-link {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: var(--radius-sm);
+ color: var(--text-inverse);
+ text-decoration: none;
+ font-weight: 500;
+ transition: background var(--transition-base);
+}
+
+.header-link:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+/* ============================================
+ Layout
+ ============================================ */
+
+#viewer {
+ display: flex;
+ flex-direction: row;
+ min-height: calc(100vh - var(--header-height));
+ max-width: 1800px;
+ margin: 0 auto;
+}
+
+/* ============================================
+ Sidebar / Catalog List
+ ============================================ */
+
+#catalog-list {
+ flex: 0 0 var(--sidebar-width);
+ background-color: var(--bg-primary);
+ border-right: 1px solid var(--border-color);
+ overflow-y: auto;
+ box-shadow: var(--shadow-sm);
+ transition: flex-basis var(--transition-base);
+}
+
+#catalog-list.region-active {
+ flex: 0 0 650px;
+}
+
+.sidebar-sticky {
+ padding: 2rem 1.5rem;
+}
+
+.sidebar-intro {
+ margin-bottom: 2rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 2px solid var(--border-light);
+}
+
+.instruction-text {
+ margin: 0 0 0.75rem 0;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+.update-time {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 1rem 0 0 0;
+ padding: 0.75rem 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+
+/* ============================================
+ Navigation Controls
+ ============================================ */
+
+.sidebar-nav {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.nav-btn {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ box-shadow: var(--shadow-sm);
+}
+
+.nav-btn.primary {
+ background: var(--primary-color);
+ color: var(--text-inverse);
+}
+
+.nav-btn.primary:hover {
+ background: var(--primary-dark);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.nav-btn.secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.nav-btn.secondary:hover {
+ background: var(--border-color);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.nav-btn:active {
+ transform: translateY(0);
+}
+
+/* ============================================
+ Catalog Items / Cards
+ ============================================ */
+
+.catalog-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+.catalog-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.item {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-primary);
+ border: 2px solid var(--border-light);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ transition: all var(--transition-base);
+ box-shadow: var(--shadow-sm);
+}
+
+.item:hover {
+ border-color: var(--primary-light);
+ box-shadow: var(--shadow-hover);
+ transform: translateY(-2px);
+}
+
+.item.selected {
+ background: linear-gradient(135deg, #F2F7FD 0%, #F6F9FC 100%);
+ border-color: var(--primary-color);
+ box-shadow: var(--shadow-md);
+}
+
+.item-title {
+ font-size: 1.15rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem 0;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+
+.item-type {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-size: 0.75rem;
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ margin-bottom: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.item-description {
+ font-size: 0.9rem;
+ margin: 0.5rem 0 1rem 0;
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+/* Select All Button */
+.item button.all {
+ position: absolute;
+ right: 1.25rem;
+ top: 1.25rem;
+ width: 2rem;
+ height: 2rem;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 2px solid var(--border-color);
+ border-radius: 50%;
+ font-size: 1.2rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.item button.all:hover {
+ background: var(--primary-color);
+ color: var(--text-inverse);
+ border-color: var(--primary-color);
+ transform: scale(1.1);
+}
+
+/* ============================================
+ Checkbox Container
+ ============================================ */
+
+.checkbox-container {
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ grid-auto-rows: auto;
+ grid-column-gap: 0.75rem;
+ grid-row-gap: 0.75rem;
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 1rem;
+ margin-top: 1rem;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-color) transparent;
+}
+
+.checkbox-container::-webkit-scrollbar {
+ width: 6px;
+}
+
+.checkbox-container::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.checkbox-container::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 3px;
+}
+
+.checkbox-container::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+.checkbox-container[disabled] {
+ background-color: #e9ecef;
+ opacity: 0.7;
+}
+
+.checkbox-row {
+ display: contents;
+}
+
+.checkbox-row:hover > * {
+ color: var(--primary-color);
+}
+
+.checkbox-row:hover > input[type='checkbox'] {
+ box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.15);
+}
+
+.checkbox-row input[type='checkbox'] {
+ grid-column-start: 1;
+ width: 1.25rem;
+ height: 1.25rem;
+ align-self: center;
+ cursor: pointer;
+ accent-color: var(--primary-color);
+ transition: all var(--transition-fast);
+}
+
+.checkbox-row a.more-info {
+ cursor: help;
+ text-decoration: none;
+ font-size: 0.7rem;
+ font-weight: 600;
+ display: inline-flex;
+ width: 1.2rem;
+ height: 1.2rem;
+ padding: 0;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 1 / 1;
+ border-radius: 50%;
+ border: 2px solid var(--text-secondary);
+ color: var(--text-secondary);
+ transition: all var(--transition-fast);
+}
+
+.checkbox-row a.more-info:hover {
+ background: var(--primary-color);
+ border-color: var(--primary-color);
+ color: var(--text-inverse);
+ transform: scale(1.15);
+}
+
+.checkbox-row label {
+ grid-column-start: 2;
+ align-self: center;
+ font-size: 0.9rem;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: color var(--transition-fast);
+}
+
+.checkbox-row label.code {
+ grid-column-start: 3;
+ text-align: right;
+ align-self: center;
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+/* ============================================
+ Filter Input for Checkbox Lists
+ ============================================ */
+
+.filter-wrapper {
+ margin-top: 1rem;
+ margin-bottom: 0.75rem;
+}
+
+.filter-input {
+ width: 100%;
+ padding: 0.625rem 0.875rem 0.625rem 2.25rem;
+ border: 2px solid var(--border-color);
+ border-radius: var(--radius-md);
+ font-size: 0.9rem;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ transition: all var(--transition-base);
+ background-image: url('data:image/svg+xml, ');
+ background-repeat: no-repeat;
+ background-position: 0.75rem center;
+ background-size: 16px 16px;
+}
+
+.filter-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1);
+}
+
+.filter-input::placeholder {
+ color: var(--text-light);
+}
+
+/* Sidebar Footer */
+.sidebar-footer {
+ margin-top: 2rem;
+ padding-top: 1.5rem;
+ border-top: 2px solid var(--border-light);
+}
+
+.text-link {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-md);
+ color: var(--primary-color);
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 0.9rem;
+ transition: all var(--transition-base);
+}
+
+.text-link:hover {
+ background: var(--primary-color);
+ color: var(--text-inverse);
+ box-shadow: var(--shadow-md);
+}
+
+/* ============================================
+ Main Content Area
+ ============================================ */
+
+#details {
+ flex: 1;
+ padding: 2rem;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: var(--bg-secondary);
+ max-width: 100%;
+}
+
+.detail-section {
+ background: var(--bg-primary);
+ border-radius: var(--radius-lg);
+ padding: 2rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--shadow-sm);
+ border: 1px solid var(--border-light);
+}
+
+.detail-section.collapsible {
+ padding: 0;
+}
+
+.detail-section.collapsible summary {
+ padding: 1.5rem 2rem;
+ cursor: pointer;
+ transition: background var(--transition-base);
+ border-radius: var(--radius-lg);
+}
+
+.detail-section.collapsible summary:hover {
+ background: var(--bg-secondary);
+}
+
+.detail-section.collapsible[open] summary {
+ border-bottom: 1px solid var(--border-light);
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+}
+
+.detail-section.collapsible > *:not(summary) {
+ padding: 0 2rem 2rem 2rem;
+}
+
+.section-title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0 0 1rem 0;
+ color: var(--text-primary);
+ letter-spacing: -0.02em;
+}
+
+.section-title svg {
+ color: var(--primary-color);
+}
+
+.section-description {
+ margin: 0 0 1.25rem 0;
+ color: var(--text-secondary);
+ font-size: 0.95rem;
+ line-height: 1.6;
+}
+
+.section-description a {
+ color: var(--primary-color);
+ text-decoration: none;
+ font-weight: 500;
+ transition: color var(--transition-fast);
+}
+
+.section-description a:hover {
+ color: var(--primary-dark);
+ text-decoration: underline;
+}
+
+/* ============================================
+ Code Blocks
+ ============================================ */
+
+.code-block {
+ background: var(--bg-dark);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.code-block pre {
+ margin: 0;
+ padding: 1.5rem;
+ overflow-x: auto;
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
+ font-size: 0.85rem;
+ line-height: 1.6;
+ scrollbar-width: thin;
+ scrollbar-color: var(--text-secondary) transparent;
+}
+
+.code-block pre::-webkit-scrollbar {
+ height: 8px;
+}
+
+.code-block pre::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.code-block pre::-webkit-scrollbar-thumb {
+ background: var(--text-secondary);
+ border-radius: 4px;
+}
+
+.code-block code {
+ font-family: inherit;
+ font-size: inherit;
+}
+
+#final_req,
+#qube {
+ margin: 0;
+ padding: 1.5rem;
+ background: var(--bg-dark);
+ color: #e9ecef;
+ border-radius: var(--radius-md);
+ overflow-x: auto;
+ font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
+ font-size: 0.85rem;
+ line-height: 1.6;
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+/* Syntax Highlighting Enhancements */
+span.key {
+ color: #4fc3f7;
+ font-weight: 600;
+}
+
+span.value {
+ color: #81c784;
+}
+
+span.key:hover,
+span.value:hover {
+ color: #ffeb3b;
+ cursor: help;
+}
+
+span.punct {
+ color: #e0e0e0;
+ font-weight: 500;
+}
+
+/* ============================================
+ Form Elements
+ ============================================ */
+
+input[type="text"] {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 2px solid var(--border-color);
+ border-radius: var(--radius-md);
+ font-size: 0.9rem;
+ font-family: inherit;
+ transition: all var(--transition-base);
+ background: var(--bg-primary);
+}
+
+input[type="text"]:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1);
+}
+
+/* ============================================
+ Utility Classes
+ ============================================ */
+
+.has-data {
+ background-color: rgba(129, 199, 132, 0.2);
+ border: 2px solid #81c784 !important;
+}
+
+.list-label {
+ font-weight: 600;
+ margin-bottom: 0.75rem;
+ display: block;
+ color: var(--primary-color);
+ font-size: 0.9rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Date Picker Styles */
+.date-picker-input {
+ width: 100%;
+ margin-top: 1rem;
+ border: none;
+ background: transparent;
+}
+
+.date-picker-hint {
+ margin: 0.75rem 0 1rem 0;
+ padding: 0.75rem 1rem;
+ background: linear-gradient(135deg, #F2F7FD 0%, #F6F9FC 100%);
+ border-left: 4px solid var(--primary-color);
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ line-height: 1.5;
+ color: var(--text-secondary);
+ font-weight: 500;
+ transition: all var(--transition-base);
+}
+
+.date-picker-hint:empty {
+ display: none;
+}
+
+div.air-datepicker {
+ width: 100% !important;
+ background: var(--bg-primary);
+ border: 2px solid var(--border-light);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+ font-family: inherit;
+}
+
+div.air-datepicker .air-datepicker-nav {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-light);
+ padding: 0.75rem;
+}
+
+div.air-datepicker .air-datepicker-nav--title {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+div.air-datepicker .air-datepicker-nav--action {
+ color: var(--primary-color);
+}
+
+div.air-datepicker .air-datepicker-nav--action:hover {
+ background: var(--primary-light);
+}
+
+div.air-datepicker .air-datepicker-body--day-name {
+ color: var(--text-secondary);
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+}
+
+div.air-datepicker .air-datepicker-cell {
+ color: var(--text-primary);
+ border-radius: var(--radius-sm);
+}
+
+div.air-datepicker .air-datepicker-cell.-disabled- {
+ color: var(--text-light);
+ opacity: 0.25;
+ cursor: not-allowed;
+ background: transparent;
+ text-decoration: line-through;
+}
+
+div.air-datepicker .air-datepicker-cell:not(.-disabled-):hover {
+ background: var(--primary-light);
+ color: var(--text-inverse);
+ cursor: pointer;
+ transform: scale(1.05);
+ transition: all var(--transition-fast);
+}
+
+div.air-datepicker .air-datepicker-cell.-selected- {
+ background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%) !important;
+ color: var(--text-inverse) !important;
+ font-weight: 700;
+ box-shadow: 0 2px 8px rgba(0, 115, 230, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ border: none !important;
+}
+
+div.air-datepicker .air-datepicker-cell.-in-range- {
+ background: linear-gradient(135deg, rgba(0, 115, 230, 0.2) 0%, rgba(0, 115, 230, 0.15) 100%) !important;
+ color: var(--primary-dark);
+ font-weight: 600;
+ border-top: 1px solid rgba(0, 115, 230, 0.3);
+ border-bottom: 1px solid rgba(0, 115, 230, 0.3);
+}
+
+div.air-datepicker .air-datepicker-cell.-range-from-,
+div.air-datepicker .air-datepicker-cell.-range-to- {
+ background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%) !important;
+ color: var(--text-inverse) !important;
+ font-weight: 700;
+ box-shadow: 0 2px 8px rgba(0, 115, 230, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
+ border: none !important;
+}
+
+/* Subtle indicator for dates with available data */
+div.air-datepicker .air-datepicker-cell.has-data {
+ font-weight: 600;
+ position: relative;
+ background: rgba(0, 115, 230, 0.06);
+ border-bottom: 2px solid rgba(0, 115, 230, 0.4);
+}
+
+div.air-datepicker .air-datepicker-cell.has-data:not(.-disabled-) {
+ color: var(--primary-dark);
+}
+
+/* When dates are selected, make them darker and more prominent */
+div.air-datepicker .air-datepicker-cell.-selected-.has-data,
+div.air-datepicker .air-datepicker-cell.-range-from-.has-data,
+div.air-datepicker .air-datepicker-cell.-range-to-.has-data {
+ background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%) !important;
+ color: var(--text-inverse) !important;
+ border-bottom: 2px solid var(--primary-dark);
+ font-weight: 700;
+ box-shadow: 0 3px 10px rgba(47, 56, 66, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.15);
+}
+
+div.air-datepicker .air-datepicker-cell.-in-range-.has-data {
+ background: linear-gradient(135deg, rgba(0, 115, 230, 0.25) 0%, rgba(0, 115, 230, 0.2) 100%) !important;
+ color: var(--primary-dark);
+ border-bottom: 2px solid rgba(0, 115, 230, 0.5);
+ font-weight: 600;
+ border-top: 1px solid rgba(0, 115, 230, 0.4);
+}
+
+/* ============================================
+ Responsive Design
+ ============================================ */
+
+@media (max-width: 1200px) {
+ :root {
+ --sidebar-width: 360px;
+ }
+
+ .header-content {
+ padding: 0 1.5rem;
+ }
+
+ .site-title {
+ font-size: 1.25rem;
+ }
+}
+
+@media (max-width: 968px) {
+ #viewer {
+ flex-direction: column;
+ }
+
+ #catalog-list {
+ flex: 0 0 auto;
+ width: 100%;
+ border-right: none;
+ border-bottom: 1px solid var(--border-color);
+ max-height: 60vh;
+ }
+
+ #details {
+ width: 100%;
+ }
+
+ .header-content {
+ padding: 0 1rem;
+ }
+
+ .site-title {
+ font-size: 1.1rem;
+ }
+
+ .sidebar-nav {
+ flex-direction: column;
+ }
+}
+
+@media (max-width: 640px) {
+ :root {
+ --header-height: 60px;
+ }
+
+ .header-content {
+ padding: 0 1rem;
+ }
+
+ .site-title {
+ font-size: 1rem;
+ }
+
+ .logo-icon {
+ font-size: 1.5rem;
+ }
+
+ .sidebar-sticky {
+ padding: 1.5rem 1rem;
+ }
+
+ #details {
+ padding: 1.5rem 1rem;
+ }
+
+ .detail-section {
+ padding: 1.5rem;
+ }
+
+ .detail-section.collapsible summary {
+ padding: 1rem 1.5rem;
+ }
+
+ .detail-section.collapsible > *:not(summary) {
+ padding: 0 1.5rem 1.5rem 1.5rem;
+ }
+
+ .item {
+ padding: 1.25rem;
+ }
+
+ .item button.all {
+ right: 1rem;
+ top: 1rem;
+ width: 1.75rem;
+ height: 1.75rem;
+ font-size: 1rem;
+ }
+}
+
+/* ============================================
+ Print Styles
+ ============================================ */
+
+@media print {
+ .main-header,
+ .sidebar-nav,
+ .sidebar-footer,
+ button {
+ display: none;
+ }
+
+ #viewer {
+ flex-direction: column;
+ }
+
+ #catalog-list,
+ #details {
+ width: 100%;
+ border: none;
+ box-shadow: none;
+ }
+}
+
+/* ============================================
+ Geographic Region Selection
+ ============================================ */
+
+.region-selection {
+ margin-top: 2rem;
+ padding: 1.5rem;
+ background: var(--bg-secondary);
+ border: 2px solid var(--border-light);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+.region-title {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0 0 0.5rem 0;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.region-description {
+ margin: 0 0 1rem 0;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ line-height: 1.5;
+}
+
+.region-controls {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+ flex-wrap: wrap;
+}
+
+.region-btn {
+ flex: 1;
+ min-width: 120px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ box-shadow: var(--shadow-sm);
+}
+
+.region-btn.primary {
+ background: var(--primary-color);
+ color: var(--text-inverse);
+}
+
+.region-btn.primary:hover {
+ background: var(--primary-dark);
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.region-btn.secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 2px solid var(--border-color);
+}
+
+.region-btn.secondary:hover {
+ background: var(--bg-tertiary);
+ border-color: var(--text-secondary);
+}
+
+#map-container {
+ margin-top: 1.5rem;
+}
+
+.map-instructions {
+ margin-bottom: 1rem;
+ padding: 0.75rem 1rem;
+ background: var(--bg-primary);
+ border-left: 4px solid var(--primary-color);
+ border-radius: var(--radius-sm);
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.map-instructions p {
+ margin: 0;
+ line-height: 1.5;
+}
+
+.map-instructions strong {
+ color: var(--text-primary);
+}
+
+#map {
+ border: 2px solid var(--border-light);
+ box-shadow: var(--shadow-md);
+}
+
+.selected-region {
+ margin-top: 1.5rem;
+ padding: 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-md);
+}
+
+.selected-region h4 {
+ margin: 0 0 1rem 0;
+ color: var(--text-primary);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.selected-region .code-block {
+ margin-bottom: 1rem;
+}
+
+.selected-region pre {
+ margin: 0;
+ padding: 1rem;
+ background: var(--bg-dark);
+ color: #e9ecef;
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ line-height: 1.6;
+ overflow-x: auto;
+}
+
+/* ============================================
+ Animations
+ ============================================ */
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.item {
+ animation: fadeIn var(--transition-base) ease-out;
+}
+
+/* Loading State */
+.item.loading {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ pointer-events: none;
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Focus Visible for Accessibility */
+:focus-visible {
+ outline: 3px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
+button:focus-visible,
+a:focus-visible {
+ outline: 3px solid var(--accent-color);
+}
+
+/* Canvas Elements */
+canvas {
+ width: 100%;
+ height: 300px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ margin-top: 1.5rem;
+}
+
+/* ============================================
+ Copy Button for Code Blocks
+ ============================================ */
+
+.code-block-with-copy {
+ position: relative;
+}
+
+.copy-btn {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
+ border: 2px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ z-index: 10;
+ box-shadow: var(--shadow-sm);
+}
+
+.copy-btn:hover {
+ background: var(--primary-color);
+ color: var(--text-inverse);
+ border-color: var(--primary-color);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.copy-btn.copied {
+ background: #28a745;
+ color: white;
+ border-color: #28a745;
+}
+
+.copy-btn svg {
+ flex-shrink: 0;
+}
+
+@media (max-width: 640px) {
+ .copy-btn {
+ padding: 0.4rem 0.75rem;
+ font-size: 0.8rem;
+ }
+
+ .copy-btn-text {
+ display: none;
+ }
+}
+
+/* ============================================
+ Polytope Query Section
+ ============================================ */
+
+.polytope-section {
+ margin-top: 2rem;
+ padding: 1.5rem;
+ background: linear-gradient(135deg, #F6F9FC 0%, #F2F7FD 100%);
+ border: 2px solid var(--border-light);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+.polytope-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin: 0 0 0.75rem 0;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.polytope-description {
+ margin: 0 0 1.25rem 0;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+.polytope-auth-form {
+ margin-bottom: 1.5rem;
+ padding: 1.25rem;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+}
+
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group:last-child {
+ margin-bottom: 0;
+}
+
+.form-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
+.form-input {
+ width: 100%;
+ padding: 0.75rem;
+ border: 2px solid var(--border-light);
+ border-radius: var(--radius-sm);
+ font-size: 0.9rem;
+ font-family: inherit;
+ color: var(--text-primary);
+ background: var(--bg-primary);
+ transition: all var(--transition-base);
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1);
+}
+
+.form-input::placeholder {
+ color: var(--text-light);
+}
+
+.form-hint {
+ margin: 0.5rem 0 0 0;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+}
+
+.form-hint a {
+ color: var(--primary-color);
+ text-decoration: none;
+}
+
+.form-hint a:hover {
+ text-decoration: underline;
+}
+
+.polytope-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
+ color: var(--text-inverse);
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all var(--transition-base);
+ box-shadow: var(--shadow-md);
+}
+
+.polytope-btn:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-lg);
+}
+
+.polytope-btn:active {
+ transform: translateY(0);
+}
+
+.polytope-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.polytope-status {
+ margin-top: 1.25rem;
+ padding: 1rem;
+ background: var(--bg-primary);
+ border-left: 4px solid var(--primary-color);
+ border-radius: var(--radius-sm);
+ font-size: 0.9rem;
+ color: var(--text-primary);
+}
+
+.polytope-status.loading {
+ border-left-color: var(--secondary-color);
+}
+
+.polytope-status.success {
+ border-left-color: #28a745;
+}
+
+.polytope-status.error {
+ border-left-color: var(--accent-color);
+ color: #dc3545;
+}
+
+.polytope-results {
+ margin-top: 1.25rem;
+}
+
+.polytope-result-item {
+ padding: 1rem;
+ margin-bottom: 0.75rem;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+}
+
+.polytope-result-item.success {
+ border-left: 4px solid #28a745;
+}
+
+.polytope-result-item.error {
+ border-left: 4px solid #dc3545;
+}
+
+.polytope-result-header {
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ color: var(--text-primary);
+ font-size: 0.9rem;
+}
+
+.polytope-result-detail {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ font-family: 'Monaco', 'Courier New', monospace;
+}
+.download-json-btn {
+ transition: all 0.2s ease;
+}
+
+.download-json-btn:hover {
+ background: var(--secondary-color) !important;
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(0, 115, 230, 0.3);
+}
+
+.download-json-btn:active {
+ transform: translateY(0);
+}
+/* ============================================
+ JupyterLite Notebook Section
+ ============================================ */
+
+.notebook-section {
+ background: var(--bg-primary);
+ padding: 2rem;
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-light);
+ margin-bottom: 2rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.notebook-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.notebook-title {
+ font-size: 1.3rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 0;
+}
+
+.notebook-title svg {
+ color: var(--primary-color);
+}
+
+.close-notebook-btn {
+ background: transparent;
+ border: 1px solid var(--border-light);
+ color: var(--text-secondary);
+ padding: 0.5rem;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.close-notebook-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-color: var(--primary-color);
+}
+
+.notebook-description {
+ color: var(--text-secondary);
+ margin-bottom: 1.5rem;
+ line-height: 1.6;
+}
+
+.notebook-description code {
+ background: var(--bg-secondary);
+ padding: 0.2rem 0.5rem;
+ border-radius: 3px;
+ font-size: 0.9em;
+ color: var(--primary-color);
+ font-family: 'Monaco', 'Courier New', monospace;
+}
+
+.notebook-controls {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.run-code-btn,
+.reset-code-btn {
+ padding: 0.6rem 1.2rem;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: all 0.2s ease;
+}
+
+.run-code-btn {
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ color: white;
+}
+
+.run-code-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(0, 115, 230, 0.4);
+}
+
+.run-code-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.reset-code-btn {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-light);
+}
+
+.reset-code-btn:hover {
+ background: var(--bg-tertiary);
+ border-color: var(--primary-color);
+}
+
+.package-installer {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ margin-bottom: 1rem;
+}
+
+.package-installer-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 0.5rem 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.package-installer-title svg {
+ color: var(--primary-color);
+}
+
+.package-installer-hint {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin: 0 0 0.75rem 0;
+ line-height: 1.4;
+}
+
+.package-installer-controls {
+ display: flex;
+ gap: 0.5rem;
+ align-items: stretch;
+}
+
+.package-input {
+ flex: 1;
+ padding: 0.6rem;
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+ font-family: 'Monaco', 'Courier New', monospace;
+ transition: border-color 0.2s ease;
+}
+
+.package-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(0, 115, 230, 0.1);
+}
+
+.package-input::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.6;
+}
+
+.install-package-btn {
+ padding: 0.6rem 1.2rem;
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ color: white;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.install-package-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(0, 115, 230, 0.4);
+}
+
+.install-package-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.package-install-status {
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ font-family: 'Monaco', 'Courier New', monospace;
+ line-height: 1.5;
+}
+
+.package-install-status.loading {
+ background: rgba(0, 115, 230, 0.1);
+ border: 1px solid rgba(0, 115, 230, 0.2);
+ color: var(--primary-color);
+}
+
+.package-install-status.success {
+ background: rgba(40, 167, 69, 0.1);
+ border: 1px solid rgba(40, 167, 69, 0.2);
+ color: #28a745;
+}
+
+.package-install-status.error {
+ background: rgba(220, 53, 69, 0.1);
+ border: 1px solid rgba(220, 53, 69, 0.2);
+ color: #dc3545;
+}
+
+.code-editor {
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ margin-bottom: 1rem;
+}
+
+.CodeMirror {
+ height: 400px;
+ font-size: 14px;
+ font-family: 'Monaco', 'Courier New', monospace;
+}
+
+.notebook-output {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-light);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ margin-top: 1rem;
+}
+
+.output-title {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 0.75rem 0;
+}
+
+.output-content {
+ margin: 0;
+ padding: 0.75rem;
+ background: #1e1e1e;
+ color: #d4d4d4;
+ border-radius: var(--radius-sm);
+ font-family: 'Monaco', 'Courier New', monospace;
+ font-size: 0.85rem;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.output-images {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+.output-image {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--radius-md);
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ background: white;
+ display: block;
+}
+
+.notebook-loading-exec {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-md);
+ color: var(--text-secondary);
+ margin-top: 1rem;
+}
+
+.loading-spinner-small {
+ width: 20px;
+ height: 20px;
+ border: 3px solid var(--border-light);
+ border-top: 3px solid var(--primary-color);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 4px solid var(--border-light);
+ border-top: 4px solid var(--primary-color);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 1rem;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.open-notebook-btn {
+ margin-top: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ color: white;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ transition: all 0.2s ease;
+}
+
+.open-notebook-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 16px rgba(0, 115, 230, 0.4);
+}
+
+.open-notebook-btn:active {
+ transform: translateY(0);
+}
+
+.open-notebook-btn svg {
+ width: 16px;
+ height: 16px;
+}
\ No newline at end of file
diff --git a/stac_server/templates/index.html b/stac_server/templates/index.html
new file mode 100644
index 0000000..45b52e4
--- /dev/null
+++ b/stac_server/templates/index.html
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+ {{title}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Current Selection
+
+
+ This is a MARS Selection object in JSON format. Hover over keys or values for more information.
+
+
+
+
+
+
+
+
+
+ Selected MARS Requests
+
+
+ These are the complete MARS requests matching your selection criteria. You can now use these to retrieve data.
+
+
+
+
+
+
+
+
+
+ Query Data with Polytope
+
+
Extract the actual data for these MARS requests using the Destination Earth Polytope data extraction service. You'll need to provide your Destination Earth credentials.
+
+
+
+
+
+
+
+
+ Query Polytope Service
+
+
+
+
+
+
+
+
+
+ Write Python code to visualize and analyze the data. The data is available in the polytope_data variable.
+ Pre-installed packages: numpy , covjsonkit , earthkit-plots , xarray , matplotlib .
+
+
+
+
+
+
+
+ Run Code
+
+
+
+
+
+
+ Reset
+
+
+
+
+
+
+
+
+
+
Executing Python code...
+
+
+
+
+
+
+
+
+
+ Currently Selected Tree
+
+
+ This shows the data qube that matches the current query. The leaves are the next set of available selections you can make.
+
+
+
+
+
+
+
+
+
+
+ Example Qubed Code
+
+
+
+ See the Qubed documentation for more details.
+
+
+
# pip install qubed requests
+import requests
+from qubed import Qube
+qube = Qube.from_json(requests.get("{{ api_url }}select/?{{request.url.query}}").json())
+qube.print()
+
+
+
+
+
+
+
+
+
+ Raw STAC Response
+
+
+
+ See the STAC Extension Proposal for more details on the format.
+
+
+
+
+
+
+
+
+
+
+ Debug Info
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/stac_server/templates/landing.html b/stac_server/templates/landing.html
new file mode 100644
index 0000000..4cafd05
--- /dev/null
+++ b/stac_server/templates/landing.html
@@ -0,0 +1,526 @@
+
+
+
+
+
+
+ {{title}} - Welcome
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🌍
+
Climate DT
+
Digital Twin for Climate Change Adaptation - Long-term climate projections and historical climate data
+
+
+
+
⚡
+
Extremes DT
+
Digital Twin for Weather-Induced Extremes - High-resolution extreme weather events and forecasts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/stac_server/test_api.py b/stac_server/test_api.py
new file mode 100644
index 0000000..3ab3a14
--- /dev/null
+++ b/stac_server/test_api.py
@@ -0,0 +1,66 @@
+import os
+from pathlib import Path
+from fastapi.testclient import TestClient
+
+os.environ["API_KEY"] = "testkey"
+
+
+from . import main
+
+app_client = TestClient(main.app)
+
+
+def test_root_page_renders_html():
+ resp = app_client.get("/")
+ assert resp.status_code == 200
+ # Fast check that we served HTML from the Jinja template
+ assert resp.headers.get("content-type", "").startswith("text/html")
+
+
+def test_get_returns_qube_json_object():
+ resp = app_client.get("/api/v2/get/")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, dict)
+ # Should not be empty given example qubes are loaded via config/config.yaml
+ assert len(data) > 0
+ # Should be the same as the example extremes_dt qube
+
+
+def test_query_returns_axes_list():
+ resp = app_client.get("/api/v2/query")
+ assert resp.status_code == 200
+ axes = resp.json()
+ assert isinstance(axes, list)
+ # With example data, we expect at least one axis
+ assert len(axes) >= 1
+ # Each axis item should include required keys
+ if axes:
+ assert {"key", "values", "dtype", "on_frontier"}.issubset(axes[0].keys())
+
+
+def test_basicstac_root_catalog():
+ resp = app_client.get("/api/v2/basicstac/")
+ assert resp.status_code == 200
+ payload = resp.json()
+ assert payload.get("type") == "Catalog"
+ assert isinstance(payload.get("links"), list)
+
+
+def test_union_requires_bearer_token():
+ # Missing Authorization header should be rejected by HTTPBearer
+ resp = app_client.post("/api/v2/union/", json={})
+ assert resp.status_code in (401, 403)
+
+
+def test_union_with_valid_bearer_token_works():
+ # Merge the current qube with itself; this should be a no-op but exercises the path
+ base = app_client.get("/api/v2/get/").json()
+ resp = app_client.post(
+ "/api/v2/union/",
+ headers={"Authorization": "Bearer testkey"},
+ json=base,
+ )
+ assert resp.status_code == 200
+ merged = resp.json()
+ assert merged == base
\ No newline at end of file