<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>async-geotiff MapLibre example</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<style>
html,
body,
#map {
height: 100%;
}
body {
margin: 0;
color: #17202a;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#map {
background: #d8dee6;
}
.toolbar {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
display: grid;
grid-template-columns: repeat(5, minmax(92px, auto));
gap: 8px;
align-items: end;
max-width: calc(100vw - 96px);
padding: 10px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(23, 32, 42, 0.14);
border-radius: 8px;
box-shadow: 0 6px 18px rgba(23, 32, 42, 0.16);
backdrop-filter: blur(12px);
}
.field[hidden] { display: none; }
.field {
display: grid;
gap: 4px;
min-width: 92px;
}
.field.wide {
min-width: 138px;
}
label {
color: #4a5568;
font-size: 11px;
font-weight: 650;
letter-spacing: 0;
line-height: 1.1;
}
input,
select,
button {
box-sizing: border-box;
height: 32px;
min-width: 0;
border: 1px solid #aeb7c2;
border-radius: 6px;
background: #ffffff;
color: #17202a;
font: inherit;
font-size: 13px;
}
input,
select {
padding: 0 8px;
}
input[type="range"] {
padding: 0;
}
button {
padding: 0 12px;
background: #1f6feb;
border-color: #1f6feb;
color: #ffffff;
cursor: pointer;
font-weight: 650;
}
button:hover {
background: #1759c8;
}
.status {
box-sizing: border-box;
position: absolute;
left: 12px;
bottom: 22px;
z-index: 1;
max-width: min(520px, calc(100vw - 24px));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 6px 8px;
border-radius: 6px;
background: rgba(23, 32, 42, 0.82);
color: #ffffff;
font-size: 12px;
}
@media (max-width: 760px) {
.toolbar {
right: 56px;
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: none;
}
.field,
.field.wide {
min-width: 0;
}
button {
width: 100%;
}
}
</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="mode">Source</label>
<select id="mode" name="mode">
<option value="single">single dataset</option>
<option value="mosaic">mosaic</option>
</select>
</div>
<div class="field">
<label for="pixel_selection">Pixel sel</label>
<select id="pixel_selection" name="pixel_selection">
<option value="first">first</option>
<option value="highest">highest</option>
<option value="lowest">lowest</option>
<option value="mean">mean</option>
<option value="median">median</option>
</select>
</div>
<div class="field">
<label for="stage">Merge stage</label>
<select id="stage" name="stage">
<option value="before_channels">Before band math</option>
<option value="after_channels">After band math</option>
</select>
</div>
<div class="field">
<label for="rank_channel">Rank channel</label>
<input id="rank_channel" name="rank_channel" type="number" min="1" step="1" value="1"
title="Channel position at the selected merge stage, starting at 1">
</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" autocomplete="off" spellcheck="false">
</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" autocomplete="off" spellcheck="false" 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">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" name="opacity" type="range" min="0" max="1" step="0.05">
</div>
<button type="button" id="ndvi">NDVI</button>
<button type="submit">Apply</button>
<span style="margin-left:auto;font-size:12px">
viewer:
<a href="/maplibre" style="color:#2563eb;margin-left:8px">MapLibre</a>
<a href="/openlayers" style="color:#2563eb;margin-left:8px">OpenLayers</a>
<a href="/leaflet" style="color:#2563eb;margin-left:8px">Leaflet</a>
</span>
</form>
<div class="status" id="status"></div>
<script>
maplibregl.setMaxParallelImageRequests(2);
const DEFAULT_TILE_ORIGIN = "http://127.0.0.1:8080";
const SINGLE_PATH = "/tiles/WebMercatorQuad/{z}/{x}/{y}";
const MOSAIC_PATH = "/mosaic/tiles/WebMercatorQuad/{z}/{x}/{y}";
function isMosaic() {
return controls.mode && controls.mode.value === "mosaic";
}
function currentExt() {
return (controls.format && controls.format.value) || "png";
}
function tilePath() {
return `${isMosaic() ? MOSAIC_PATH : SINGLE_PATH}.${currentExt()}`;
}
const SERVER_DEFAULT_CENTER = "__ASYNC_GEOTIFF_DEFAULT_CENTER__";
const SERVER_DEFAULT_ZOOM = "__ASYNC_GEOTIFF_DEFAULT_ZOOM__";
const SERVER_DEFAULT_MODE = "__ASYNC_GEOTIFF_DEFAULT_MODE__";
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"] ?? {};
const mosaicOptions = extensionFields["raster:mosaic"] ?? {};
let automaticStretch = !pageParams.has("method");
const controls = {
dataset: document.getElementById("dataset"),
basemap: document.getElementById("basemap"),
mode: document.getElementById("mode"),
pixelSelection: document.getElementById("pixel_selection"),
stage: document.getElementById("stage"),
rankChannel: document.getElementById("rank_channel"),
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"),
status: document.getElementById("status")
};
controls.mode.value = pageParams.get("mode") ?? SERVER_DEFAULT_MODE;
controls.pixelSelection.value = mosaicOptions.pixel_selection ?? "first";
controls.stage.value = mosaicOptions.stage ?? "before_channels";
controls.rankChannel.value = mosaicOptions.rank_channel ?? "1";
controls.colormap.value = pageParams.get("colormap_name") ?? "";
controls.stretch.value = pageParams.get("method") ?? "none";
controls.rescale.value = pageParams.getAll("rescale").join(";") || "0,255;0,255;0,255";
for (const field of ["percentiles", "stddev", "curves"]) {
const value = pageParams.get(field);
if (value !== null) controls[field].value = value;
}
controls.bidx.value = pageParams.getAll("bidx").join(",");
let rgbPresetPending = pageParams.get("mode") !== "mosaic" && controls.bidx.value.replace(/\s/g, "") === "1,2,3";
controls.expression.value = pageParams.get("expression") ?? "";
controls.resampling.value = pageParams.get("resampling") ?? "bilinear";
controls.tilesize.value = (tileOptions.tilesize === undefined ? null : String(tileOptions.tilesize)) ?? "256";
controls.opacity.value = pageParams.get("opacity") ?? "0.85";
function tileOrigin() {
const configured = pageParams.get("tile_origin");
if (configured) {
return configured.replace(/\/$/, "");
}
if (window.location.protocol === "http:" || window.location.protocol === "https:") {
return window.location.origin;
}
return DEFAULT_TILE_ORIGIN;
}
function parseCenter() {
const raw = pageParams.get("center");
if (raw) {
const parts = raw.split(",").map((value) => Number(value.trim()));
return parts.length === 2 && parts.every(Number.isFinite) ? parts : defaultCenter();
}
return defaultCenter();
}
function defaultCenter() {
if (SERVER_DEFAULT_CENTER.startsWith("__")) {
return [0, 0];
}
const parts = SERVER_DEFAULT_CENTER.split(",").map((value) => Number(value.trim()));
return parts.length === 2 && parts.every(Number.isFinite) ? parts : [0, 0];
}
function parseZoom() {
const defaultZoom = SERVER_DEFAULT_ZOOM.startsWith("__")
? 2
: Number(SERVER_DEFAULT_ZOOM);
const zoom = Number(pageParams.get("zoom") ?? defaultZoom);
return Number.isFinite(zoom) ? zoom : 2;
}
function appendRepeated(params, name, value, separator) {
value
.split(separator)
.map((item) => item.trim())
.filter(Boolean)
.forEach((item) => params.append(name, item));
}
let datasetBands = 1;
function protocolParams(params) {
params.set("version", "3.0");
const expression = params.get("expression");
if (!expression && !params.has("bidx")) {
const bands = controls.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" : (controls.colormap.value ? "pseudocolor" : "gray"));
if (count === 3) params.delete("colormap_name");
params.set("method", controls.stretch.value);
params.set("opacity", controls.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: controls.format.value || "png"};
if (tile.format === "webp") tile.lossless = true;
tile.tilesize = Number(controls.tilesize.value || "256");
const fields = {};
const declarations = [];
if (isMosaic()) {
const mosaic = {pixel_selection: controls.pixelSelection.value};
if (["highest", "lowest"].includes(mosaic.pixel_selection)) mosaic.rank_channel = Number(controls.rankChannel.value || "1");
mosaic.stage = controls.stage.value;
fields["raster:mosaic"] = mosaic;
declarations.push("https://mapseekai.github.io/raster-style/extensions/raster/mosaic/v1.0.0/schema.json");
}
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 tileParams() {
const params = new URLSearchParams();
params.set("mode", isMosaic() ? "mosaic" : "single");
if (!isMosaic() && controls.dataset && controls.dataset.value) {
params.set("dataset", controls.dataset.value);
}
const expression = controls.expression.value.trim();
if (expression) {
params.set("expression", expression);
} else {
if (!(rgbPresetPending && controls.bidx.value.replace(/\s/g, "") === "1,2,3")) {
appendRepeated(params, "bidx", controls.bidx.value, ",");
}
}
if (controls.stretch.value === "linear") {
appendRepeated(params, "rescale", controls.rescale.value, ";");
}
if (controls.stretch.value === "percentile") params.set("percentiles", controls.percentiles.value);
if (controls.stretch.value === "stddev") params.set("stddev", controls.stddev.value);
if (controls.stretch.value === "curve") params.set("curves", controls.curves.value);
if (controls.colormap.value) {
params.set("colormap_name", controls.colormap.value);
}
params.set("resampling", controls.resampling.value);
params.set("reproject", controls.resampling.value);
return protocolParams(params);
}
let styleVersion = 0;
let rasterReady = false;
let styleReady = false;
function tileUrl() {
const params = tileParams();
params.set("_v", String(styleVersion));
return `${tileOrigin()}${tilePath()}?${params.toString().replace(/\+/g, "%20")}`;
}
function syncStatus() {
controls.status.textContent = tileUrl();
}
function syncSourceUi() {
const mosaic = isMosaic();
controls.dataset.disabled = mosaic;
controls.pixelSelection.disabled = !mosaic;
controls.stage.disabled = !mosaic;
controls.rankChannel.disabled = controls.stage.disabled || !["highest", "lowest"].includes(controls.pixelSelection.value);
}
function syncPermalink() {
if (!(window.location.protocol === "http:" || window.location.protocol === "https:")) {
return;
}
const params = tileParams();
params.set("opacity", controls.opacity.value);
const center = map.getCenter();
params.set("center", `${center.lng.toFixed(5)},${center.lat.toFixed(5)}`);
params.set("zoom", map.getZoom().toFixed(2));
window.history.replaceState(null, "", `/maplibre?${params.toString().replace(/\+/g, "%20")}`);
}
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
"osm-raster": {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
minzoom: 0,
maxzoom: 19,
attribution: '<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
},
layers: [],
id: "async-geotiff-maplibre"
},
center: parseCenter(),
zoom: parseZoom()
});
function applyBasemap() {
if (!styleReady) return;
if (map.getLayer("osm-raster")) map.removeLayer("osm-raster");
if (controls.basemap.value === "osm") {
map.addLayer(
{ id: "osm-raster", type: "raster", source: "osm-raster" },
map.getLayer("cog-raster") ? "cog-raster" : undefined,
);
}
}
map.on("load", () => {
styleReady = true;
applyBasemap();
document.getElementById("style-form").requestSubmit();
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.addControl(new maplibregl.ScaleControl(), "bottom-right");
document.getElementById("style-form").addEventListener("submit", (event) => {
event.preventDefault();
if (!rasterReady && !isMosaic()) return;
if (!styleReady) return;
styleVersion += 1;
if (map.getLayer("cog-raster")) {
map.removeLayer("cog-raster");
}
if (map.getSource("cog-raster")) {
map.removeSource("cog-raster");
}
map.addSource("cog-raster", {
type: "raster",
tiles: [tileUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 22,
attribution: "async-geotiff"
});
map.addLayer({
id: "cog-raster",
type: "raster",
source: "cog-raster",
paint: { "raster-opacity": 1 }
});
syncStatus();
syncPermalink();
});
controls.opacity.addEventListener("change", () => {
document.getElementById("style-form").requestSubmit();
});
(async function loadFormats() {
const wanted = tileOptions.format ?? "png";
try {
const resp = await fetch(`${tileOrigin()}/formats`);
if (!resp.ok) return;
const formats = await resp.json();
if (!Array.isArray(formats) || formats.length === 0) return;
controls.format.innerHTML = "";
for (const fmt of formats) {
const opt = document.createElement("option");
opt.value = fmt.value;
opt.textContent = fmt.label;
controls.format.appendChild(opt);
}
const has = formats.some((f) => f.value === wanted);
controls.format.value = has ? wanted : "png";
document.getElementById("style-form").requestSubmit();
} catch (err) {
console.warn("format list unavailable", err);
}
})();
controls.format.addEventListener("change", () => {
document.getElementById("style-form").requestSubmit();
});
(async function loadDatasets() {
try {
const resp = await fetch(`${tileOrigin()}/datasets`);
if (!resp.ok) return;
const names = await resp.json();
if (!Array.isArray(names)) return;
const wanted = pageParams.get("dataset");
controls.dataset.innerHTML = "";
for (const name of names) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
controls.dataset.appendChild(opt);
}
controls.dataset.value =
wanted && names.includes(wanted) ? wanted : (names[0] ?? "");
if (!isMosaic()) {
await flyToDataset();
document.getElementById("style-form").requestSubmit();
}
} catch (err) {
console.warn("dataset list unavailable", err);
}
})();
async function flyToDataset() {
const ds = controls.dataset && controls.dataset.value;
try {
const r = await fetch(
`${tileOrigin()}/bounds${ds ? `?dataset=${encodeURIComponent(ds)}` : ""}`
);
if (!r.ok) return;
if (controls.dataset.value !== ds) return;
const defaultStretch = r.headers.get("x-raster-default-stretch");
if (automaticStretch && ["none", "minmax"].includes(defaultStretch)) {
controls.stretch.value = defaultStretch;
syncStretchUi();
}
const bands = Number(r.headers.get("x-raster-bands"));
if (Number.isInteger(bands) && bands > 0) {
datasetBands = bands;
if (bands < 3 && controls.bidx.value.replace(/\s/g, "") === "1,2,3") {
controls.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;
}
const b = await r.json();
if (Array.isArray(b) && b.length === 4 && b.every(Number.isFinite)) {
rasterReady = true;
map.fitBounds(
[
[b[0], b[1]],
[b[2], b[3]]
],
{ padding: 40, duration: 800 }
);
}
} catch (err) {
console.warn("dataset bounds unavailable", err);
}
}
controls.basemap.addEventListener("change", applyBasemap);
controls.dataset.addEventListener("change", async () => {
rasterReady = false;
if (!isMosaic()) {
rgbPresetPending = controls.bidx.value.replace(/\s/g, "") === "1,2,3";
await flyToDataset();
document.getElementById("style-form").requestSubmit();
}
});
controls.pixelSelection.addEventListener("change", syncSourceUi);
controls.mode.addEventListener("change", async () => {
syncSourceUi();
if (!isMosaic()) {
rasterReady = false;
await flyToDataset();
}
document.getElementById("style-form").requestSubmit();
});
function syncStretchUi() {
controls.rescale.disabled = controls.stretch.value !== "linear";
for (const [field, method] of [["percentiles", "percentile"], ["stddev", "stddev"], ["curves", "curve"]]) {
controls[field].disabled = controls.stretch.value !== method;
document.getElementById(`${field}-field`).hidden = controls[field].disabled;
}
}
function isExpressionMode() {
return Boolean(controls.expression.value.trim());
}
function syncExpressionUi() {
const active = isExpressionMode();
controls.bidx.disabled = active;
}
function applyNdvi() {
automaticStretch = false;
controls.expression.value = "(b5-b4)/(b5+b4)";
controls.bidx.value = "";
controls.stretch.value = "linear";
controls.rescale.value = "-1,1";
controls.colormap.value = "viridis";
syncStretchUi();
syncExpressionUi();
document.getElementById("style-form").requestSubmit();
}
controls.stretch.addEventListener("change", () => {
automaticStretch = false;
syncStretchUi();
document.getElementById("style-form").requestSubmit();
});
syncStretchUi();
controls.expression.addEventListener("change", () => {
syncExpressionUi();
});
controls.ndvi.addEventListener("click", applyNdvi);
syncExpressionUi();
syncSourceUi();
controls.tilesize.addEventListener("change", () => {
document.getElementById("style-form").requestSubmit();
});
map.on("moveend", syncPermalink);
syncStatus();
</script>
</body>
</html>