From 00a7f9533de1433383f587e14022ca31cfd51833 Mon Sep 17 00:00:00 2001 From: mathleur Date: Thu, 12 Mar 2026 16:50:39 +0100 Subject: [PATCH 1/5] add basic stac browsser --- stac_server/main.py | 13 + stac_server/stac_router.py | 541 +++++++++++++++++++++++++ stac_server/templates/landing.html | 2 + stac_server/templates/stac_browse.html | 418 +++++++++++++++++++ 4 files changed, 974 insertions(+) create mode 100644 stac_server/stac_router.py create mode 100644 stac_server/templates/stac_browse.html diff --git a/stac_server/main.py b/stac_server/main.py index 9fa98ff..46595a5 100644 --- a/stac_server/main.py +++ b/stac_server/main.py @@ -1,4 +1,5 @@ from .key_ordering import dataset_key_orders +from . import stac_router as _stac_router import json import logging import os @@ -82,6 +83,10 @@ with open(Path(__file__).parents[1] / "config/language/language.yaml", "r") as f: mars_language = yaml.safe_load(f) +# Register STAC API router +_stac_router.setup(qube, mars_language) +app.include_router(_stac_router.router) + logger.info("Ready to serve requests!") @@ -130,6 +135,14 @@ async def browse_catalogue(request: Request): }) +@app.get("/stac-browse", response_class=HTMLResponse) +async def stac_browse(request: Request): + """STAC Catalogue Browser — visual explorer for the STAC API at /api/stac/v1.""" + return templates.TemplateResponse(request, "stac_browse.html", { + "title": os.environ.get("TITLE", "Qubed Catalogue Browser"), + }) + + # --------------------------------------------------------------------------- # WASM support endpoints – let the browser load catalogue data directly # --------------------------------------------------------------------------- diff --git a/stac_server/stac_router.py b/stac_server/stac_router.py new file mode 100644 index 0000000..2281fcb --- /dev/null +++ b/stac_server/stac_router.py @@ -0,0 +1,541 @@ +""" +STAC API v1.0.0 router for the Qubed catalogue. + +Spec references: + https://api.stacspec.org/v1.0.0/core/ + https://api.stacspec.org/v1.0.0/item-search/ + https://api.stacspec.org/v1.0.0/ogcapi-features/ + +Mapping from Qubed / MARS concepts to STAC: + Collection ← unique value of the "dataset" dimension (falls back to a + single root collection when no "dataset" key exists) + Item ← individual datacube returned by PyQube.to_datacubes() + geometry ← null (meteorological fields are global or gridded; no point + geometry is available from the catalogue index alone) +""" + +from __future__ import annotations + +import hashlib +import logging +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse + +logger = logging.getLogger("uvicorn.error") + +# ── Module-level state injected by main.py via setup() ───────────────────── + +_qube = None # PyQube instance +_mars_language: dict = {} + +STAC_VERSION = "1.0.0" +MAX_ITEMS_DEFAULT = 100 +MAX_ITEMS_HARD_LIMIT = 10_000 + +CONFORMANCE_CLASSES = [ + "https://api.stacspec.org/v1.0.0/core", + "https://api.stacspec.org/v1.0.0/item-search", + "https://api.stacspec.org/v1.0.0/ogcapi-features", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30", + "http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson", +] + +router = APIRouter(prefix="/api/stac/v1", tags=["STAC"]) + + +def setup(qube, mars_language: dict) -> None: + """Call this once from main.py after loading the qube and language config.""" + global _qube, _mars_language + _qube = qube + _mars_language = mars_language + + +# ── Internal helpers ──────────────────────────────────────────────────────── + +def _base_url(request: Request) -> str: + return str(request.base_url).rstrip("/") + + +def _make_item_id(dc: dict[str, str]) -> str: + """Deterministic, URL-safe 24-char ID derived from a datacube's key-value pairs.""" + canonical = "&".join(f"{k}={v}" for k, v in sorted(dc.items())) + return hashlib.sha256(canonical.encode()).hexdigest()[:24] + + +def _mars_datetime(date: Optional[str], time: Optional[str]) -> Optional[str]: + """ + Convert MARS date (YYYYMMDD) and time (HHMM or HH) to RFC 3339 datetime. + Returns None when the date string doesn't look like YYYYMMDD. + """ + if not date or len(date) != 8: + return None + t = (time or "0000").zfill(4) + return f"{date[:4]}-{date[4:6]}-{date[6:8]}T{t[:2]}:{t[2:]}:00Z" + + +def _datacube_to_stac_item( + dc: dict[str, str], + collection_id: str, + base: str, +) -> dict[str, Any]: + """Convert a qubed datacube dict into a GeoJSON STAC Feature.""" + item_id = _make_item_id(dc) + dt = _mars_datetime(dc.get("date"), dc.get("time")) + + properties: dict[str, Any] = dict(dc) + properties["datetime"] = dt # STAC requires this key (may be null) + + prefix = f"{base}/api/stac/v1" + return { + "type": "Feature", + "stac_version": STAC_VERSION, + "id": item_id, + "collection": collection_id, + "geometry": None, + "bbox": None, + "properties": properties, + "links": [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{prefix}/collections/{collection_id}/items/{item_id}", + }, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + { + "rel": "parent", + "type": "application/json", + "href": f"{prefix}/collections/{collection_id}", + }, + { + "rel": "collection", + "type": "application/json", + "href": f"{prefix}/collections/{collection_id}", + }, + ], + "assets": {}, + } + + +def _collection_temporal_extent(all_coords: dict[str, list]) -> list[list[Optional[str]]]: + dates = sorted(d for d in all_coords.get("date", []) if len(d) == 8) + if dates: + return [[_mars_datetime(dates[0], None), _mars_datetime(dates[-1], None)]] + return [[None, None]] + + +def _make_collection( + collection_id: str, + all_coords: dict[str, list], + base: str, +) -> dict[str, Any]: + lang_values = _mars_language.get("dataset", {}).get("values", {}) + description = ( + lang_values.get(collection_id) + or _mars_language.get(collection_id, {}).get("description", "") + or f"Dataset: {collection_id}" + ) + + prefix = f"{base}/api/stac/v1" + summaries = {k: sorted(v) for k, v in all_coords.items() if k != "dataset" and v} + + return { + "type": "Collection", + "id": collection_id, + "stac_version": STAC_VERSION, + "title": collection_id, + "description": description, + "license": "proprietary", + "extent": { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": {"interval": _collection_temporal_extent(all_coords)}, + }, + "summaries": summaries, + "links": [ + { + "rel": "self", + "type": "application/json", + "href": f"{prefix}/collections/{collection_id}", + }, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + { + "rel": "items", + "type": "application/geo+json", + "href": f"{prefix}/collections/{collection_id}/items", + }, + ], + } + + +def _get_all_collections() -> list[str]: + """Return the list of unique dataset IDs in the qube.""" + coords = _qube.all_unique_dim_coords() + datasets = coords.get("dataset", []) + if datasets: + return sorted(datasets) + # Fallback: single anonymous collection + return ["default"] + + +def _select_collection(collection_id: str): + """Return a qube filtered to a single collection (dataset value).""" + coords = _qube.all_unique_dim_coords() + datasets = coords.get("dataset", []) + if datasets: + if collection_id not in datasets: + return None + return _qube.select({"dataset": collection_id}, None, None) + # No "dataset" dimension → only "default" is valid + if collection_id != "default": + return None + return _qube + + +def _items_from_qube(sub_qube, collection_id: str, base: str, offset: int, limit: int): + """Return (items_page, total_matched) from a qube.""" + all_dcs = sub_qube.to_datacubes() + total = len(all_dcs) + page = all_dcs[offset : offset + limit] + items = [_datacube_to_stac_item(dc, collection_id, base) for dc in page] + return items, total + + +def _apply_item_filters(sub_qube, filters: dict[str, Any]): + """ + Apply property filters (from ?bbox, ?datetime, or extra query params) to narrow + the qube before materialising items. Unrecognised keys are silently ignored. + """ + selection: dict[str, str | list[str]] = {} + + # Pass MARS-key filters through directly (e.g. ?param=130&type=an) + STAC_RESERVED = {"bbox", "datetime", "limit", "offset", "page", "collections", "ids", "fields"} + for k, v in filters.items(): + if k in STAC_RESERVED: + continue + selection[k] = v + + if not selection: + return sub_qube + try: + return sub_qube.select(selection, None, None) + except Exception as exc: + logger.warning(f"STAC filter select failed ({exc}), ignoring filters") + return sub_qube + + +# ── STAC API endpoints ────────────────────────────────────────────────────── + +@router.get("/", summary="STAC API Landing Page") +async def stac_landing(request: Request): + """ + OGC API / STAC API landing page. + Returns conformance links and the list of available collections. + """ + base = _base_url(request) + prefix = f"{base}/api/stac/v1" + return { + "type": "Catalog", + "id": "qubed-stac", + "stac_version": STAC_VERSION, + "title": "Qubed STAC Catalogue", + "description": ( + "STAC-compliant catalogue backed by the Qubed meteorological data index." + ), + "conformsTo": CONFORMANCE_CLASSES, + "links": [ + {"rel": "self", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + { + "rel": "conformance", + "type": "application/json", + "href": f"{prefix}/conformance", + "title": "OGC API conformance classes", + }, + { + "rel": "data", + "type": "application/json", + "href": f"{prefix}/collections", + "title": "Access the data", + }, + { + "rel": "search", + "type": "application/geo+json", + "href": f"{prefix}/search", + "title": "STAC Item Search", + "method": "GET", + }, + { + "rel": "search", + "type": "application/geo+json", + "href": f"{prefix}/search", + "title": "STAC Item Search", + "method": "POST", + }, + *[ + { + "rel": "child", + "type": "application/json", + "href": f"{prefix}/collections/{cid}", + "title": cid, + } + for cid in _get_all_collections() + ], + ], + } + + +@router.get("/conformance", summary="STAC API Conformance") +async def stac_conformance(): + """Return the list of OGC API conformance classes this service implements.""" + return {"conformsTo": CONFORMANCE_CLASSES} + + +@router.get("/collections", summary="List Collections") +async def list_collections(request: Request): + """Return all available STAC Collections.""" + base = _base_url(request) + all_coords = _qube.all_unique_dim_coords() + datasets = _get_all_collections() + + collections = [] + for cid in datasets: + if cid == "default": + sub_coords = all_coords + else: + sub = _select_collection(cid) + sub_coords = sub.all_unique_dim_coords() if sub else {} + collections.append(_make_collection(cid, sub_coords, base)) + + prefix = f"{base}/api/stac/v1" + return { + "collections": collections, + "links": [ + {"rel": "self", "type": "application/json", "href": f"{prefix}/collections"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + ], + } + + +@router.get("/collections/{collection_id}", summary="Get Collection") +async def get_collection(collection_id: str, request: Request): + """Return metadata for a single STAC Collection.""" + base = _base_url(request) + sub = _select_collection(collection_id) + if sub is None: + raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") + sub_coords = sub.all_unique_dim_coords() + return _make_collection(collection_id, sub_coords, base) + + +@router.get("/collections/{collection_id}/items", summary="Get Items") +async def get_items( + collection_id: str, + request: Request, + limit: int = Query(MAX_ITEMS_DEFAULT, ge=1, le=MAX_ITEMS_HARD_LIMIT, + description="Maximum number of items to return"), + offset: int = Query(0, ge=0, description="Zero-based index of the first item to return"), +): + """ + Return a GeoJSON FeatureCollection of STAC Items for a collection. + + Supports pagination via `limit` / `offset`. + Additional MARS dimension filters (e.g. `?param=130&type=an`) are passed + through to the qubed select mechanism. + """ + base = _base_url(request) + sub = _select_collection(collection_id) + if sub is None: + raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") + + # Apply any extra query-param filters + extra = dict(request.query_params) + extra.pop("limit", None) + extra.pop("offset", None) + sub = _apply_item_filters(sub, extra) + + items, total = _items_from_qube(sub, collection_id, base, offset, limit) + + prefix = f"{base}/api/stac/v1" + links = [ + { + "rel": "self", + "type": "application/geo+json", + "href": f"{prefix}/collections/{collection_id}/items?limit={limit}&offset={offset}", + }, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + { + "rel": "collection", + "type": "application/json", + "href": f"{prefix}/collections/{collection_id}", + }, + ] + if offset + limit < total: + links.append({ + "rel": "next", + "type": "application/geo+json", + "href": ( + f"{prefix}/collections/{collection_id}/items" + f"?limit={limit}&offset={offset + limit}" + ), + }) + if offset > 0: + links.append({ + "rel": "prev", + "type": "application/geo+json", + "href": ( + f"{prefix}/collections/{collection_id}/items" + f"?limit={limit}&offset={max(0, offset - limit)}" + ), + }) + + return { + "type": "FeatureCollection", + "features": items, + "numberMatched": total, + "numberReturned": len(items), + "links": links, + } + + +@router.get("/collections/{collection_id}/items/{item_id}", summary="Get Item") +async def get_item(collection_id: str, item_id: str, request: Request): + """Return a single STAC Item by ID.""" + base = _base_url(request) + sub = _select_collection(collection_id) + if sub is None: + raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") + + for dc in sub.to_datacubes(): + if _make_item_id(dc) == item_id: + return _datacube_to_stac_item(dc, collection_id, base) + + raise HTTPException(status_code=404, detail=f"Item '{item_id}' not found in collection '{collection_id}'") + + +# ── Item Search (GET + POST) ──────────────────────────────────────────────── + +def _search_items( + request_obj: Request, + base: str, + *, + collections: Optional[list[str]] = None, + ids: Optional[list[str]] = None, + bbox: Optional[list[float]] = None, + datetime_str: Optional[str] = None, + limit: int = MAX_ITEMS_DEFAULT, + offset: int = 0, + extra_filters: Optional[dict] = None, +) -> dict[str, Any]: + all_collections = _get_all_collections() + target_collections = collections if collections else all_collections + + all_items: list[dict] = [] + total_matched = 0 + + for cid in target_collections: + if cid not in all_collections: + continue + sub = _select_collection(cid) + if sub is None: + continue + if extra_filters: + sub = _apply_item_filters(sub, extra_filters) + + dcs = sub.to_datacubes() + + for dc in dcs: + item = _datacube_to_stac_item(dc, cid, base) + # Filter by IDs if requested + if ids and item["id"] not in ids: + continue + all_items.append(item) + + total_matched = len(all_items) + page = all_items[offset : offset + limit] + + prefix = f"{base}/api/stac/v1" + return { + "type": "FeatureCollection", + "features": page, + "numberMatched": total_matched, + "numberReturned": len(page), + "links": [ + {"rel": "self", "type": "application/geo+json", "href": f"{prefix}/search"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + ], + } + + +@router.get("/search", summary="Search Items (GET)", response_class=JSONResponse) +async def search_get( + request: Request, + collections: Optional[str] = Query(None, description="Comma-separated collection IDs"), + ids: Optional[str] = Query(None, description="Comma-separated item IDs"), + bbox: Optional[str] = Query(None, description="Bounding box: minLon,minLat,maxLon,maxLat"), + datetime: Optional[str] = Query(None, description="RFC 3339 datetime or interval"), + limit: int = Query(MAX_ITEMS_DEFAULT, ge=1, le=MAX_ITEMS_HARD_LIMIT), + offset: int = Query(0, ge=0), +): + """ + STAC Item Search (GET). + + Supports standard STAC search parameters as well as arbitrary MARS dimension + filters passed as extra query parameters (e.g. `?param=130&type=an`). + """ + base = _base_url(request) + + extra = dict(request.query_params) + for reserved in ("collections", "ids", "bbox", "datetime", "limit", "offset"): + extra.pop(reserved, None) + + return _search_items( + request, + base, + collections=collections.split(",") if collections else None, + ids=ids.split(",") if ids else None, + bbox=[float(x) for x in bbox.split(",")] if bbox else None, + datetime_str=datetime, + limit=limit, + offset=offset, + extra_filters=extra or None, + ) + + +@router.post("/search", summary="Search Items (POST)", response_class=JSONResponse) +async def search_post(request: Request): + """ + STAC Item Search (POST). + + Accepts a JSON body following the STAC API Item Search spec: + https://api.stacspec.org/v1.0.0/item-search/#operation/postSearches + + Additional MARS filters can be supplied under the `"filter"` key as a + flat dict of {dimension: value} pairs. + """ + base = _base_url(request) + try: + body = await request.json() + except Exception: + body = {} + + collections = body.get("collections") + ids = body.get("ids") + bbox = body.get("bbox") + datetime_str = body.get("datetime") + limit = min(int(body.get("limit", MAX_ITEMS_DEFAULT)), MAX_ITEMS_HARD_LIMIT) + offset = int(body.get("offset", 0)) + extra_filters = body.get("filter") or None + + return _search_items( + request, + base, + collections=collections, + ids=ids, + bbox=bbox, + datetime_str=datetime_str, + limit=limit, + offset=offset, + extra_filters=extra_filters, + ) diff --git a/stac_server/templates/landing.html b/stac_server/templates/landing.html index 4cafd05..b4e9278 100644 --- a/stac_server/templates/landing.html +++ b/stac_server/templates/landing.html @@ -340,6 +340,8 @@

