Skip to content

Commit f67a70f

Browse files
committed
feat: so many feats
1 parent 255a116 commit f67a70f

10 files changed

Lines changed: 871 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ Key UX requirements:
1515

1616
- **Build tool:** Vite
1717
- **Language:** TypeScript
18-
- **Mapping:** MapLibre GL JS (supports globe projection natively)
19-
- **Deployment:** GitHub Pages (static site, `gh-pages` branch)
18+
- **Mapping:** MapLibre GL JS v5 (supports globe projection natively)
19+
- **Deployment:** GitHub Pages (static site, `gh-pages` branch via `gh-pages` npm package)
2020

2121
## Development Commands
2222

@@ -41,33 +41,86 @@ npm run deploy
4141

4242
The backend API is documented at https://developmentseed.org/titiler-cmr. Key endpoints used by this app:
4343

44-
- **TileJSON:** `GET /{backend}/tilejson.json` — returns metadata including tile URL template; pass as a `raster-source` in MapLibre
44+
- **TileJSON:** `GET /{backend}/WebMercatorQuad/tilejson.json` — returns metadata including tile URL template; pass as a `raster-source` in MapLibre
4545
- **Tiles:** `GET /{backend}/tiles/{z}/{x}/{y}` — the actual tile imagery (`rasterio` backend for GeoTIFF/COG, `xarray` for NetCDF/HDF5)
46-
- **Granule search:** `GET /bbox/{minx},{miny},{maxx},{maxy}/granules` — find available granules in a bounding box
4746

48-
Common query parameters for tile requests: `concept_id`, `datetime`, `assets`/`bands`, `rescale`, `colormap_name`.
47+
Common query parameters for tile requests: `collection_concept_id`, `datetime`, `assets`/`variables`, `assets_regex`, `rescale`, `colormap_name`, `expression`, `minzoom`, `maxzoom`.
4948

50-
The hosted titiler-cmr instance URL should be stored in a single config constant (e.g., `src/config.ts`) so it can be easily swapped between environments.
49+
The hosted titiler-cmr instance URL is stored in `src/config.ts` as `TITILER_ENDPOINT` (currently `https://staging.openveda.cloud/api/titiler-cmr`).
5150

5251
## Architecture
5352

54-
Single-page static site, no server-side code. Globe projection via MapLibre GL JS v5 (`map.setProjection({ type: 'globe' })` called on `load`).
53+
Single-page static site, no server-side code. Globe projection via MapLibre GL JS v5 (`map.setProjection({ type: 'globe' })` called on `load`). Base map style: CartoCDN dark-matter.
54+
55+
MapLibre parallel image request limits are bumped to 64 (`MAX_PARALLEL_IMAGE_REQUESTS` and `MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME`) because CMR tiles are slow and benefit from aggressive parallel fetching.
5556

5657
```
5758
src/
58-
config.ts # TITILER_ENDPOINT, DatasetConfig/CollectionConfig/RenderConfig types, DATASETS array
59-
main.ts # Map init, wires controls → layers → zoom-guard → loading
60-
controls.ts # Dataset/Collection/Render selects + date picker; exports getState()
61-
layers.ts # updateLayer(): removes old source/layer, builds TileJSON URL, adds new raster source
59+
config.ts # TITILER_ENDPOINT, type definitions, DATASETS array
60+
main.ts # Map init, sky/globe setup, wires controls → layers → zoom-guard → loading
61+
controls.ts # Dataset/Collection/Render selects + date picker + extra params; exports getState()
62+
layers.ts # updateLayer(): removes old CMR sources/layers, builds TileJSON URL, adds new raster source(s)
6263
loading.ts # Shows #loading spinner on map `dataloading`, hides on `idle`
6364
zoom-guard.ts # Shows #zoom-guard message when zoom < collection.minzoom
6465
style.css # Full-viewport map, absolute-positioned overlay panels
6566
```
6667

