<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>async-geotiff — Leaflet</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css"
/>
<style>
html,
body {
margin: 0;
height: 100%;
}
#map {
position: absolute;
inset: 0;
}
.toolbar {
position: absolute;
z-index: 1000;
top: 12px;
left: 56px;
right: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
background: rgba(255, 255, 255, 0.95);
padding: 12px 16px;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
font:
13px system-ui,
sans-serif;
}
.field[hidden] { display: none; }
.field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
max-width: 100%;
}
.field label {
font-weight: 600;
font-size: 11px;
color: #444;
}
.field.wide input,
.field.wide select {
min-width: 220px;
}
input,
select {
box-sizing: border-box;
max-width: 100%;
padding: 5px 6px;
border: 1px solid #ccc;
border-radius: 5px;
font: inherit;
}
button {
padding: 6px 14px;
border: 0;
border-radius: 5px;
background: #2563eb;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.nav {
margin-left: auto;
font-size: 12px;
}
.nav a {
color: #2563eb;
margin-left: 8px;
}
</style>
</head>
<body>
<div id="style-error" role="alert" hidden style="position:fixed;top:50%;left:1rem;right:1rem;z-index:1000;padding:1rem;background:#fff;color:#991b1b"></div>
<div id="map"></div>
<form class="toolbar" id="style-form">
<div class="field wide">
<label for="dataset">Dataset</label>
<select id="dataset" name="dataset"></select>
</div>
<div class="field">
<label for="basemap">Basemap</label>
<select id="basemap" name="basemap">
<option value="">blank</option>
<option value="osm">OpenStreetMap</option>
</select>
</div>
<div class="field">
<label for="colormap">Colormap</label>
<select id="colormap" name="colormap_name">
<option value="">greys</option>
<option value="viridis">viridis</option>
<option value="magma">magma</option>
<option value="inferno">inferno</option>
<option value="plasma">plasma</option>
<option value="cividis">cividis</option>
<option value="turbo">turbo</option>
</select>
</div>
<div class="field">
<label for="stretch">Stretch</label>
<select id="stretch" name="method">
<option value="none">none (0–255)</option>
<option value="linear">linear (rescale)</option>
<option value="minmax">minmax</option>
<option value="percentile">percentile</option>
<option value="stddev">standard deviation</option>
<option value="histogram_equalization">histogram equalization</option>
<option value="curve">curve</option>
</select>
</div>
<div class="field wide">
<label for="rescale">Rescale</label>
<input
id="rescale"
name="rescale"
type="text"
value="0,255;0,255;0,255"
/>
</div>
<div class="field" id="percentiles-field" hidden>
<label for="percentiles">Percentiles (low, high)</label>
<input id="percentiles" name="percentiles" type="text" value="2,98" />
</div>
<div class="field" id="stddev-field" hidden>
<label for="stddev">Standard deviations</label>
<input id="stddev" name="stddev" type="number" min="0.000001" step="any" value="2" />
</div>
<div class="field wide" id="curves-field" hidden>
<label for="curves">Curves ([[x,u],…] per channel)</label>
<input id="curves" name="curves" type="text" value="[[[0,0],[128,0.5],[255,1]]]" />
</div>
<div class="field">
<label for="bidx">Bands</label>
<input id="bidx" name="bidx" type="text" value="" placeholder="Auto" />
<small id="band-note" role="status" hidden></small>
</div>
<div class="field wide">
<label for="expression">Expression</label>
<input id="expression" name="expression" type="text" autocomplete="off" spellcheck="false"
placeholder="(b5-b4)/(b5+b4)">
</div>
<div class="field">
<label for="resampling">Kernel</label>
<select id="resampling" name="resampling">
<option value="nearest">nearest</option>
<option value="bilinear">bilinear</option>
<option value="average">average</option>
</select>
</div>
<div class="field">
<label for="tilesize">Tile size</label>
<select id="tilesize" name="tilesize">
<option value="64">64</option>
<option value="128">128</option>
<option value="256" selected>256</option>
<option value="512">512</option>
<option value="1024">1024</option>
</select>
</div>
<div class="field">
<label for="format">Format</label>
<select id="format" name="format">
<option value="png">PNG</option>
</select>
</div>
<div class="field">
<label for="opacity">Opacity</label>
<input
id="opacity"
type="range"
min="0"
max="1"
step="0.05"
value="0.9"
/>
</div>
<button type="submit">Apply</button>
<button type="button" id="ndvi">NDVI</button>
<span class="nav">
viewer:
<a href="/maplibre">MapLibre</a>
<a href="/openlayers">OpenLayers</a>
<a href="/leaflet">Leaflet</a>
</span>
</form>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const ORIGIN = window.location.origin;
const TILE_BASE = "/tiles/WebMercatorQuad/{z}/{x}/{y}";
let styleVersion = 0;
let rasterReady = false;
const c = {
dataset: document.getElementById("dataset"),
basemap: document.getElementById("basemap"),
colormap: document.getElementById("colormap"),
stretch: document.getElementById("stretch"),
rescale: document.getElementById("rescale"),
percentiles: document.getElementById("percentiles"),
stddev: document.getElementById("stddev"),
curves: document.getElementById("curves"),
bidx: document.getElementById("bidx"),
expression: document.getElementById("expression"),
ndvi: document.getElementById("ndvi"),
resampling: document.getElementById("resampling"),
tilesize: document.getElementById("tilesize"),
format: document.getElementById("format"),
opacity: document.getElementById("opacity"),
};
const pageParams = new URLSearchParams(window.location.search);
function parseExtensionFields() {
try {
const fields = JSON.parse(pageParams.get("extension_fields") ?? "{}");
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
if (!isObject(fields)) throw new Error("expected an object");
for (const key of ["raster:tile", "raster:mosaic"]) {
if (key in fields && !isObject(fields[key])) throw new Error(`${key} must be an object`);
}
return fields;
} catch (error) {
const alert = document.getElementById("style-error");
alert.textContent = `Invalid extension_fields: ${error.message}`;
alert.hidden = false;
throw error;
}
}
const extensionFields = parseExtensionFields();
const tileOptions = extensionFields["raster:tile"] ?? {};
let automaticStretch = !pageParams.has("method");
const requestedDataset = pageParams.get("dataset");
const requestedFormat = tileOptions.format ?? "png";
let rgbPresetPending = pageParams.getAll("bidx").join(",").replace(/\s/g, "") === "1,2,3";
function isExpressionMode() {
return Boolean(c.expression.value.trim());
}
function syncExpressionUi() {
const active = isExpressionMode();
c.bidx.disabled = active;
}
function syncStretchUi() {
c.rescale.disabled = c.stretch.value !== "linear";
for (const [field, method] of [["percentiles", "percentile"], ["stddev", "stddev"], ["curves", "curve"]]) {
c[field].disabled = c.stretch.value !== method;
document.getElementById(`${field}-field`).hidden = c[field].disabled;
}
}
function restorePageParams() {
if (requestedDataset !== null) {
c.dataset.add(new Option(requestedDataset, requestedDataset, true, true));
}
c.colormap.value = pageParams.get("colormap_name") ?? "";
c.bidx.value = pageParams.getAll("bidx").join(",");
const rescale = pageParams.getAll("rescale");
if (rescale.length) c.rescale.value = rescale.join(";");
c.stretch.value = pageParams.get("method") ?? "none";
for (const field of ["percentiles", "stddev", "curves"]) {
const value = pageParams.get(field);
if (value !== null) c[field].value = value;
}
c.opacity.value = pageParams.get("opacity") ?? "1";
for (const control of [c.resampling, c.tilesize]) {
const value = control === c.tilesize ? (tileOptions.tilesize === undefined ? null : String(tileOptions.tilesize)) : pageParams.get(control.name);
if (value !== null && [...control.options].some((option) => option.value === value)) {
control.value = value;
}
}
c.expression.value = pageParams.get("expression") ?? "";
syncStretchUi();
syncExpressionUi();
}
restorePageParams();
function applyNdvi() {
automaticStretch = false;
c.expression.value = "(b5-b4)/(b5+b4)";
c.bidx.value = "";
c.stretch.value = "linear";
c.rescale.value = "-1,1";
c.colormap.value = "viridis";
syncStretchUi();
syncExpressionUi();
document.getElementById("style-form").requestSubmit();
}
function appendRepeated(p, name, value, sep) {
value
.split(sep)
.map((s) => s.trim())
.filter(Boolean)
.forEach((v) => p.append(name, v));
}
let datasetBands = 1;
function protocolParams(params) {
params.set("version", "3.0");
const expression = params.get("expression");
if (!expression && !params.has("bidx")) {
const bands = c.colormap.value || datasetBands < 3 ? [1] : [1,2,3];
for (const band of bands) params.append("bidx", String(band));
}
const count = expression ? expression.split(";").length : params.getAll("bidx").length;
params.set("type", count === 3 ? "rgb" : (c.colormap.value ? "pseudocolor" : "gray"));
if (count === 3) params.delete("colormap_name");
params.set("method", c.stretch.value);
params.set("opacity", c.opacity.value);
if (count === 1 && params.getAll("rescale").length > 1) {
const first = params.get("rescale"); params.delete("rescale"); params.append("rescale", first);
}
const tile = {format: c.format.value || "png"};
if (tile.format === "webp") tile.lossless = true;
tile.tilesize = Number(c.tilesize.value || "256");
const fields = {};
const declarations = [];
fields["raster:tile"] = tile;
declarations.push("https://mapseekai.github.io/raster-style/extensions/raster/tile/v1.0.0/schema.json");
params.set("extensions", JSON.stringify(declarations));
params.set("extension_fields", JSON.stringify(fields));
params.sort();
return params;
}
function tileUrl() {
const p = new URLSearchParams();
if (c.dataset.value) p.set("dataset", c.dataset.value);
const expression = c.expression.value.trim();
if (expression) p.set("expression", expression);
else if (!(rgbPresetPending && c.bidx.value.replace(/\s/g, "") === "1,2,3")) appendRepeated(p, "bidx", c.bidx.value, ",");
if (c.stretch.value === "linear") {
appendRepeated(p, "rescale", c.rescale.value, ";");
}
if (c.stretch.value === "percentile") p.set("percentiles", c.percentiles.value);
if (c.stretch.value === "stddev") p.set("stddev", c.stddev.value);
if (c.stretch.value === "curve") p.set("curves", c.curves.value);
if (c.colormap.value) p.set("colormap_name", c.colormap.value);
p.set("resampling", c.resampling.value);
p.set("reproject", c.resampling.value);
p.set("_v", String(styleVersion));
const ext = (c.format.value || "png");
return `${ORIGIN}${TILE_BASE}.${ext}?${protocolParams(p).toString().replace(/\+/g, "%20")}`;
}
const map = L.map("map", { center: [0, 0], zoom: 2 });
let zooming = false;
map.on("zoomstart", () => { zooming = true; });
map.on("zoomend", () => { zooming = false; });
let baseLayer = null;
function applyBasemap() {
if (baseLayer) {
map.removeLayer(baseLayer);
baseLayer = null;
}
if (c.basemap.value === "osm") {
baseLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap",
}).addTo(map);
baseLayer.bringToBack();
}
}
applyBasemap();
let cogLayer = null;
function applyStyle() {
if (!rasterReady) return;
styleVersion += 1;
if (cogLayer) map.removeLayer(cogLayer);
cogLayer = L.tileLayer(tileUrl(), {
maxZoom: 22,
opacity: 1,
attribution: "async-geotiff",
}).addTo(map);
}
async function flyToDataset() {
const ds = c.dataset.value;
try {
const r = await fetch(
`${ORIGIN}/bounds${ds ? `?dataset=${encodeURIComponent(ds)}` : ""}`,
);
if (!r.ok) return;
const b = await r.json();
if (zooming) await new Promise((resolve) => map.once("zoomend", resolve));
if (c.dataset.value !== ds) return;
const defaultStretch = r.headers.get("x-raster-default-stretch");
if (automaticStretch && ["none", "minmax"].includes(defaultStretch)) {
c.stretch.value = defaultStretch;
syncStretchUi();
}
const bands = Number(r.headers.get("x-raster-bands"));
if (Number.isInteger(bands) && bands > 0) {
datasetBands = bands;
if (bands < 3 && c.bidx.value.replace(/\s/g, "") === "1,2,3") {
c.bidx.value = "";
const note = document.getElementById("band-note");
note.textContent = `${bands} band${bands === 1 ? "" : "s"}: RGB preset changed to Auto.`;
note.hidden = false;
}
rgbPresetPending = false;
}
if (Array.isArray(b) && b.length === 4 && b.every(Number.isFinite)) {
rasterReady = true;
map.fitBounds(
[
[b[1], b[0]],
[b[3], b[2]],
],
{ padding: [40, 40], maxZoom: 19 },
);
}
} catch (err) {
console.warn("dataset bounds unavailable", err);
}
}
document.getElementById("style-form").addEventListener("submit", (e) => {
e.preventDefault();
applyStyle();
});
c.basemap.addEventListener("change", applyBasemap);
c.dataset.addEventListener("change", async () => {
rasterReady = false;
rgbPresetPending = c.bidx.value.replace(/\s/g, "") === "1,2,3";
await flyToDataset();
applyStyle();
});
c.stretch.addEventListener("change", () => {
automaticStretch = false;
syncStretchUi();
applyStyle();
});
c.tilesize.addEventListener("change", applyStyle);
c.format.addEventListener("change", applyStyle);
c.expression.addEventListener("change", syncExpressionUi);
c.ndvi.addEventListener("click", applyNdvi);
syncStretchUi();
syncExpressionUi();
c.opacity.addEventListener("change", applyStyle);
(async function loadFormats() {
try {
const r = await fetch(`${ORIGIN}/formats`);
if (!r.ok) return;
const formats = await r.json();
if (!Array.isArray(formats) || !formats.length) return;
c.format.innerHTML = "";
for (const fmt of formats) {
const o = document.createElement("option");
o.value = fmt.value;
o.textContent = fmt.label;
c.format.appendChild(o);
}
c.format.value = [...c.format.options].some(
(option) => option.value === requestedFormat,
)
? requestedFormat
: "png";
applyStyle();
} catch (err) {
console.warn("format list unavailable", err);
}
})();
(async function init() {
try {
const r = await fetch(`${ORIGIN}/datasets`);
if (!r.ok) return;
const names = await r.json();
if (!Array.isArray(names)) return;
c.dataset.innerHTML = "";
for (const n of names) {
const o = document.createElement("option");
o.value = n;
o.textContent = n;
c.dataset.appendChild(o);
}
c.dataset.value = names.includes(requestedDataset)
? requestedDataset
: (names[0] ?? "");
await flyToDataset();
applyStyle();
} catch (err) {
console.warn("dataset list unavailable", err);
}
})();
</script>
</body>
</html>