Extremes DT

+  ·  +
diff --git a/stac_server/templates/stac_browse.html b/stac_server/templates/stac_browse.html new file mode 100644 index 0000000..379db2e --- /dev/null +++ b/stac_server/templates/stac_browse.html @@ -0,0 +1,418 @@ + + + + + + {{ title }} — STAC Browser + + + + + + +
+

🗂️ {{ title }} — STAC Browser

+ ← Qubed Browser + + External viewer ↗ +
+ +
+ +
+
+ + + + From 688f6c37ed501846476352fa712a78de26a46927 Mon Sep 17 00:00:00 2001 From: mathleur Date: Fri, 13 Mar 2026 13:40:55 +0100 Subject: [PATCH 2/5] try to add metadata keys as browsable collections --- stac_server/stac_router.py | 652 ++++++++++++------------ stac_server/templates/stac_browse.html | 658 +++++++++++++------------ 2 files changed, 690 insertions(+), 620 deletions(-) diff --git a/stac_server/stac_router.py b/stac_server/stac_router.py index 2281fcb..b6f3948 100644 --- a/stac_server/stac_router.py +++ b/stac_server/stac_router.py @@ -7,11 +7,23 @@ https://api.stacspec.org/v1.0.0/ogcapi-features/ Mapping from Qubed / MARS concepts to STAC: - Collection ← unique value of the "dataset" dimension (falls back to a + Collection <- unique value of the "dataset" dimension (falls back to a single root collection when no "dataset" key exists) - Item ← individual datacube returned by PyQube.to_datacubes() - geometry ← null (meteorological fields are global or gridded; no point - geometry is available from the catalogue index alone) + Catalog <- each metadata key becomes a nesting level; browsing drills + down one key at a time using the dataset-specific key ordering + Item <- individual datacube at the leaf of the key hierarchy + geometry <- null (no point geometry available from the catalogue index) + +Hierarchical URLs +----------------- + /api/stac/v1/ landing page + /api/stac/v1/collections all collections + /api/stac/v1/collections/{cid} collection metadata + /api/stac/v1/collections/{cid}/catalog root catalog node + /api/stac/v1/collections/{cid}/catalog/{path} nested catalog, + path = k=v/k=v/... + /api/stac/v1/collections/{cid}/items/{item_id} single item + /api/stac/v1/search cross-collection search """ from __future__ import annotations @@ -25,10 +37,11 @@ logger = logging.getLogger("uvicorn.error") -# ── Module-level state injected by main.py via setup() ───────────────────── +# ── Module-level state injected by main.py via setup() ────────────────────── -_qube = None # PyQube instance +_qube = None # PyQube instance _mars_language: dict = {} +_key_orders: dict = {} # dataset -> ordered list of keys STAC_VERSION = "1.0.0" MAX_ITEMS_DEFAULT = 100 @@ -47,239 +60,304 @@ def setup(qube, mars_language: dict) -> None: - """Call this once from main.py after loading the qube and language config.""" - global _qube, _mars_language + """Call this once from main.py after loading data.""" + global _qube, _mars_language, _key_orders _qube = qube _mars_language = mars_language + try: + from .key_ordering import dataset_key_orders + except ImportError: + from key_ordering import dataset_key_orders # type: ignore[no-redef] + _key_orders = dataset_key_orders -# ── Internal helpers ──────────────────────────────────────────────────────── +# ── Generic helpers ────────────────────────────────────────────────────────── def _base_url(request: Request) -> str: return str(request.base_url).rstrip("/") def _make_item_id(dc: dict[str, str]) -> str: - """Deterministic, URL-safe 24-char ID derived from a datacube's key-value pairs.""" + """Deterministic 24-char hex ID for a datacube.""" canonical = "&".join(f"{k}={v}" for k, v in sorted(dc.items())) return hashlib.sha256(canonical.encode()).hexdigest()[:24] def _mars_datetime(date: Optional[str], time: Optional[str]) -> Optional[str]: - """ - Convert MARS date (YYYYMMDD) and time (HHMM or HH) to RFC 3339 datetime. - Returns None when the date string doesn't look like YYYYMMDD. - """ + """MARS date (YYYYMMDD) + time (HHMM) -> RFC 3339.""" if not date or len(date) != 8: return None t = (time or "0000").zfill(4) return f"{date[:4]}-{date[4:6]}-{date[6:8]}T{t[:2]}:{t[2:]}:00Z" -def _datacube_to_stac_item( - dc: dict[str, str], - collection_id: str, - base: str, -) -> dict[str, Any]: - """Convert a qubed datacube dict into a GeoJSON STAC Feature.""" - item_id = _make_item_id(dc) - dt = _mars_datetime(dc.get("date"), dc.get("time")) - - properties: dict[str, Any] = dict(dc) - properties["datetime"] = dt # STAC requires this key (may be null) - - prefix = f"{base}/api/stac/v1" - return { - "type": "Feature", - "stac_version": STAC_VERSION, - "id": item_id, - "collection": collection_id, - "geometry": None, - "bbox": None, - "properties": properties, - "links": [ - { - "rel": "self", - "type": "application/geo+json", - "href": f"{prefix}/collections/{collection_id}/items/{item_id}", - }, - {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, - { - "rel": "parent", - "type": "application/json", - "href": f"{prefix}/collections/{collection_id}", - }, - { - "rel": "collection", - "type": "application/json", - "href": f"{prefix}/collections/{collection_id}", - }, - ], - "assets": {}, - } - - -def _collection_temporal_extent(all_coords: dict[str, list]) -> list[list[Optional[str]]]: - dates = sorted(d for d in all_coords.get("date", []) if len(d) == 8) +def _collection_temporal_extent(coords: dict[str, list]) -> list[list[Optional[str]]]: + dates = sorted(d for d in coords.get("date", []) if len(d) == 8) if dates: return [[_mars_datetime(dates[0], None), _mars_datetime(dates[-1], None)]] return [[None, None]] -def _make_collection( - collection_id: str, - all_coords: dict[str, list], - base: str, -) -> dict[str, Any]: - lang_values = _mars_language.get("dataset", {}).get("values", {}) - description = ( - lang_values.get(collection_id) - or _mars_language.get(collection_id, {}).get("description", "") - or f"Dataset: {collection_id}" - ) - - prefix = f"{base}/api/stac/v1" - summaries = {k: sorted(v) for k, v in all_coords.items() if k != "dataset" and v} - - return { - "type": "Collection", - "id": collection_id, - "stac_version": STAC_VERSION, - "title": collection_id, - "description": description, - "license": "proprietary", - "extent": { - "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, - "temporal": {"interval": _collection_temporal_extent(all_coords)}, - }, - "summaries": summaries, - "links": [ - { - "rel": "self", - "type": "application/json", - "href": f"{prefix}/collections/{collection_id}", - }, - {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, - { - "rel": "items", - "type": "application/geo+json", - "href": f"{prefix}/collections/{collection_id}/items", - }, - ], - } - - def _get_all_collections() -> list[str]: - """Return the list of unique dataset IDs in the qube.""" coords = _qube.all_unique_dim_coords() datasets = coords.get("dataset", []) - if datasets: - return sorted(datasets) - # Fallback: single anonymous collection - return ["default"] + return sorted(datasets) if datasets else ["default"] def _select_collection(collection_id: str): - """Return a qube filtered to a single collection (dataset value).""" + """Return a qube filtered to a single collection.""" coords = _qube.all_unique_dim_coords() datasets = coords.get("dataset", []) if datasets: if collection_id not in datasets: return None - return _qube.select({"dataset": collection_id}, None, None) - # No "dataset" dimension → only "default" is valid + return _qube.select({"dataset": collection_id}, "prune", None) if collection_id != "default": return None return _qube -def _items_from_qube(sub_qube, collection_id: str, base: str, offset: int, limit: int): - """Return (items_page, total_matched) from a qube.""" - all_dcs = sub_qube.to_datacubes() - total = len(all_dcs) - page = all_dcs[offset : offset + limit] - items = [_datacube_to_stac_item(dc, collection_id, base) for dc in page] - return items, total +def _key_ordering_for(collection_id: str) -> list[str]: + return _key_orders.get(collection_id, _key_orders.get("default", [])) -def _apply_item_filters(sub_qube, filters: dict[str, Any]): +def _next_key( + ordering: list[str], + selected_keys: set[str], + available_coords: dict, +) -> Optional[str]: + """First unselected key in *ordering* that has values in *available_coords*.""" + for key in ordering: + if key in selected_keys: + continue + vals = available_coords.get(key, []) + if vals: + return key + return None # all keys exhausted -> leaf level + + +# ── Catalog path helpers ───────────────────────────────────────────────────── + +def _parse_catalog_path(path: str) -> list[tuple[str, str]]: """ - Apply property filters (from ?bbox, ?datetime, or extra query params) to narrow - the qube before materialising items. Unrecognised keys are silently ignored. + "class=od/stream=oper" -> [("class", "od"), ("stream", "oper")] + Blank / empty path -> [] """ - selection: dict[str, str | list[str]] = {} - - # Pass MARS-key filters through directly (e.g. ?param=130&type=an) - STAC_RESERVED = {"bbox", "datetime", "limit", "offset", "page", "collections", "ids", "fields"} - for k, v in filters.items(): - if k in STAC_RESERVED: + pairs: list[tuple[str, str]] = [] + for seg in path.strip("/").split("/"): + seg = seg.strip() + if "=" not in seg: continue - selection[k] = v + k, _, v = seg.partition("=") + pairs.append((k.strip(), v.strip())) + return pairs - if not selection: - return sub_qube - try: - return sub_qube.select(selection, None, None) - except Exception as exc: - logger.warning(f"STAC filter select failed ({exc}), ignoring filters") - return sub_qube +def _catalog_path_str(pairs: list[tuple[str, str]]) -> str: + return "/".join(f"{k}={v}" for k, v in pairs) -# ── STAC API endpoints ────────────────────────────────────────────────────── -@router.get("/", summary="STAC API Landing Page") -async def stac_landing(request: Request): +def _apply_path_selection(base_qube, pairs: list[tuple[str, str]]): + """Successively select each (key, value) from the path.""" + q = base_qube + for k, v in pairs: + try: + q = q.select({k: v}, None, None) + except Exception as exc: + raise HTTPException( + status_code=404, + detail=f"No data for {k}={v}: {exc}", + ) + return q + + +# ── Build a catalog node ───────────────────────────────────────────────────── + +def _make_catalog_node( + *, + collection_id: str, + path_pairs: list[tuple[str, str]], + sub_qube, + prefix: str, + ordering: list[str], +) -> dict[str, Any]: """ - OGC API / STAC API landing page. - Returns conformance links and the list of available collections. + Return a STAC Catalog JSON object representing one node in the hierarchy. + + * If there are more keys to traverse, children are sub-catalog links (one + per distinct value of the next key). + * At the leaf (all ordered keys consumed or data exhausted), children are + STAC Item links. """ + available = sub_qube.all_unique_dim_coords() + selected_keys = {k for k, _ in path_pairs} + + coll_root = f"{prefix}/collections/{collection_id}/catalog" + path_str = _catalog_path_str(path_pairs) + self_href = f"{coll_root}/{path_str}" if path_str else coll_root + + if path_pairs: + parent_str = _catalog_path_str(path_pairs[:-1]) + parent_href = f"{coll_root}/{parent_str}" if parent_str else coll_root + else: + parent_href = f"{prefix}/collections/{collection_id}" + + last = path_pairs[-1] if path_pairs else None + title = f"{last[0]} = {last[1]}" if last else collection_id + + base_links: list[dict] = [ + {"rel": "self", "type": "application/json", "href": self_href}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "parent", "type": "application/json", "href": parent_href}, + {"rel": "collection", "type": "application/json", "href": f"{prefix}/collections/{collection_id}"}, + ] + + nk = _next_key(ordering, selected_keys, available) + + if nk is not None: + # ---- internal node: one child per value of the next key ---- + values = sorted(available[nk]) + lang_info = _mars_language.get(nk, {}) + child_links: list[dict] = [] + for val in values: + child_path = _catalog_path_str(path_pairs + [(nk, val)]) + val_info = lang_info.get("values", {}).get(val) or {} + val_desc = (val_info.get("name") or val_info.get("description") or "") if isinstance(val_info, dict) else "" + child_title = f"{nk} = {val}" + (f" ({val_desc})" if val_desc else "") + child_links.append({ + "rel": "child", + "type": "application/json", + "href": f"{coll_root}/{child_path}", + "title": child_title, + # non-standard extras consumed by the JS browser + "stac:key": nk, + "stac:value": val, + }) + return { + "type": "Catalog", + "id": f"{collection_id}/{path_str}" if path_str else collection_id, + "stac_version": STAC_VERSION, + "title": title, + "description": f"Select a value for '{nk}'", + "next_key": nk, + "links": base_links + child_links, + } + + else: + # ---- leaf node: one item link per datacube ---- + dcs = sub_qube.to_datacubes() + item_links: list[dict] = [] + for dc in dcs: + iid = _make_item_id(dc) + dt = _mars_datetime(dc.get("date"), dc.get("time")) + item_links.append({ + "rel": "item", + "type": "application/geo+json", + "href": f"{prefix}/collections/{collection_id}/items/{iid}", + "title": dt or iid, + "stac:properties": dict(dc), + }) + return { + "type": "Catalog", + "id": f"{collection_id}/{path_str}" if path_str else collection_id, + "stac_version": STAC_VERSION, + "title": title, + "description": f"{len(dcs)} item(s)", + "links": base_links + item_links, + } + + +# ── Collection helpers ─────────────────────────────────────────────────────── + +def _make_collection( + collection_id: str, + all_coords: dict[str, list], + base: str, +) -> dict[str, Any]: + prefix = f"{base}/api/stac/v1" + lang_values = _mars_language.get("dataset", {}).get("values", {}) + lang_entry = lang_values.get(collection_id) or {} + description = ( + (lang_entry.get("description") or lang_entry.get("name") or "") if isinstance(lang_entry, dict) else str(lang_entry) + ) or _mars_language.get(collection_id, {}).get("description", "") or f"Dataset: {collection_id}" + summaries = {k: sorted(v) for k, v in all_coords.items() if k != "dataset" and v} + return { + "type": "Collection", + "id": collection_id, + "stac_version": STAC_VERSION, + "title": collection_id, + "description": description, + "license": "proprietary", + "extent": { + "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]}, + "temporal": {"interval": _collection_temporal_extent(all_coords)}, + }, + "summaries": summaries, + "links": [ + {"rel": "self", "type": "application/json", "href": f"{prefix}/collections/{collection_id}"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + { + "rel": "child", + "type": "application/json", + "href": f"{prefix}/collections/{collection_id}/catalog", + "title": "Browse by metadata key hierarchy", + }, + ], + } + + +def _datacube_to_stac_item( + dc: dict[str, str], + collection_id: str, + base: str, +) -> dict[str, Any]: + item_id = _make_item_id(dc) + dt = _mars_datetime(dc.get("date"), dc.get("time")) + prefix = f"{base}/api/stac/v1" + return { + "type": "Feature", + "stac_version": STAC_VERSION, + "id": item_id, + "collection": collection_id, + "geometry": None, + "bbox": None, + "properties": {**dc, "datetime": dt}, + "links": [ + {"rel": "self", "type": "application/geo+json", "href": f"{prefix}/collections/{collection_id}/items/{item_id}"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "parent", "type": "application/json", "href": f"{prefix}/collections/{collection_id}"}, + {"rel": "collection", "type": "application/json", "href": f"{prefix}/collections/{collection_id}"}, + ], + "assets": {}, + } + + +# ── API endpoints ──────────────────────────────────────────────────────────── + +@router.get("/", summary="STAC API Landing Page") +async def stac_landing(request: Request): base = _base_url(request) prefix = f"{base}/api/stac/v1" return { - "type": "Catalog", - "id": "qubed-stac", + "type": "Catalog", + "id": "qubed-stac", "stac_version": STAC_VERSION, - "title": "Qubed STAC Catalogue", - "description": ( - "STAC-compliant catalogue backed by the Qubed meteorological data index." + "title": "Qubed STAC Catalogue", + "description": ( + "STAC-compliant catalogue backed by the Qubed meteorological data index. " + "Each collection is browsable as a hierarchical key-by-key catalogue." ), "conformsTo": CONFORMANCE_CLASSES, "links": [ - {"rel": "self", "type": "application/json", "href": f"{prefix}/"}, - {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, - { - "rel": "conformance", - "type": "application/json", - "href": f"{prefix}/conformance", - "title": "OGC API conformance classes", - }, - { - "rel": "data", - "type": "application/json", - "href": f"{prefix}/collections", - "title": "Access the data", - }, - { - "rel": "search", - "type": "application/geo+json", - "href": f"{prefix}/search", - "title": "STAC Item Search", - "method": "GET", - }, - { - "rel": "search", - "type": "application/geo+json", - "href": f"{prefix}/search", - "title": "STAC Item Search", - "method": "POST", - }, + {"rel": "self", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "conformance", "type": "application/json", "href": f"{prefix}/conformance"}, + {"rel": "data", "type": "application/json", "href": f"{prefix}/collections"}, + {"rel": "search", "type": "application/geo+json", "href": f"{prefix}/search", "method": "GET"}, + {"rel": "search", "type": "application/geo+json", "href": f"{prefix}/search", "method": "POST"}, *[ - { - "rel": "child", - "type": "application/json", - "href": f"{prefix}/collections/{cid}", - "title": cid, - } + {"rel": "child", "type": "application/json", + "href": f"{prefix}/collections/{cid}", "title": cid} for cid in _get_all_collections() ], ], @@ -288,17 +366,14 @@ async def stac_landing(request: Request): @router.get("/conformance", summary="STAC API Conformance") async def stac_conformance(): - """Return the list of OGC API conformance classes this service implements.""" return {"conformsTo": CONFORMANCE_CLASSES} @router.get("/collections", summary="List Collections") async def list_collections(request: Request): - """Return all available STAC Collections.""" base = _base_url(request) all_coords = _qube.all_unique_dim_coords() datasets = _get_all_collections() - collections = [] for cid in datasets: if cid == "default": @@ -307,7 +382,6 @@ async def list_collections(request: Request): sub = _select_collection(cid) sub_coords = sub.all_unique_dim_coords() if sub else {} collections.append(_make_collection(cid, sub_coords, base)) - prefix = f"{base}/api/stac/v1" return { "collections": collections, @@ -320,150 +394,126 @@ async def list_collections(request: Request): @router.get("/collections/{collection_id}", summary="Get Collection") async def get_collection(collection_id: str, request: Request): - """Return metadata for a single STAC Collection.""" base = _base_url(request) sub = _select_collection(collection_id) if sub is None: raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") - sub_coords = sub.all_unique_dim_coords() - return _make_collection(collection_id, sub_coords, base) + return _make_collection(collection_id, sub.all_unique_dim_coords(), base) -@router.get("/collections/{collection_id}/items", summary="Get Items") -async def get_items( - collection_id: str, - request: Request, - limit: int = Query(MAX_ITEMS_DEFAULT, ge=1, le=MAX_ITEMS_HARD_LIMIT, - description="Maximum number of items to return"), - offset: int = Query(0, ge=0, description="Zero-based index of the first item to return"), -): +# ── Hierarchical catalog browsing ──────────────────────────────────────────── + +@router.get("/collections/{collection_id}/catalog", summary="Root Catalog Node") +async def collection_catalog_root(collection_id: str, request: Request): + """ + Root catalog node for a collection. + Shows child catalogs for each distinct value of the first key in the + dataset's key ordering (e.g. 'class'). """ - Return a GeoJSON FeatureCollection of STAC Items for a collection. + return await _resolve_catalog_node(collection_id, "", request) - Supports pagination via `limit` / `offset`. - Additional MARS dimension filters (e.g. `?param=130&type=an`) are passed - through to the qubed select mechanism. + +@router.get("/collections/{collection_id}/catalog/{path:path}", summary="Nested Catalog Node") +async def collection_catalog_path(collection_id: str, path: str, request: Request): + """ + Nested catalog node at key=value/key=value/... path. + Drills down one key at a time until the leaf level, where STAC Items appear. """ + return await _resolve_catalog_node(collection_id, path, request) + + +async def _resolve_catalog_node( + collection_id: str, + path: str, + request: Request, +) -> dict: base = _base_url(request) + prefix = f"{base}/api/stac/v1" + sub = _select_collection(collection_id) if sub is None: raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") - # Apply any extra query-param filters - extra = dict(request.query_params) - extra.pop("limit", None) - extra.pop("offset", None) - sub = _apply_item_filters(sub, extra) + path_pairs = _parse_catalog_path(path) + if path_pairs: + sub = _apply_path_selection(sub, path_pairs) - items, total = _items_from_qube(sub, collection_id, base, offset, limit) + # Keys already fixed by the collection filter (e.g. dataset=climate-dt) + collection_fixed = {"dataset"} if collection_id != "default" else set() + ordering = [k for k in _key_ordering_for(collection_id) if k not in collection_fixed] - prefix = f"{base}/api/stac/v1" - links = [ - { - "rel": "self", - "type": "application/geo+json", - "href": f"{prefix}/collections/{collection_id}/items?limit={limit}&offset={offset}", - }, - {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, - { - "rel": "collection", - "type": "application/json", - "href": f"{prefix}/collections/{collection_id}", - }, - ] - if offset + limit < total: - links.append({ - "rel": "next", - "type": "application/geo+json", - "href": ( - f"{prefix}/collections/{collection_id}/items" - f"?limit={limit}&offset={offset + limit}" - ), - }) - if offset > 0: - links.append({ - "rel": "prev", - "type": "application/geo+json", - "href": ( - f"{prefix}/collections/{collection_id}/items" - f"?limit={limit}&offset={max(0, offset - limit)}" - ), - }) + return _make_catalog_node( + collection_id=collection_id, + path_pairs=path_pairs, + sub_qube=sub, + prefix=prefix, + ordering=ordering, + ) - return { - "type": "FeatureCollection", - "features": items, - "numberMatched": total, - "numberReturned": len(items), - "links": links, - } +# ── Item retrieval ─────────────────────────────────────────────────────────── @router.get("/collections/{collection_id}/items/{item_id}", summary="Get Item") async def get_item(collection_id: str, item_id: str, request: Request): - """Return a single STAC Item by ID.""" base = _base_url(request) sub = _select_collection(collection_id) if sub is None: raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") - for dc in sub.to_datacubes(): if _make_item_id(dc) == item_id: return _datacube_to_stac_item(dc, collection_id, base) + raise HTTPException(status_code=404, detail=f"Item '{item_id}' not found") + - raise HTTPException(status_code=404, detail=f"Item '{item_id}' not found in collection '{collection_id}'") +# ── Search ─────────────────────────────────────────────────────────────────── +def _apply_item_filters(sub_qube, filters: dict[str, Any]): + RESERVED = {"bbox", "datetime", "limit", "offset", "page", "collections", "ids", "fields"} + selection = {k: v for k, v in filters.items() if k not in RESERVED} + if not selection: + return sub_qube + try: + return sub_qube.select(selection, None, None) + except Exception as exc: + logger.warning(f"Search filter select failed ({exc}), ignoring") + return sub_qube -# ── Item Search (GET + POST) ──────────────────────────────────────────────── -def _search_items( - request_obj: Request, +def _do_search( base: str, *, collections: Optional[list[str]] = None, ids: Optional[list[str]] = None, - bbox: Optional[list[float]] = None, - datetime_str: Optional[str] = None, limit: int = MAX_ITEMS_DEFAULT, offset: int = 0, extra_filters: Optional[dict] = None, ) -> dict[str, Any]: - all_collections = _get_all_collections() - target_collections = collections if collections else all_collections - + prefix = f"{base}/api/stac/v1" + all_cols = _get_all_collections() + targets = collections if collections else all_cols all_items: list[dict] = [] - total_matched = 0 - - for cid in target_collections: - if cid not in all_collections: + for cid in targets: + if cid not in all_cols: continue sub = _select_collection(cid) if sub is None: continue if extra_filters: sub = _apply_item_filters(sub, extra_filters) - - dcs = sub.to_datacubes() - - for dc in dcs: + for dc in sub.to_datacubes(): item = _datacube_to_stac_item(dc, cid, base) - # Filter by IDs if requested if ids and item["id"] not in ids: continue all_items.append(item) - - total_matched = len(all_items) - page = all_items[offset : offset + limit] - - prefix = f"{base}/api/stac/v1" + total = len(all_items) return { - "type": "FeatureCollection", - "features": page, - "numberMatched": total_matched, - "numberReturned": len(page), + "type": "FeatureCollection", + "features": all_items[offset: offset + limit], + "numberMatched": total, + "numberReturned": len(all_items[offset: offset + limit]), "links": [ {"rel": "self", "type": "application/geo+json", "href": f"{prefix}/search"}, - {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, ], } @@ -473,69 +523,35 @@ async def search_get( request: Request, collections: Optional[str] = Query(None, description="Comma-separated collection IDs"), ids: Optional[str] = Query(None, description="Comma-separated item IDs"), - bbox: Optional[str] = Query(None, description="Bounding box: minLon,minLat,maxLon,maxLat"), - datetime: Optional[str] = Query(None, description="RFC 3339 datetime or interval"), limit: int = Query(MAX_ITEMS_DEFAULT, ge=1, le=MAX_ITEMS_HARD_LIMIT), offset: int = Query(0, ge=0), ): - """ - STAC Item Search (GET). - - Supports standard STAC search parameters as well as arbitrary MARS dimension - filters passed as extra query parameters (e.g. `?param=130&type=an`). - """ base = _base_url(request) - - extra = dict(request.query_params) - for reserved in ("collections", "ids", "bbox", "datetime", "limit", "offset"): - extra.pop(reserved, None) - - return _search_items( - request, + extra = { + k: v for k, v in request.query_params.items() + if k not in ("collections", "ids", "limit", "offset", "bbox", "datetime") + } + return _do_search( base, collections=collections.split(",") if collections else None, ids=ids.split(",") if ids else None, - bbox=[float(x) for x in bbox.split(",")] if bbox else None, - datetime_str=datetime, - limit=limit, - offset=offset, + limit=limit, offset=offset, extra_filters=extra or None, ) @router.post("/search", summary="Search Items (POST)", response_class=JSONResponse) async def search_post(request: Request): - """ - STAC Item Search (POST). - - Accepts a JSON body following the STAC API Item Search spec: - https://api.stacspec.org/v1.0.0/item-search/#operation/postSearches - - Additional MARS filters can be supplied under the `"filter"` key as a - flat dict of {dimension: value} pairs. - """ base = _base_url(request) try: body = await request.json() except Exception: body = {} - - collections = body.get("collections") - ids = body.get("ids") - bbox = body.get("bbox") - datetime_str = body.get("datetime") - limit = min(int(body.get("limit", MAX_ITEMS_DEFAULT)), MAX_ITEMS_HARD_LIMIT) - offset = int(body.get("offset", 0)) - extra_filters = body.get("filter") or None - - return _search_items( - request, + return _do_search( base, - collections=collections, - ids=ids, - bbox=bbox, - datetime_str=datetime_str, - limit=limit, - offset=offset, - extra_filters=extra_filters, + collections=body.get("collections"), + ids=body.get("ids"), + limit=min(int(body.get("limit", MAX_ITEMS_DEFAULT)), MAX_ITEMS_HARD_LIMIT), + offset=int(body.get("offset", 0)), + extra_filters=body.get("filter") or None, ) diff --git a/stac_server/templates/stac_browse.html b/stac_server/templates/stac_browse.html index 379db2e..98896af 100644 --- a/stac_server/templates/stac_browse.html +++ b/stac_server/templates/stac_browse.html @@ -5,135 +5,120 @@ {{ title }} — STAC Browser + + @@ -142,276 +127,345 @@