67-
### Adding a new dataset
68-
69-
Add a new `DatasetConfig` entry to the `DATASETS` array in `src/config.ts`. No other code changes are needed. Each collection needs: `conceptId`, `assetsRegex`, `backend` (`rasterio` | `xarray`), `minzoom`/`maxzoom`, `defaultDate`, and a `renders` array of `RenderConfig` objects.
70-
71-
### Layer update flow
72-
73-
Controls emit a `ControlState` on any change → `updateLayer()` removes the previous `cmr-layer`/`cmr-source` and adds a new raster source pointing at the TileJSON endpoint with all render params encoded in the URL. MapLibre fetches TileJSON natively when `type: 'raster'` with a `url` field is used.
68+
## Type System (`src/config.ts`)
69+
70+
### `DatasetConfig`
71+
Top-level entry in `DATASETS`. Has `id`, `label`, and `collection` (a single `CollectionConfig` hides the collection selector; an array shows it).
72+
73+
### `CollectionConfig`
74+
Describes one CMR collection. Key fields:
75+
- `collectionConceptId` — CMR concept ID passed as `collection_concept_id`
76+
- `backend``"rasterio"` (GeoTIFF/COG) or `"xarray"` (NetCDF/HDF5)
77+
- `assetsRegex` — optional regex forwarded to titiler-cmr for asset filtering
78+
- `minzoom` / `maxzoom`
79+
- `attribution` — HTML string shown in MapLibre attribution control
80+
- `date: DateConfig` — controls date picker UI and `datetime` param format
81+
- `renders: RenderConfig[]`
82+
- `queryParams?: QueryParamConfig[]` — optional extra controls surfaced in the "Advanced" panel
83+
84+
### `DateConfig`
85+
Controls how the date UI is rendered and how `datetime` is serialized:
86+
- `{ mode: "single"; default: string }` — one date input; datetime sent as a single-day range
87+
- `{ mode: "range"; default: [string, string] }` — two date inputs (start / end)
88+
- `{ mode: "month"; default: string }` — month/year picker; datetime sent as full calendar month range
89+
90+
### `RenderConfig`
91+
One entry in `collection.renders`. Has `label`, optional `assets[]` (rasterio) or `variables[]` (xarray), `params` (arbitrary extra query params, values may be arrays for repeated params), and optional `subLayers`.
92+
93+
### `SubLayerSpec`
94+
When `render.subLayers` is set, `updateLayer()` creates one map layer per entry instead of a single layer. Each sub-layer has its own `params` override (e.g. `group` for xarray) and `minzoom`/`maxzoom`. Used for NISAR frequencyA/B switching based on zoom level.
95+
96+
### `QueryParamConfig`
97+
Union of four control types surfaced in the "Advanced" collapsible:
98+
- `RangeQueryParam` — two number inputs rendered as `"min,max"`
99+
- `SelectQueryParam` — dropdown; value sent as-is
100+
- `TextQueryParam` — free-text input
101+
- `AttributeQueryParam` — CMR additional-attribute filter; serialized as `attributeType,attributeName,value` under the `attribute` key. Supports a null value to omit the filter.
102+
103+
## Layer Update Flow
104+
105+
Controls emit a `ControlState` on any change → `updateLayer()`:
106+
1. Scans and removes all `cmr-0``cmr-7` layers/sources (fixed ID space, tolerates partial failures)
107+
2. Builds `URLSearchParams` from collection + render + datetime + extraParams
108+
3. If `render.subLayers` exists: adds one `raster` source+layer per sub-layer with per-sub-layer `minzoom`/`maxzoom`
109+
4. Otherwise: adds a single `cmr-0` raster source+layer
110+
111+
MapLibre fetches TileJSON natively when `type: 'raster'` with a `url` field is used.
112+
113+
## Adding a New Dataset
114+
115+
Add a new `DatasetConfig` entry to the `DATASETS` array in `src/config.ts`. No other code changes are needed. Key decisions per collection:
116+
- `backend`: use `rasterio` for GeoTIFF/COG, `xarray` for NetCDF/HDF5
117+
- `date.mode`: `"month"` works well for high-revisit optical (HLS); `"range"` for SAR/sparse; `"single"` for daily products (SST)
118+
- `queryParams`: add `cloud_cover` range for optical sensors, `attribute` filters for orbit direction etc.
119+
- `subLayers`: use when different zoom levels should hit different xarray groups (e.g. NISAR frequencyA vs frequencyB)
120+
- `assetsRegex`: use for rasterio collections where asset names follow a pattern (e.g. `"B[0-9][0-9]"` for HLS)
121+
122+
## Existing Datasets
123+
124+
- **HLS** (`C2021957657-LPCLOUD` / `C2021957295-LPCLOUD`) — Harmonized Landsat/Sentinel-2, rasterio, minzoom 5, True Color + False Color renders, cloud cover filter
125+
- **NISAR Beta GCOV** (`C3622214170-ASF`) — xarray, minzoom 6, HHHH/HVHV RGB render with frequencyA/B sub-layers, orbit direction attribute filter
126+
- **MUR SST** (`C1996881146-POCLOUD`) — xarray, minzoom 0, SST + sea ice fraction renders

