Skip to content

Commit eea3096

Browse files
committed
feat: add image export, style improvements
1 parent 218c19f commit eea3096

9 files changed

Lines changed: 198 additions & 20 deletions

File tree

index.html

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,24 @@
1313
<div id="controls"></div>
1414

1515
<div id="brand">
16-
<div class="brand-name">titiler-cmr</div>
16+
<div class="brand-header">
17+
<div class="brand-name">titiler-cmr</div>
18+
<div class="brand-actions">
19+
<button id="share-btn" title="Copy link" aria-label="Copy link">
20+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
21+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
22+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
23+
</svg>
24+
</button>
25+
<button id="export-btn" title="Export PNG" aria-label="Export PNG">
26+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
27+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
28+
<polyline points="7 10 12 15 17 10"/>
29+
<line x1="12" y1="15" x2="12" y2="3"/>
30+
</svg>
31+
</button>
32+
</div>
33+
</div>
1734
<div class="brand-links">
1835
<a href="https://developmentseed.org/titiler-cmr" target="_blank" rel="noopener">docs</a>
1936
<a href="https://github.com/developmentseed/titiler-cmr-browser" target="_blank" rel="noopener">source</a>

src/config.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export type QueryParamConfig =
9797

9898
export type CollectionConfig = {
9999
label: string;
100+
/** Short slug included in export filenames (useful when a dataset has multiple collections). */
101+
slug?: string;
100102
collectionConceptId: string;
101103
assetsRegex?: string;
102104
minzoom: number;
@@ -132,6 +134,7 @@ export const DATASETS: DatasetConfig[] = [
132134
collection: [
133135
{
134136
label: "HLSS30 (Sentinel-2)",
137+
slug: "hlss30",
135138
collectionConceptId: "C2021957295-LPCLOUD",
136139
assetsRegex: "B[0-9][0-9A-Za-z]",
137140
backend: "rasterio",
@@ -175,6 +178,7 @@ export const DATASETS: DatasetConfig[] = [
175178
},
176179
{
177180
label: "HLSL30 (Landsat 8/9)",
181+
slug: "hlsl30",
178182
collectionConceptId: "C2021957657-LPCLOUD",
179183
assetsRegex: "B[0-9][0-9]",
180184
minzoom: 5,
@@ -227,8 +231,8 @@ export const DATASETS: DatasetConfig[] = [
227231
backend: "xarray",
228232
minzoom: 6,
229233
maxzoom: 13,
230-
attribution: '<a href="https://nisar.jpl.nasa.gov/" target="_blank">NISAR GCOV (NASA JPL / ASF DAAC)</a>',
231-
date: { mode: "range" },
234+
attribution: '<a href="https://nisar.jpl.nasa.gov/" target="_blank">NISAR GCOV (NASA JPL / ISRO / ASF DAAC)</a>',
235+
date: { mode: "range", default: ["2026-01-01", "2026-04-01"] },
232236
queryParams: [
233237
{
234238
type: "attribute",
@@ -316,7 +320,7 @@ export const DATASETS: DatasetConfig[] = [
316320
maxzoom: 7,
317321
attribution:
318322
'<a href="https://ceos.org/gst/micasa.html" target="_blank">MiCASA (NASA GES DISC)</a>',
319-
date: { mode: "single" },
323+
date: { mode: "single", default: "2024-12-31" },
320324
renders: [
321325
{
322326
label: "Net Primary Productivity (NPP)",

src/controls.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,12 @@ function renderDateControls(
124124
container.appendChild(input);
125125
inputs = [input];
126126
fetchMetadata(collection.collectionConceptId).then((umm) => {
127-
const begin = umm?.TemporalExtents?.[0]?.RangeDateTimes?.[0]?.BeginningDateTime;
128-
if (begin) inputs.forEach((el) => (el.min = begin.slice(0, 10)));
127+
const range = umm?.TemporalExtents?.[0]?.RangeDateTimes?.[0];
128+
if (range?.BeginningDateTime) inputs.forEach((el) => (el.min = range.BeginningDateTime!.slice(0, 10)));
129+
if (range?.EndingDateTime && !range.EndsAtPresentFlag) {
130+
const endYMD = range.EndingDateTime.slice(0, 10);
131+
inputs.forEach((el) => { if (endYMD < el.max) el.max = endYMD; });
132+
}
129133
});
130134
return () => {
131135
const d = input.value || defaultVal;
@@ -144,8 +148,12 @@ function renderDateControls(
144148
container.appendChild(input);
145149
inputs = [input];
146150
fetchMetadata(collection.collectionConceptId).then((umm) => {
147-
const begin = umm?.TemporalExtents?.[0]?.RangeDateTimes?.[0]?.BeginningDateTime;
148-
if (begin) inputs.forEach((el) => (el.min = begin.slice(0, 7)));
151+
const range = umm?.TemporalExtents?.[0]?.RangeDateTimes?.[0];
152+
if (range?.BeginningDateTime) inputs.forEach((el) => (el.min = range.BeginningDateTime!.slice(0, 7)));
153+
if (range?.EndingDateTime && !range.EndsAtPresentFlag) {
154+
const endYM = range.EndingDateTime.slice(0, 7);
155+
inputs.forEach((el) => { if (endYM < el.max) el.max = endYM; });
156+
}
149157
});
150158
return () => monthToDatetimeRange(input.value || defaultMonth);
151159
} else {
@@ -169,8 +177,12 @@ function renderDateControls(
169177
container.appendChild(endInput);
170178
inputs = [startInput, endInput];
171179
fetchMetadata(collection.collectionConceptId).then((umm) => {
172-
const begin = umm?.TemporalExtents?.[0]?.RangeDateTimes?.[0]?.BeginningDateTime;
173-
if (begin) inputs.forEach((el) => (el.min = begin.slice(0, 10)));
180+
const range = umm?.TemporalExtents?.[0]?.RangeDateTimes?.[0];
181+
if (range?.BeginningDateTime) inputs.forEach((el) => (el.min = range.BeginningDateTime!.slice(0, 10)));
182+
if (range?.EndingDateTime && !range.EndsAtPresentFlag) {
183+
const endYMD = range.EndingDateTime.slice(0, 10);
184+
inputs.forEach((el) => { if (endYMD < el.max) el.max = endYMD; });
185+
}
174186
});
175187
return () => {
176188
const s = startInput.value || defaultStart;

src/export.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { Map } from "maplibre-gl";
2+
3+
/**
4+
* Captures the current map canvas and triggers a PNG download.
5+
* If `attribution` is provided (an HTML string), it is stripped to plain text
6+
* and rendered as an overlay in the bottom-right corner of the image.
7+
* If `label` is provided it is slugified and included in the filename.
8+
* Requires the map to be initialized with `preserveDrawingBuffer: true`.
9+
*/
10+
export function exportMapImage(
11+
map: Map,
12+
attribution?: string,
13+
datasetSlug?: string,
14+
collectionSlug?: string,
15+
label?: string,
16+
dateStr?: string
17+
): void {
18+
const mapCanvas = map.getCanvas();
19+
const { width, height } = mapCanvas;
20+
21+
const canvas = document.createElement("canvas");
22+
canvas.width = width;
23+
canvas.height = height;
24+
const ctx = canvas.getContext("2d")!;
25+
26+
ctx.drawImage(mapCanvas, 0, 0);
27+
28+
if (attribution) {
29+
const text = attribution.replace(/<[^>]*>/g, "");
30+
const fontSize = 11;
31+
const padding = 5;
32+
ctx.font = `${fontSize}px system-ui, sans-serif`;
33+
const textWidth = ctx.measureText(text).width;
34+
const boxW = textWidth + padding * 2;
35+
const boxH = fontSize + padding * 2;
36+
const x = width - boxW - 4;
37+
const y = height - boxH - 4;
38+
39+
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
40+
ctx.fillRect(x, y, boxW, boxH);
41+
ctx.fillStyle = "#e8e8e8";
42+
ctx.fillText(text, x + padding, y + padding + fontSize - 1);
43+
}
44+
45+
const datasetPart = datasetSlug ? `-${datasetSlug}` : "";
46+
const collectionPart = collectionSlug ? `-${collectionSlug}` : "";
47+
const labelPart = label
48+
? "-" + label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+$/, "")
49+
: "";
50+
const datePart = dateStr ? `-${dateStr}` : "";
51+
const link = document.createElement("a");
52+
link.download = `titiler-cmr${datasetPart}${collectionPart}${labelPart}${datePart}.png`;
53+
link.href = canvas.toDataURL("image/png");
54+
link.click();
55+
}

src/layers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function removeCmrLayers(map: Map): void {
2020
}
2121
}
2222

23+
2324
/**
2425
* Builds the shared URLSearchParams for a TileJSON request.
2526
* Handles string | string[] param values and repeated `attribute` filters.
@@ -113,4 +114,5 @@ export function updateLayer(map: Map, state: ControlState): void {
113114
p.set("minzoom", String(collection.minzoom));
114115
addRasterLayer(map, "cmr-0", `${base}?${p}`, collection.minzoom, undefined, collection.attribution);
115116
}
117+
116118
}

src/loading.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,38 @@
11
import type { Map } from "maplibre-gl";
22

3+
/**
4+
* Sets visibility on all symbol layers in the current style (basemap labels).
5+
*/
6+
function setBasemapLabelsVisible(map: Map, visible: boolean): void {
7+
const visibility = visible ? "visible" : "none";
8+
for (const layer of map.getStyle().layers ?? []) {
9+
if (layer.type === "symbol") {
10+
map.setLayoutProperty(layer.id, "visibility", visibility);
11+
}
12+
}
13+
}
14+
315
/**
416
* Shows a loading indicator while map tiles are being fetched, but only when
517
* the map is at or above the active layer's minimum zoom (below that, no data
618
* tiles are requested). `getMinZoom` is called on each event so it always
719
* reflects the currently selected collection.
20+
*
21+
* Also toggles basemap labels: shown while tiles are loading (so the map
22+
* isn't a blank dark canvas), hidden once tiles finish rendering.
823
*/
924
export function initLoading(map: Map, getMinZoom: () => number): void {
1025
const el = document.getElementById("loading")!;
1126

1227
map.on("dataloading", () => {
1328
if (map.getZoom() >= getMinZoom()) {
1429
el.classList.add("visible");
30+
setBasemapLabelsVisible(map, true);
1531
}
1632
});
1733

1834
map.on("idle", () => {
1935
el.classList.remove("visible");
36+
setBasemapLabelsVisible(map, map.getZoom() < getMinZoom());
2037
});
2138
}

src/main.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,23 @@ import { initZoomGuard } from "./zoom-guard";
1414
import { initAbout } from "./about";
1515
import { initCollectionDetails } from "./collection-details";
1616
import { decodeState, encodeState, getRawDateFromDom } from "./url-state";
17+
import { exportMapImage } from "./export";
1718

1819
initAbout();
1920
initCollectionDetails();
2021

21-
// Add share button to brand links
22-
const shareBtn = document.createElement("button");
23-
shareBtn.id = "share-btn";
24-
shareBtn.textContent = "copy link";
22+
// Wire share button (pre-existing in HTML)
23+
const shareBtn = document.getElementById("share-btn") as HTMLButtonElement;
2524
shareBtn.addEventListener("click", () => {
2625
navigator.clipboard.writeText(window.location.href).then(() => {
27-
shareBtn.textContent = "copied!";
26+
shareBtn.title = "Copied!";
27+
shareBtn.style.color = "#64a0ff";
2828
setTimeout(() => {
29-
shareBtn.textContent = "copy link";
29+
shareBtn.title = "Copy link";
30+
shareBtn.style.color = "";
3031
}, 2000);
3132
});
3233
});
33-
document.querySelector(".brand-links")!.appendChild(shareBtn);
3434

3535
const initialUrlState = decodeState();
3636

@@ -39,9 +39,27 @@ const map = new maplibregl.Map({
3939
style: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
4040
center: [0, 20],
4141
zoom: 2,
42+
canvasContextAttributes: { preserveDrawingBuffer: true },
4243
});
4344

4445
map.addControl(new maplibregl.NavigationControl(), "bottom-right");
46+
map.addControl(
47+
new maplibregl.GeolocateControl({
48+
positionOptions: { enableHighAccuracy: true },
49+
fitBoundsOptions: { maxZoom: 10 },
50+
}),
51+
"bottom-right"
52+
);
53+
54+
document.getElementById("export-btn")!.addEventListener("click", () => {
55+
const { collection, render } = getState();
56+
const { datasetId } = getUrlMeta();
57+
const rawDate = getRawDateFromDom(collection.date.mode);
58+
const dateStr = rawDate.s && rawDate.e
59+
? `${rawDate.s}_${rawDate.e}`
60+
: (rawDate.dt ?? undefined);
61+
exportMapImage(map, collection.attribution, datasetId, collection.slug, render.label, dateStr);
62+
});
4563

4664
let mapReady = false;
4765

@@ -59,6 +77,8 @@ function updateUrl() {
5977
lng: parseFloat(center.lng.toFixed(4)),
6078
lat: parseFloat(center.lat.toFixed(4)),
6179
z: parseFloat(map.getZoom().toFixed(2)),
80+
b: parseFloat(map.getBearing().toFixed(1)) || undefined,
81+
pt: parseFloat(map.getPitch().toFixed(1)) || undefined,
6282
p:
6383
Object.keys(state.extraParams).length > 0
6484
? state.extraParams
@@ -106,6 +126,8 @@ map.on("load", () => {
106126
map.jumpTo({
107127
center: [initialUrlState.lng, initialUrlState.lat],
108128
zoom: initialUrlState.z,
129+
bearing: initialUrlState.b ?? 0,
130+
pitch: initialUrlState.pt ?? 0,
109131
});
110132
}
111133

@@ -117,3 +139,11 @@ map.on("load", () => {
117139
map.on("moveend", () => {
118140
if (mapReady) updateUrl();
119141
});
142+
143+
map.on("rotateend", () => {
144+
if (mapReady) updateUrl();
145+
});
146+
147+
map.on("pitchend", () => {
148+
if (mapReady) updateUrl();
149+
});

src/style.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,46 @@ body {
216216
padding: 8px 12px;
217217
}
218218

219+
.brand-header {
220+
display: flex;
221+
justify-content: space-between;
222+
align-items: center;
223+
gap: 8px;
224+
}
225+
219226
.brand-name {
220227
font-size: 14px;
221228
font-weight: 600;
222229
letter-spacing: 0.02em;
223230
color: #e8e8e8;
224231
}
225232

233+
.brand-actions {
234+
display: flex;
235+
gap: 2px;
236+
align-items: center;
237+
}
238+
239+
.brand-actions button {
240+
display: flex;
241+
align-items: center;
242+
justify-content: center;
243+
width: 22px;
244+
height: 22px;
245+
background: none;
246+
border: none;
247+
border-radius: 4px;
248+
color: #666;
249+
cursor: pointer;
250+
padding: 0;
251+
transition: color 0.15s, background 0.15s;
252+
}
253+
254+
.brand-actions button:hover {
255+
color: #e8e8e8;
256+
background: rgba(255, 255, 255, 0.08);
257+
}
258+
226259
.brand-links {
227260
display: flex;
228261
gap: 10px;

0 commit comments

Comments
 (0)