🗂️ {{ title }} — STAC Browser

← Qubed Browser - External viewer ↗ + External STAC viewer ↗
- +
From 32bc492afdaa83ac1d9e26640e2a2168ee9d3d6a Mon Sep 17 00:00:00 2001 From: mathleur Date: Fri, 13 Mar 2026 14:14:17 +0100 Subject: [PATCH 3/5] stac map and metadata --- stac_server/stac_router.py | 39 ++ stac_server/templates/stac_browse.html | 603 ++++++++++++++++++++----- 2 files changed, 541 insertions(+), 101 deletions(-) diff --git a/stac_server/stac_router.py b/stac_server/stac_router.py index b6f3948..2ae34d1 100644 --- a/stac_server/stac_router.py +++ b/stac_server/stac_router.py @@ -401,6 +401,45 @@ async def get_collection(collection_id: str, request: Request): return _make_collection(collection_id, sub.all_unique_dim_coords(), base) +@router.get("/collections/{collection_id}/items", summary="List Collection Items") +async def list_collection_items( + collection_id: str, + request: Request, + limit: int = Query(MAX_ITEMS_DEFAULT, ge=1, le=MAX_ITEMS_HARD_LIMIT), + offset: int = Query(0, ge=0), +): + """Return GeoJSON FeatureCollection of items for a collection (STAC spec).""" + base = _base_url(request) + prefix = f"{base}/api/stac/v1" + sub = _select_collection(collection_id) + if sub is None: + raise HTTPException(status_code=404, detail=f"Collection '{collection_id}' not found") + all_dcs = sub.to_datacubes() + total = len(all_dcs) + features = [ + _datacube_to_stac_item(dc, collection_id, base) + for dc in all_dcs[offset: offset + limit] + ] + links = [ + {"rel": "self", "type": "application/geo+json", "href": f"{prefix}/collections/{collection_id}/items?limit={limit}&offset={offset}"}, + {"rel": "root", "type": "application/json", "href": f"{prefix}/"}, + {"rel": "collection", "type": "application/json", "href": f"{prefix}/collections/{collection_id}"}, + ] + if offset + limit < total: + links.append({"rel": "next", "type": "application/geo+json", + "href": f"{prefix}/collections/{collection_id}/items?limit={limit}&offset={offset + limit}"}) + if offset > 0: + links.append({"rel": "prev", "type": "application/geo+json", + "href": f"{prefix}/collections/{collection_id}/items?limit={limit}&offset={max(0, offset - limit)}"}) + return { + "type": "FeatureCollection", + "features": features, + "numberMatched": total, + "numberReturned": len(features), + "links": links, + } + + # ── Hierarchical catalog browsing ──────────────────────────────────────────── @router.get("/collections/{collection_id}/catalog", summary="Root Catalog Node") diff --git a/stac_server/templates/stac_browse.html b/stac_server/templates/stac_browse.html index 98896af..fc3ca7d 100644 --- a/stac_server/templates/stac_browse.html +++ b/stac_server/templates/stac_browse.html @@ -105,20 +105,75 @@ .btn.sec:hover { background:var(--primary-color,#2563eb); color:#fff; } .row { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; margin-bottom:.85rem; } - /* map */ - #item-map { height:340px; border-radius:8px; overflow:hidden; border:1.5px solid var(--border-color,#e0e4f0); } - - /* metadata table */ + /* item two-column layout */ + .item-2col { + display:grid; + grid-template-columns: 340px 1fr; + gap:.9rem; + align-items:start; + } + @media (max-width:780px) { .item-2col { grid-template-columns:1fr; } } + .item-left { position:sticky; top:.75rem; } + .map-wrap { border-radius:9px; overflow:hidden; border:1.5px solid var(--border-color,#e0e4f0); } + #item-map { height:260px; } + + /* metadata sections */ + .meta-section { background:var(--bg-primary,#fff); border-radius:10px; padding:1rem 1.1rem; box-shadow:0 1px 6px rgba(0,0,0,.08); margin-bottom:.85rem; } + .meta-section-title { + font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.07em; + color:var(--text-secondary,#888); margin-bottom:.65rem; padding-bottom:.4rem; + border-bottom:1.5px solid var(--border-color,#e8eaf0); + } .meta-table { width:100%; border-collapse:collapse; font-size:.86rem; } - .meta-table th { text-align:left; padding:.45rem .7rem; font-weight:600; color:var(--text-secondary,#555); border-bottom:2px solid var(--border-color,#e8eaf0); white-space:nowrap; } - .meta-table td { padding:.42rem .7rem; border-bottom:1px solid var(--border-color,#f0f2f8); vertical-align:top; } + .meta-table td { padding:.42rem .5rem; border-bottom:1px solid var(--border-color,#f0f2f8); vertical-align:top; } .meta-table tr:last-child td { border-bottom:none; } .meta-table tr:hover td { background:var(--bg-secondary,#f4f6fb); } - .meta-key { font-weight:700; font-family:monospace; font-size:.84rem; color:var(--primary-color,#2563eb); } - .meta-key-desc { font-size:.78rem; color:var(--text-secondary,#777); margin-top:.1rem; } + .meta-key { font-weight:700; font-family:monospace; font-size:.82rem; color:var(--primary-color,#2563eb); white-space:nowrap; } + .meta-key-desc { font-size:.76rem; color:var(--text-secondary,#999); margin-top:.08rem; } .meta-val-name { font-weight:600; } .meta-val-raw { font-family:monospace; font-size:.82rem; color:var(--text-secondary,#555); } - .meta-val-desc { font-size:.78rem; color:var(--text-secondary,#777); margin-top:.1rem; } + .meta-val-desc { font-size:.76rem; color:var(--text-secondary,#777); margin-top:.08rem; } + .meta-val-url { font-size:.76rem; margin-top:.08rem; } + + /* quick-facts grid */ + .quick-grid { display:grid; grid-template-columns:auto 1fr; gap:.2rem .6rem; font-size:.84rem; } + .qk { font-weight:600; color:var(--text-secondary,#666); white-space:nowrap; padding:.15rem 0; } + .qv { word-break:break-all; padding:.15rem 0; font-family:monospace; font-size:.82rem; } + + /* collapsible details */ + details.meta-section summary { + cursor:pointer; font-weight:600; font-size:.88rem; color:var(--text-primary,#1a1a2e); + user-select:none; list-style:none; display:flex; align-items:center; gap:.45rem; + } + details.meta-section summary::before { content:'▶'; font-size:.7rem; color:var(--text-secondary,#aaa); transition:transform .15s; } + details.meta-section[open] summary::before { transform:rotate(90deg); } + details.meta-section summary::-webkit-details-marker { display:none; } + details.meta-section .det-body { margin-top:.75rem; } + + /* code blocks */ + .code-block-wrap { position:relative; margin:.35rem 0; } + .code-block { + background:var(--bg-secondary,#f4f6fb); border:1.5px solid var(--border-color,#e0e4f0); + border-radius:7px; padding:.75rem 3rem .75rem .85rem; + font-size:.78rem; white-space:pre; overflow-x:auto; margin:0; line-height:1.55; + font-family:'SFMono-Regular',Consolas,monospace; + } + .copy-btn { + position:absolute; top:.45rem; right:.45rem; + background:var(--bg-primary,#fff); border:1px solid var(--border-color,#dde1ee); + border-radius:5px; padding:.18rem .55rem; font-size:.72rem; font-weight:600; + color:var(--text-secondary,#666); cursor:pointer; transition:background .12s; + } + .copy-btn:hover { background:var(--primary-color,#2563eb); color:#fff; border-color:transparent; } + + /* asset rows */ + .asset-row { + display:flex; align-items:flex-start; gap:.7rem; + padding:.5rem 0; border-bottom:1px solid var(--border-color,#f0f2f8); + } + .asset-row:last-child { border-bottom:none; } + .asset-title { font-weight:600; font-size:.87rem; } + .asset-type { font-size:.74rem; color:var(--text-secondary,#888); font-family:monospace; margin-top:.1rem; } @@ -142,8 +197,12 @@