index.html

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,55 @@
1212
<button id="controls-toggle" aria-label="Toggle controls" aria-expanded="false"></button>
1313
<div id="controls"></div>
1414

15-
<div id="logo">
16-
<img src="/titiler-cmr.png" alt="titiler-cmr" />
15+
<div id="brand">
16+
<div class="brand-name">titiler-cmr</div>
17+
<div class="brand-links">
18+
<a href="https://developmentseed.org/titiler-cmr" target="_blank" rel="noopener">docs</a>
19+
<a href="https://github.com/developmentseed/titiler-cmr-browser" target="_blank" rel="noopener">source</a>
20+
<button id="about-trigger">about</button>
21+
</div>
1722
</div>
1823

19-
<div id="zoom-guard">Zoom in further to see data</div>
24+
<div id="zoom-guard">Zoom in further to see data</div>
2025

2126
<div id="loading">
2227
<div class="spinner"></div>
2328
Loading tiles&hellip;
2429
</div>
2530

31+
<div id="legend"></div>
32+
33+
<div id="about-backdrop" aria-hidden="true"></div>
34+
<div id="about-modal" role="dialog" aria-modal="true" aria-labelledby="about-title">
35+
<div class="about-panel">
36+
<button id="about-close" aria-label="Close">&times;</button>
37+
<h2 id="about-title">About</h2>
38+
<div class="about-body">
39+
<section>
40+
<h3>This app</h3>
41+
<p>An interactive map showcasing <a href="https://developmentseed.org/titiler-cmr" target="_blank" rel="noopener">titiler-cmr</a>'s ability to render NASA Earthdata assets on-the-fly. Browse datasets, adjust date ranges, and explore rendering options — all served as map tiles from a live titiler-cmr instance.</p>
42+
</section>
43+
<section>
44+
<h3>NASA CMR</h3>
45+
<p>The <a href="https://cmr.earthdata.nasa.gov" target="_blank" rel="noopener">Common Metadata Repository</a> is NASA's catalog for Earth science data. It indexes thousands of collections — satellite imagery, atmospheric data, ocean observations — and exposes a unified search API used to locate assets for rendering.</p>
46+
</section>
47+
<section>
48+
<h3>titiler-cmr</h3>
49+
<p><a href="https://developmentseed.org/titiler-cmr" target="_blank" rel="noopener">titiler-cmr</a> is a dynamic tile server built on <a href="https://developmentseed.org/titiler" target="_blank" rel="noopener">TiTiler</a> that reads Cloud-Optimized GeoTIFFs and NetCDF/HDF5 assets directly from CMR, reprojects them, and serves map tiles without any preprocessing or data duplication.</p>
50+
</section>
51+
</div>
52+
</div>
53+
</div>
54+
55+
<div id="collection-details-backdrop" aria-hidden="true"></div>
56+
<div id="collection-details-modal" role="dialog" aria-modal="true" aria-labelledby="collection-details-title">
57+
<div class="about-panel">
58+
<button id="collection-details-close" aria-label="Close">&times;</button>
59+
<h2 id="collection-details-title">Collection Details</h2>
60+
<div class="collection-details-body"></div>
61+
</div>
62+
</div>
63+
2664
<script type="module" src="/src/main.ts"></script>
2765
</body>
2866
</html>

