← OnlyMapJS Gallery / Stories & animation
Shift basemaps on a timeline
Swap MapTiler basemap styles from a story timeline while the map runs. Layers and the camera hold steady as the ground changes underneath.
<!DOCTYPE html>
<!--
Custom-configured basemaps, not presets: this fetches real MapTiler
style.json bases and produces four genuinely different looks by mutating
the parsed JSON in plain JS — recoloring specific layers by id, never a
canned theme. Cyberpunk starts from `dataviz-dark` (MapTiler's minimal,
dark-preset style — a better recolor target than a busy streets style,
since it has far fewer competing layers to fight for attention, and the
dark defaults suit a neon-on-black look); the other three share
`streets-v2`. Each base is fetched once and reused by
every look built on it. `OmMap.registerBasemap(name, { style })` takes
the mutated object directly; an intro card, a one-shot story that cycles
the four registered names via `set-basemap`, and a real
`basemap-switcher` widget (same action, a viewer's own click) sit side
by side — the story is a convenience, not the only way to reach them.
Ends on a CTA card.
Not a practical basemap lineup — cyberpunk/pastel/blueprint/sepia were
picked purely for how differently they read against the same real map
data, not for any cartographic purpose. Top-down and flat, on purpose:
the point is comparing basemap styles, not camera angle or 3D relief.
Needs a free MapTiler key (https://www.maptiler.com — the free tier
covers this easily). Add it as `?key=YOUR_KEY`, or paste it into the
panel for local testing; it's kept in localStorage only.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OnlyMapJS — Shift basemaps on a timeline</title>
<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css" />
<script type="module" src="https://unpkg.com/@nika-js/onlymap/dist/onlymap.standalone.js"></script>
<style>
:root { color-scheme: dark; }
/* Explicit, not decorative: `color-scheme: dark` above makes the browser
paint any exposed viewport area black by default. The two map canvases
(MapLibre base + deck.gl overlay) can have small transparent gaps —
without this, those gaps read as a stray black rectangle instead of
just... not being there. */
html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; background: #05060a; }
om-map {
display: block; height: 100vh;
--om-widget-bg: #14161c;
--om-widget-fg: #f8fafc;
--om-widget-muted: #8b93a7;
--om-widget-border: #2c3040;
--om-widget-hover-bg: #22262f;
--om-widget-accent: #f472b6;
}
/* Top-right, deliberately: the style-label widget owns top-left, the
player owns bottom-left, zoom-controls owns bottom-right — this is
a plain fixed div (setup chrome, not part of the manifest), so it
doesn't participate in the managed layout and would otherwise sit
wherever it's told regardless of what else is already there. */
.hud {
position: fixed; right: 16px; top: 16px; z-index: 10001;
max-width: 380px; padding: 12px 14px; border-radius: 8px;
background: rgba(18, 22, 28, .9); color: #fff;
font: 13px/1.45 system-ui, sans-serif; box-shadow: 0 10px 28px rgba(0,0,0,.3);
}
.hud code { color: #b9d4ff; }
.hud input {
box-sizing: border-box; width: 100%; margin-top: 8px; padding: 7px 9px;
border: 1px solid rgba(255,255,255,.25); border-radius: 6px;
background: rgba(255,255,255,.08); color: #fff; font: inherit;
}
.hud button {
margin-top: 8px; padding: 7px 10px; border: 0; border-radius: 6px;
background: #f472b6; color: #14161c; font: inherit; font-weight: 700; cursor: pointer;
}
.hud[data-ready="true"] .setup { display: none; }
.hud[data-ready="false"] .ready { display: none; }
.hud .error { color: #fca5a5; margin-top: 6px; }
</style>
</head>
<body>
<!-- basemap starts unset (standalone canvas) — the four custom names
below don't exist until the key is supplied and the fetch+patch
pass has actually run; the setup script assigns the first one once
that's done, same gating as the Google 3D Tiles example. -->
<om-map center="[-122.435, 37.775]" zoom="12.4" pitch="0" bearing="0" basemap="none">
<!-- One layer, on purpose: the point is watching a single scene read
differently under four styles, not touring new data each time. -->
<om-layer id="districts" type="GeoJsonLayer"
data="../../data/regions.geojson"
label="SF districts" color="#f472b6"
filled stroked pickable
get-fill-color="[244, 114, 182, 165]"
get-line-color="[251, 207, 232, 230]"
line-width-min-pixels="1.5"></om-layer>
<om-widget id="style-label" position="top-left" order="-10">
<style>
.style-card { padding: 12px 16px; border: 1px solid var(--card-border, #2c3040); border-radius: 12px;
background: rgba(20, 22, 28, .86); color: #f8fafc; box-shadow: 0 12px 38px rgba(0, 0, 0, .5);
font: 650 16px/1.25 system-ui, sans-serif; letter-spacing: .01em; backdrop-filter: blur(10px);
min-width: 190px; }
.style-card span { display: block; margin-top: 4px; font: 11px/1.3 system-ui, sans-serif;
letter-spacing: .08em; text-transform: uppercase; color: var(--om-widget-accent); }
</style>
<div class="style-card">
<span>Now shifting to</span>
<div data-style-name>Waiting for a key…</div>
</div>
</om-widget>
<om-overlay id="tour-intro" anchor="[-122.435, 37.775]" anchor-offset="center" visible="false">
<style>
.card { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; padding: 16px 18px;
border-radius: 12px; background: rgba(20, 22, 28, .86); color: #f8fafc;
border: 1px solid rgba(44, 48, 64, .8); box-shadow: 0 12px 38px rgba(0, 0, 0, .5);
font: 650 15px/1.5 system-ui, sans-serif; text-align: center; backdrop-filter: blur(10px); }
</style>
<div class="card">OnlyMapJS lets you register your own basemap styles and switch
between them using React or the built-in widget for basemaps.</div>
</om-overlay>
<om-overlay id="tour-cta-card" anchor="[-122.435, 37.775]" anchor-offset="center" visible="false">
<style>
.card { display: grid; grid-template-columns: minmax(150px, .85fr) minmax(220px, 1.15fr);
gap: 14px; align-items: center; width: min(480px, calc(100vw - 32px));
box-sizing: border-box; padding: 14px 18px; border-radius: 12px;
background: rgba(20, 22, 28, .86); color: #f8fafc; border: 1px solid rgba(44, 48, 64, .8);
box-shadow: 0 12px 38px rgba(0, 0, 0, .5); font: 14px/1.4 system-ui, sans-serif;
backdrop-filter: blur(10px); }
.package { color: var(--om-widget-accent); font-weight: 700; }
pre { margin: 0; padding: 10px 12px; border-radius: 8px; overflow: auto; white-space: pre-wrap;
background: #0b0e14; border: 1px solid rgba(44, 48, 64, .8);
font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.command { color: #fbbf24; } .package-code { color: #86efac; font-weight: 700; }
@media (max-width: 700px) { .card { grid-template-columns: 1fr; width: min(340px, calc(100vw - 32px)); } }
</style>
<div class="card">
<div>Try it today: OnlyMapJS on npm at <span class="package">@nika-js/onlymap</span></div>
<pre><code><span class="command">npm install</span> <span class="package-code">@nika-js/onlymap</span></code></pre>
</div>
</om-overlay>
<!-- Intro, then each chapter swaps the basemap, then the CTA — a
one-shot tour, not a loop. No camera motion: with the map top-down
and static, what changes is only ever the basemap. -->
<om-story id="tour" interrupt="pause">
<om-step duration="4000ms" action="show-overlay" target="tour-intro"></om-step>
<om-step action="hide-overlay" target="tour-intro"></om-step>
<om-step duration="4000ms" action="set-basemap" basemap="mt-cyberpunk"></om-step>
<om-step duration="4000ms" action="set-basemap" basemap="mt-pastel"></om-step>
<om-step duration="4000ms" action="set-basemap" basemap="mt-blueprint"></om-step>
<om-step duration="4000ms" action="set-basemap" basemap="mt-sepia"></om-step>
<om-step duration="4500ms" action="show-overlay" target="tour-cta-card"></om-step>
<om-step action="hide-overlay" target="tour-cta-card"></om-step>
</om-story>
<!-- The story drives the tour, but a viewer can also just click a look
directly — same mechanism, the switcher writes `basemap` too. -->
<om-widget type="basemap-switcher" position="bottom-right"
options="mt-cyberpunk mt-pastel mt-blueprint mt-sepia"></om-widget>
<om-widget type="player" story="tour" position="bottom-left"></om-widget>
<om-widget type="zoom-controls" position="bottom-right"></om-widget>
<om-fallback>
<p style="font: 15px system-ui, sans-serif; padding: 24px; max-width: 42ch">
<strong>This map needs JavaScript.</strong><br />
Open this file in a web browser such as Chrome, Safari, or Firefox.
</p>
</om-fallback>
</om-map>
<div class="hud" id="hud" data-ready="false">
<div class="setup">
<strong>Custom MapTiler basemaps</strong><br />
Add a free MapTiler key (<a href="https://www.maptiler.com" target="_blank" rel="noopener" style="color:#b9d4ff">maptiler.com</a>)
as <code>?key=...</code>, or paste it here for local testing.
<input id="key" autocomplete="off" spellcheck="false" placeholder="MapTiler API key" />
<button id="apply" type="button">Fetch & patch four looks</button>
<div class="error" id="error" hidden></div>
</div>
<div class="ready">
Four looks from two fetched MapTiler bases (<code>dataviz-dark</code> + <code>streets-v2</code>), patched four different ways in plain JS.
</div>
</div>
<script type="module">
import { OmMap } from "https://unpkg.com/@nika-js/onlymap/dist/onlymap.standalone.js";
const STORAGE_KEY = "onlymapjs.shiftbasemaps.mtkey";
const params = new URLSearchParams(location.search);
const map = document.querySelector("om-map");
const hud = document.querySelector("#hud");
const input = document.querySelector("#key");
const applyBtn = document.querySelector("#apply");
const errorEl = document.querySelector("#error");
const label = document.getElementById("style-label").shadowRoot.querySelector("[data-style-name]");
const STYLE_NAMES = {
"mt-cyberpunk": "Cyberpunk",
"mt-pastel": "Pastel",
"mt-blueprint": "Blueprint",
"mt-sepia": "Sepia",
};
const syncLabel = () => {
label.textContent = STYLE_NAMES[map.getAttribute("basemap")] ?? map.getAttribute("basemap");
};
new MutationObserver(syncLabel).observe(map, { attributes: true, attributeFilter: ["basemap"] });
// --- small, type-correct paint helpers -----------------------------
// MapLibre's own style validator rejects a paint/layout property that
// isn't legal for a layer's type — a substring match alone can't tell
// "Water" (fill) from "Water intermittent" (also fill, fine) apart
// from "Building 3D" (fill-extrusion, wants a DIFFERENT property
// entirely), so each helper below is scoped to one layer type.
const layersMatching = (style, substr) =>
style.layers.filter((l) => l.id.toLowerCase().includes(substr.toLowerCase()));
function paint(style, substr, type, prop, value) {
for (const layer of layersMatching(style, substr)) {
if (layer.type !== type) continue;
layer.paint = { ...(layer.paint ?? {}), [prop]: value };
}
}
function background(style, color) {
const bg = style.layers.find((l) => l.type === "background");
if (bg) bg.paint = { ...(bg.paint ?? {}), "background-color": color };
}
// One "look" = background + water + roads + buildings (flat and
// extruded) + labels — the same handful of high-impact layers a real
// theming pass would start with, not an exhaustive re-theme of all 90
// layers in the base style.
function applyLook(style, look) {
background(style, look.bg);
paint(style, "water", "fill", "fill-color", look.water);
paint(style, "road", "line", "line-color", look.road);
paint(style, "highway", "line", "line-color", look.road);
paint(style, "building", "fill", "fill-color", look.building);
paint(style, "building", "fill-extrusion", "fill-extrusion-color", look.buildingExtrusion);
paint(style, "labels", "symbol", "text-color", look.text);
paint(style, "labels", "symbol", "text-halo-color", look.bg);
}
// Cyberpunk starts from `dataviz-dark` (minimal — fewer layers competing
// for a heavy recolor pass, and already dark by default); the rest share
// `streets-v2`. Real, VERIFIED layer ids for each (fetched and inspected
// directly, never guessed): dataviz-dark has no fill-extrusion building
// layer at all, so `buildingExtrusion` simply has nothing to paint there
// — harmless, kept for parity with the other three looks (and any
// consumer copying this object).
const LOOKS = {
"mt-cyberpunk": { base: "dataviz-dark", bg: "#05060a", water: "#0a2540", road: "#0ea5ff", building: "#1a1035", buildingExtrusion: "#2d1b5e", text: "#67e8f9" },
"mt-pastel": { base: "streets-v2", bg: "#fdf6f0", water: "#bfe3d0", road: "#e8a0b4", building: "#d9c7a8", buildingExtrusion: "#c9b190", text: "#7a6a58" },
"mt-blueprint": { base: "streets-v2", bg: "#0b1f3a", water: "#123259", road: "#eaf6ff", building: "#14294a", buildingExtrusion: "#1c355c", text: "#eaf6ff" },
"mt-sepia": { base: "streets-v2", bg: "#ede0c8", water: "#b7a578", road: "#8a5a34", building: "#c9ad7f", buildingExtrusion: "#a9895c", text: "#5c4526" },
};
async function useKey(key) {
const trimmed = key.trim();
if (!trimmed) return;
errorEl.hidden = true;
localStorage.setItem(STORAGE_KEY, trimmed);
// Fetch each DISTINCT base style once, however many looks reuse it.
const baseCache = new Map();
const fetchBase = async (styleId) => {
if (baseCache.has(styleId)) return baseCache.get(styleId);
const resp = await fetch(`https://api.maptiler.com/maps/${styleId}/style.json?key=${encodeURIComponent(trimmed)}`);
if (!resp.ok) throw new Error(`MapTiler returned HTTP ${resp.status} for "${styleId}" — check the key.`);
const json = await resp.json();
baseCache.set(styleId, json);
return json;
};
try {
for (const [name, look] of Object.entries(LOOKS)) {
const base = await fetchBase(look.base);
const style = structuredClone(base);
applyLook(style, look);
OmMap.registerBasemap(name, { style, attribution: base.attribution ?? "© MapTiler © OpenStreetMap contributors" });
}
map.setAttribute("basemap", "mt-cyberpunk");
hud.dataset.ready = "true";
} catch (err) {
errorEl.hidden = false;
errorEl.textContent = String(err?.message ?? err);
}
}
input.value = params.get("key") || localStorage.getItem(STORAGE_KEY) || "";
applyBtn.addEventListener("click", () => useKey(input.value));
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") useKey(input.value);
});
if (input.value) useKey(input.value);
</script>
</body>
</html>