🗂️ {{ title }} — STAC Browser

// ── State ───────────────────────────────────────────────────────────────── // view: 'collections' | 'collection' | 'catalog' | 'item' -// catalogPath: "class=od/stream=oper" style string (empty = root) -let S = { view: 'collections', collectionId: null, catalogPath: '', itemId: null }; +// collections → list of collection cards (clicking one goes to catalog) +// collection → collection overview/detail (reachable via breadcrumb) +// catalog → hierarchical drill-down (root or nested key=value path) +// item → full item detail with map +// catalogPath: "class=od/stream=oper" style string (empty = root catalog) +let S = { view: 'collections', collectionId: null, catalogPath: '', itemId: null, itemData: null }; // Set external viewer link document.getElementById('ext-link').href = @@ -170,34 +229,44 @@

🗂️ {{ title }} — STAC Browser

crumbs.push({ label: 'Collections', click: () => go({ view:'collections', collectionId:null, catalogPath:'', itemId:null }) }); if (S.collectionId) { - crumbs.push({ label: S.collectionId, click: () => go({ view:'collection', catalogPath:'', itemId:null }) }); + // collection name → collection detail (overview), catalog root → no extra crumb + const collClick = () => go({ view:'collection', catalogPath:'', itemId:null }); + if (S.view === 'collection') { + crumbs.push({ label: S.collectionId, click: null }); + } else { + crumbs.push({ label: S.collectionId, click: collClick }); + } } if (S.view === 'catalog' || S.view === 'item') { - // one crumb per key=value pair in the path const parts = S.catalogPath ? S.catalogPath.split('/') : []; - // root catalog - crumbs.push({ label: 'Catalogue', click: () => go({ view:'catalog', catalogPath:'', itemId:null }) }); + // root catalog crumb only if we're deeper + if (parts.length > 0 || S.view === 'item') { + crumbs.push({ label: 'Catalogue', click: () => go({ view:'catalog', catalogPath:'', itemId:null }) }); + } let acc = ''; for (let i = 0; i < parts.length; i++) { const seg = parts[i]; acc = acc ? acc + '/' + seg : seg; const snap = acc; const [k, v] = seg.split('='); - crumbs.push({ label: `${k} = ${v}`, click: () => go({ view:'catalog', catalogPath: snap, itemId:null }) }); + const isLast = i === parts.length - 1 && S.view !== 'item'; + crumbs.push({ label: `${v}`, title: `${k} = ${v}`, click: isLast ? null : () => go({ view:'catalog', catalogPath: snap, itemId:null }) }); } } if (S.view === 'item' && S.itemId) { - crumbs.push({ label: S.itemId.slice(0, 12) + '…', click: null }); + crumbs.push({ label: S.itemId.slice(0, 16) + '…', click: null }); } bc.innerHTML = crumbs.map((c, i) => { const sep = i ? '' : ''; + const label = esc(c.label); + const tip = c.title ? ` title="${esc(c.title)}"` : ''; if (i === crumbs.length - 1 || !c.click) - return `${sep}${esc(c.label)}`; + return `${sep}${label}`; const fn = c.click.toString(); - return `${sep}${esc(c.label)}`; + return `${sep}${label}`; }).join(' '); } @@ -228,57 +297,230 @@

🗂️ {{ title }} — STAC Browser

const d0 = interval[0] ? interval[0].slice(0,10) : '—'; const d1 = interval[1] ? interval[1].slice(0,10) : '—'; const card = mkCard('Collection', c.title || c.id, c.description || '', `📅 ${d0} → ${d1}`); - card.onclick = () => go({ view:'collection', collectionId: c.id, catalogPath:'', itemId:null }); + card.onclick = () => go({ view:'catalog', collectionId: c.id, catalogPath:'', itemId:null }); el.querySelector('#cg').appendChild(card); } } // ── Collection detail ───────────────────────────────────────────────────── +let _collItems = null; // cached items for current collection +let _collItemsPage = 0; +const COLL_PAGE = 50; + async function renderCollection(el) { const cid = S.collectionId; + _collItems = null; + _collItemsPage = 0; const c = await api(`${API}/collections/${cid}`); const interval = c.extent?.temporal?.interval?.[0] || []; const d0 = interval[0] ? interval[0].slice(0,10) : '—'; const d1 = interval[1] ? interval[1].slice(0,10) : '—'; const summHtml = Object.entries(c.summaries || {}).map(([k, vals]) => { - const chips = vals.slice(0,24).map(v => `${esc(String(v))}`).join(''); - const more = vals.length > 24 ? `+${vals.length-24} more` : ''; - return `
${esc(k)}
${chips}${more}
`; + const chips = vals.slice(0,20).map(v => `${esc(String(v))}`).join(''); + const more = vals.length > 20 ? `+${vals.length-20} more` : ''; + return `
${esc(k)}
${chips}${more}
`; }).join(''); el.innerHTML = ` -
-
Collection
-