src/about.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Wires up the about modal: open on trigger click, close on backdrop/button/Escape.
3+
*/
4+
export function initAbout(): void {
5+
const trigger = document.getElementById("about-trigger")!;
6+
const modal = document.getElementById("about-modal")!;
7+
const backdrop = document.getElementById("about-backdrop")!;
8+
const closeBtn = document.getElementById("about-close")!;
9+
10+
function open() {
11+
modal.classList.add("visible");
12+
backdrop.classList.add("visible");
13+
}
14+
15+
function close() {
16+
modal.classList.remove("visible");
17+
backdrop.classList.remove("visible");
18+
}
19+
20+
const panel = modal.querySelector(".about-panel")!;
21+
22+
trigger.addEventListener("click", open);
23+
closeBtn.addEventListener("click", close);
24+
// Close when clicking the full-screen container outside the panel
25+
modal.addEventListener("click", close);
26+
// Prevent clicks inside the panel from bubbling up to the container
27+
panel.addEventListener("click", (e) => e.stopPropagation());
28+
document.addEventListener("keydown", (e) => {
29+
if (e.key === "Escape" && !modal.hidden) close();
30+
});
31+
}

src/collection-details.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { CollectionConfig } from "./config";
2+
3+
const CMR_SEARCH = "https://cmr.earthdata.nasa.gov/search";
4+
5+
type CmrRangeDateTime = {
6+
BeginningDateTime?: string;
7+
EndingDateTime?: string;
8+
EndsAtPresentFlag?: boolean;
9+
};
10+
11+
type CmrUmm = {
12+
Abstract?: string;
13+
TemporalExtents?: Array<{ RangeDateTimes?: CmrRangeDateTime[] }>;
14+
DOI?: { DOI: string };
15+
RelatedUrls?: Array<{ Type: string; URL: string }>;
16+
};
17+
18+
const cache = new Map<string, Promise<CmrUmm | null>>();
19+
20+
export function fetchMetadata(conceptId: string): Promise<CmrUmm | null> {
21+
if (!cache.has(conceptId)) {
22+
cache.set(
23+
conceptId,
24+
fetch(
25+
`${CMR_SEARCH}/collections.umm_json?concept_id=${encodeURIComponent(conceptId)}`
26+
)
27+
.then((r) => r.json())
28+
.then((data) => (data.items?.[0]?.umm as CmrUmm) ?? null)
29+
.catch(() => null)
30+
);
31+
}
32+
return cache.get(conceptId)!;
33+
}
34+
35+
/** Formats an ISO datetime string as a short date (YYYY-MM-DD). */
36+
function shortDate(iso: string): string {
37+
return iso.slice(0, 10);
38+
}
39+
40+
/** Builds the metadata section DOM from a fetched UMM record. */
41+
function renderMetadata(body: HTMLElement, umm: CmrUmm, conceptId: string): void {
42+
body.innerHTML = "";
43+
44+
if (umm.Abstract) {
45+
const abstract = document.createElement("p");
46+
abstract.className = "collection-details-abstract";
47+
abstract.textContent = umm.Abstract;
48+
body.appendChild(abstract);
49+
}
50+
51+
const dl = document.createElement("dl");
52+
dl.className = "collection-details-meta";
53+
54+
function addRow(label: string, content: string | HTMLElement): void {
55+
const dt = document.createElement("dt");
56+
dt.textContent = label;
57+
const dd = document.createElement("dd");
58+
if (typeof content === "string") {
59+
dd.textContent = content;
60+
} else {
61+
dd.appendChild(content);
62+
}
63+
dl.appendChild(dt);
64+
dl.appendChild(dd);
65+
}
66+
67+
// Temporal coverage
68+
const rangeDateTime =
69+
umm.TemporalExtents?.[0]?.RangeDateTimes?.[0];
70+
if (rangeDateTime) {
71+
const start = rangeDateTime.BeginningDateTime
72+
? shortDate(rangeDateTime.BeginningDateTime)
73+
: "unknown";
74+
const end = rangeDateTime.EndsAtPresentFlag
75+
? "present"
76+
: rangeDateTime.EndingDateTime
77+
? shortDate(rangeDateTime.EndingDateTime)
78+
: "present";
79+
addRow("Temporal", `${start}${end}`);
80+
}
81+
82+
// Concept ID with Earthdata Search link
83+
const conceptLink = document.createElement("a");
84+
conceptLink.href = `https://search.earthdata.nasa.gov/search?q=${encodeURIComponent(conceptId)}`;
85+
conceptLink.target = "_blank";
86+
conceptLink.rel = "noopener";
87+
conceptLink.textContent = conceptId;
88+
addRow("Concept ID", conceptLink);
89+
90+
// DOI
91+
if (umm.DOI?.DOI) {
92+
const doiLink = document.createElement("a");
93+
doiLink.href = `https://doi.org/${umm.DOI.DOI}`;
94+
doiLink.target = "_blank";
95+
doiLink.rel = "noopener";
96+
doiLink.textContent = umm.DOI.DOI;
97+
addRow("DOI", doiLink);
98+
}
99+
100+
// Dataset landing page from RelatedUrls
101+
const landingUrl = umm.RelatedUrls?.find(
102+
(u) => u.Type === "DATA SET LANDING PAGE"
103+
)?.URL;
104+
if (landingUrl) {
105+
const landingLink = document.createElement("a");
106+
landingLink.href = landingUrl;
107+
landingLink.target = "_blank";
108+
landingLink.rel = "noopener";
109+
landingLink.textContent = "Dataset landing page";
110+
addRow("More info", landingLink);
111+
}
112+
113+
body.appendChild(dl);
114+
}
115+
116+
/**
117+
* Opens the collection details modal for the given collection, fetching CMR
118+
* metadata on demand and caching it for subsequent views.
119+
*/
120+
export function showCollectionDetails(collection: CollectionConfig): void {
121+
const modal = document.getElementById("collection-details-modal")!;
122+
const backdrop = document.getElementById("collection-details-backdrop")!;
123+
const title = document.getElementById("collection-details-title")!;
124+
const body = modal.querySelector<HTMLElement>(".collection-details-body")!;
125+
126+
title.textContent = collection.label;
127+
128+
// Show spinner while fetching
129+
body.innerHTML = `<div class="collection-details-loading"><div class="spinner"></div></div>`;
130+
131+
modal.classList.add("visible");
132+
backdrop.classList.add("visible");
133+
134+
fetchMetadata(collection.collectionConceptId).then((umm) => {
135+
// Ensure the modal is still showing this collection (user may have closed it)
136+
if (!modal.classList.contains("visible")) return;
137+
138+
if (umm) {
139+
renderMetadata(body, umm, collection.collectionConceptId);
140+
} else {
141+
body.innerHTML =
142+
'<p class="collection-details-error">Could not load collection metadata.</p>';
143+
}
144+
});
145+
}
146+
147+
/**
148+
* Wires up the collection details modal close interactions.
149+
* Call once during app initialization.
150+
*/
151+
export function initCollectionDetails(): void {
152+
const modal = document.getElementById("collection-details-modal")!;
153+
const backdrop = document.getElementById("collection-details-backdrop")!;
154+
const closeBtn = document.getElementById("collection-details-close")!;
155+
156+
function close(): void {
157+
modal.classList.remove("visible");
158+
backdrop.classList.remove("visible");
159+
}
160+
161+
closeBtn.addEventListener("click", close);
162+
modal.addEventListener("click", close);
163+
modal
164+
.querySelector(".about-panel")!
165+
.addEventListener("click", (e) => e.stopPropagation());
166+
document.addEventListener("keydown", (e) => {
167+
if (e.key === "Escape" && modal.classList.contains("visible")) close();
168+
});
169+
}

0 commit comments

Comments
 (0)