${esc(c.title||c.id)}

-

${esc(c.description||'')}

-
- Temporal${d0} → ${d1} - License${esc(c.license||'—')} +
+
+
Collection
+

${esc(c.title||c.id)}

+
+
+ +
+ + + +
+ + +
+
+
Description
+

${esc(c.description||'No description.')}

+
+ Temporal${d0} → ${d1} + License${esc(c.license||'—')} +
+
+ ${summHtml ? `
Dimension Summaries
${summHtml}
` : ''} +
+ + + + + + `; + + // pre-load items in background so the tab is snappy + _loadCollItems(cid); +} + +async function _loadCollItems(cid) { + try { + const data = await api(`${API}/collections/${cid}/items?limit=${COLL_PAGE}&offset=0`); + _collItems = data; + _renderCollItemsTable(); + } catch(e) { + const w = document.getElementById('coll-items-wrap'); + if (w) w.innerHTML = `
⚠️ ${esc(e.message)}
`; + } +} + +function _renderCollItemsTable() { + const wrap = document.getElementById('coll-items-wrap'); + if (!wrap) return; + if (!_collItems) { wrap.innerHTML = '

Loading…

'; return; } + const features = _collItems.features || []; + const total = _collItems.numberMatched || features.length; + if (!features.length) { wrap.innerHTML = '

No items found.

'; return; } + + // Collect all property keys (prioritised) + const priority = ['datetime','date','time','type','stream','class','param','levtype','step']; + const keySet = new Set(); + features.forEach(f => Object.keys(f.properties||{}).forEach(k => keySet.add(k))); + const keys = [...priority.filter(k => keySet.has(k)), + ...[...keySet].filter(k => !priority.includes(k) && k !== 'datetime')]; + + const offset = _collItems._offset || 0; + const prevBtn = offset > 0 + ? `` : ''; + const nextBtn = offset + features.length < total + ? `` : ''; + + wrap.innerHTML = ` +
+ ${offset+1}–${offset+features.length} of ${total} items + + ${prevBtn}${nextBtn}
- `; +
+ + ${keys.map(k=>``).join('')} + +
#${esc(k)}
+
`; + + const tb = wrap.querySelector('#coll-items-tb'); + features.forEach((f, i) => { + const props = f.properties || {}; + const iid = f.id; + const prebuiltItem = { + type: 'Feature', stac_version: '1.0.0', + id: iid, collection: S.collectionId, + geometry: null, bbox: null, + properties: props, links: f.links || [], assets: f.assets || {}, + }; + const tr = document.createElement('tr'); + tr.className = 'data-row'; + tr.innerHTML = `${offset+i+1}` + + keys.map(k => { + const v = props[k]; + return `${v != null ? esc(String(v)) : ''}`; + }).join(''); + tr.onclick = () => go({ view:'item', itemId: iid, itemData: prebuiltItem }); + tb.appendChild(tr); + }); +} + +async function _collItemsPrev() { + const offset = Math.max(0, (_collItems?._offset || 0) - COLL_PAGE); + await _collLoadPage(offset); +} +async function _collItemsNext() { + const offset = (_collItems?._offset || 0) + COLL_PAGE; + await _collLoadPage(offset); +} +async function _collLoadPage(offset) { + const wrap = document.getElementById('coll-items-wrap'); + if (wrap) wrap.innerHTML = '

Loading…

'; + const data = await api(`${API}/collections/${S.collectionId}/items?limit=${COLL_PAGE}&offset=${offset}`); + data._offset = offset; + _collItems = data; + _renderCollItemsTable(); +} + +function showCollTab(name) { + ['ov','items','hier'].forEach(n => { + document.getElementById(`ctab-${n}`)?.classList.toggle('active', n === name); + const b = document.getElementById(`ctab-body-${n}`); + if (b) b.style.display = n === name ? '' : 'none'; + }); + // kick render if items tab selected and already loaded + if (name === 'items') _renderCollItemsTable(); } // ── Catalog node ────────────────────────────────────────────────────────── +let _collCache = {}; // collection metadata cache keyed by id + +async function _getCollection(cid) { + if (!_collCache[cid]) _collCache[cid] = api(`${API}/collections/${cid}`); + return _collCache[cid]; +} + async function renderCatalog(el) { const cid = S.collectionId; const url = S.catalogPath ? `${CATALOG_BASE(cid)}/${S.catalogPath}` : CATALOG_BASE(cid); - const node = await api(url); + // fetch catalog node + (at root) collection info in parallel + const isRoot = !S.catalogPath; + const [node, coll] = await Promise.all([api(url), isRoot ? _getCollection(cid) : Promise.resolve(null)]); + + el.innerHTML = ''; // clear the "Loading…" placeholder + + // ── collection info banner at root level ────────────────────────────── + if (isRoot && coll) { + const interval = coll.extent?.temporal?.interval?.[0] || []; + const d0 = interval[0] ? interval[0].slice(0,10) : '—'; + const d1 = interval[1] ? interval[1].slice(0,10) : '—'; + const summKeys = Object.keys(coll.summaries || {}); + const chips = summKeys.slice(0,6).map(k => { + const vals = (coll.summaries[k] || []); + return `${esc(k)}: ${vals.length > 3 ? vals.slice(0,3).map(v=>esc(String(v))).join(', ')+' +'+( vals.length-3)+' more' : vals.map(v=>esc(String(v))).join(', ')}`; + }).join(''); + const infoHtml = ` +
+ + Collection + ${esc(coll.title||coll.id)} + ${esc(coll.description||'')} + +
+ 📅 ${d0} → ${d1} + ${chips ? `
${chips}
` : ''} + View full collection details → +
+
`; + el.insertAdjacentHTML('beforeend', infoHtml); + } const childLinks = (node.links || []).filter(l => l.rel === 'child'); const itemLinks = (node.links || []).filter(l => l.rel === 'item'); const nextKey = node.next_key; + // content goes into a div that sits below the (already-inserted) banner + const body = document.createElement('div'); + el.appendChild(body); + if (childLinks.length > 0) { // ── Internal node: show child catalog cards ── - el.innerHTML = ` + body.innerHTML = `
Select a value for ${esc(nextKey || '?')} @@ -287,16 +529,14 @@

${esc(c.title||c.id)}

`; for (const lnk of childLinks) { - const val = lnk['stac:value'] ?? lnk.title ?? ''; - const key = lnk['stac:key'] ?? nextKey ?? ''; - // strip " (description)" suffix from title to get clean value + const val = lnk['stac:value'] ?? lnk.title ?? ''; + const key = lnk['stac:key'] ?? nextKey ?? ''; const rawVal = lnk['stac:value'] ?? val; - // description = title minus the "key = value" prefix - const desc = lnk.title?.replace(`${key} = ${rawVal}`, '').replace(/^\s*\(\s*/, '').replace(/\s*\)\s*$/, '') || ''; + const desc = lnk.title?.replace(`${key} = ${rawVal}`, '').replace(/^\s*\(\s*/, '').replace(/\s*\)\s*$/, '') || ''; const childPath = S.catalogPath ? S.catalogPath + '/' + key + '=' + rawVal : key + '=' + rawVal; const card = mkCard('Value', esc(rawVal), esc(desc), `🔑 ${esc(key)}`); card.onclick = () => go({ view:'catalog', catalogPath: childPath }); - el.querySelector('#cg').appendChild(card); + body.querySelector('#cg').appendChild(card); } } else if (itemLinks.length > 0) { @@ -306,7 +546,7 @@

${esc(c.title||c.id)}

const priority = ['datetime','date','time','type','stream','param','levtype','step','number']; const keys = [...priority.filter(k => allKeys.has(k)), ...[...allKeys].filter(k => !priority.includes(k) && k !== 'datetime')]; - el.innerHTML = ` + body.innerHTML = `
Leaf ${itemLinks.length} item${itemLinks.length !== 1 ? 's' : ''} @@ -318,10 +558,17 @@

${esc(c.title||c.id)}

`; - const tb = el.querySelector('#tb'); + const tb = body.querySelector('#tb'); itemLinks.forEach((lnk, i) => { const props = lnk['stac:properties'] || {}; const iid = lnk.href.split('/').pop(); + // pre-build a lightweight STAC item from the catalog link data + const prebuiltItem = { + type: 'Feature', stac_version: '1.0.0', + id: iid, collection: S.collectionId, + geometry: null, bbox: null, + properties: props, links: [], assets: {}, + }; const tr = document.createElement('tr'); tr.className = 'data-row'; tr.innerHTML = `${i+1}` + @@ -329,12 +576,12 @@

${esc(c.title||c.id)}

const v = props[k]; return `${v != null ? esc(String(v)) : ''}`; }).join(''); - tr.onclick = () => go({ view:'item', itemId: iid }); + tr.onclick = () => go({ view:'item', itemId: iid, itemData: prebuiltItem }); tb.appendChild(tr); }); } else { - el.innerHTML = '

No children or items at this level.

'; + body.innerHTML = '

No children or items at this level.

'; } } @@ -346,73 +593,236 @@

${esc(c.title||c.id)}

return _lang; } -let _currentItem = null; // stashed for the Map tab onclick +let _currentItem = null; // kept for debug/console access // ── Item detail ─────────────────────────────────────────────────────────── async function renderItem(el) { const cid = S.collectionId; const iid = S.itemId; - const [item, lang] = await Promise.all([ - api(`${API}/collections/${cid}/items/${iid}`), - getLang(), - ]); + + // Use pre-built item from catalog state immediately (no round-trip needed). + // Fetch full item + language in parallel in the background to enrich display. + let item = S.itemData || null; + let lang = _lang || {}; + + if (!item) { + // Fallback: fetch from server (slower path, e.g. direct URL navigation) + [item, lang] = await Promise.all([ + api(`${API}/collections/${cid}/items/${iid}`), + getLang(), + ]); + } else { + // Render immediately with whatever language we have cached, + // then re-render once language finishes loading. + getLang().then(l => { + lang = l; + _currentItem = item; + _renderItemHtml(el, item, lang, cid, iid); + }); + } + _currentItem = item; + _renderItemHtml(el, item, lang, cid, iid); +} + +function _renderItemHtml(el, item, lang, cid, iid) { const props = item.properties || {}; - // ── Metadata tab ────────────────────────────────────────────────────── - const metaRows = Object.entries(props).map(([k, v]) => { + // ── Metadata row builder ────────────────────────────────────────────── + function metaRow(k, v) { const langKey = lang[k] || {}; const keyDesc = langKey.description || ''; - const valStr = v != null ? String(v) : ''; + const valStr = v != null ? String(v) : 'null'; const valInfo = (langKey.values || {})[valStr] || {}; const valName = valInfo.name || ''; const valDesc = valInfo.description || ''; const valUrl = valInfo.url || ''; - const rawSpan = `${esc(valStr || 'null')}`; - const nameSpan = valName ? `${esc(valName)} ${rawSpan}` : rawSpan; + const rawSpan = `${esc(valStr)}`; + const nameSpan = valName ? `${esc(valName)} ${rawSpan}` : rawSpan; const descDiv = valDesc ? `
${esc(valDesc)}
` : ''; - const urlDiv = valUrl ? `` : ''; - return ` - -
${esc(k)}
${keyDesc ? `
${esc(keyDesc)}
` : ''} - ${nameSpan}${descDiv}${urlDiv} - `; - }).join(''); + const urlDiv = valUrl ? `` : ''; + return ` + +
${esc(k)}
+ ${keyDesc ? `
${esc(keyDesc)}
` : ''} + + ${nameSpan}${descDiv}${urlDiv} + `; + } - // ── Links tab ───────────────────────────────────────────────────────── - const linksHtml = (item.links||[]).map(l => - `
${esc(l.rel)} - — ${esc(l.type||'')}
` - ).join(''); + // top-level "general" keys surfaced in the left column + const GENERAL_KEYS = ['datetime','date','time','class','stream','type','dataset','expver', + 'activity','experiment','generation','model','realization']; + const generalEntries = GENERAL_KEYS.filter(k => props[k] != null).map(k => [k, props[k]]); + const otherEntries = Object.entries(props).filter(([k]) => !GENERAL_KEYS.includes(k)); + const generalRows = generalEntries.map(([k,v]) => metaRow(k, v)).join(''); + const otherRows = otherEntries.map(([k,v]) => metaRow(k, v)).join(''); + + // ── Spatial extent ──────────────────────────────────────────────────── + const bbox = item.bbox || [-180, -90, 180, 90]; + const isGlobal = bbox[0]===-180 && bbox[1]===-90 && bbox[2]===180 && bbox[3]===90; + const bboxLabel = isGlobal + ? 'Global (−180 → 180°, −90 → 90°)' + : `${bbox[0]}°, ${bbox[1]}° → ${bbox[2]}°, ${bbox[3]}°`; + + // ── Code snippets ───────────────────────────────────────────────────── + // MARS request — all props except STAC-internal datetime + const marsProps = Object.entries(props).filter(([k]) => k !== 'datetime'); + const marsLines = marsProps.map(([k,v]) => ` ${k}=${v}`).join(',\n'); + const marsSnippet = `retrieve,\n${marsLines},\n target = "output.grib"`; + + // Python earthkit.data + const marsDict = Object.fromEntries(marsProps); + const pyDictStr = '{\n' + Object.entries(marsDict).map(([k,v]) => ` "${k}": "${v}"`).join(',\n') + '\n}'; + const earthkitSnippet = +`import earthkit.data as ekd + +request = ${pyDictStr} + +ds = ekd.from_source("mars", request) +print(ds.to_pandas())`; + + // STAC API (Python requests) + const stacUrl = `${location.origin}/api/stac/v1/collections/${cid}/items/${iid}`; + const stacSnippet = +`import requests + +# Fetch item metadata from the STAC API +resp = requests.get("${stacUrl}") +item = resp.json() + +# Properties contain the MARS request parameters +props = item["properties"] +print(props) + +# You can then build a data request: +import earthkit.data as ekd +ds = ekd.from_source("mars", {k: v for k, v in props.items() if k != "datetime"})`; + + // STAC + curl one-liner + const curlSnippet = `curl -s "${stacUrl}" | python3 -m json.tool`; + + // ── Assets (STAC assets dict, may be empty) ─────────────────────────── + const assets = item.assets || {}; + const assetKeys = Object.keys(assets); + const assetsBlock = assetKeys.length + ? assetKeys.map(ak => { + const a = assets[ak]; + return `
+
+
${esc(a.title||ak)}
+ ${a.type ? `
${esc(a.type)}
` : ''} + ${(a.roles||[]).length ? `
${esc(a.roles.join(', '))}
` : ''} +
+ ↓ Download +
`; + }).join('') + : ''; el.innerHTML = ` -
-

${esc(iid)}

-
-
- - - - -
-
- - - ${metaRows} -
FieldValue
-
- - -