← Gallery / Interaction
Time series
Step a month of USGS earthquakes through time with a custom slider widget that drives a GPU filter.
<!DOCTYPE html>
<!--
A time filter is an ordinary filter whose field holds a date. The built-in
widget covers the still case with no script — type="filter" with
format="date" labels its handles as dates — and date labels elsewhere come
from get-text="formatDate($time, 'datetime', 'UTC')". Write a widget
script only when the control has to MOVE: the playback controller here
sweeps a two-handle window across the month, emitting the same public
filter-layer action. deck.gl applies the window on the GPU and ctx.stats
reads the same declarative filter, so the map and the statistics cannot
disagree.
The map: a month of USGS M2.5+ earthquakes — a fixed snapshot of the
official feed, taken 2026-07-24. Press play on the timeline to sweep the
window through the month, or drag it by hand.
-->
<html>
<head>
<meta charset="utf-8" />
<title>OnlyMapJS — Time series example</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>
html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
om-map { display: block; height: 100vh; }
</style>
</head>
<body>
<om-map center="[12, 18]" zoom="1.35" basemap="positron">
<om-layer id="earthquakes" type="GeoJsonLayer"
data="../../data/usgs-earthquakes-month.geojson"
label="M2.5+ earthquakes" color="#dc2626"
get-fill-color="scale($mag, sequential, ['#fde68a', '#dc2626'], domain=[2.5, 7.5])"
get-point-radius="scale($mag, sqrt, [3, 22], domain=[2.5, 7.5])"
point-radius-units="pixels" point-radius-min-pixels="3"
opacity="0.82" filled stroked="false" pickable visible="false"
filter-field="time" filter-range="[0, 9999999999999]"></om-layer>
<om-widget id="magnitude-legend" type="legend" title="Magnitude" position="top-right"
style="display:none;" aria-hidden="true"></om-widget>
<om-widget id="time-series-title" position="top-left" order="-10">
<style>
.series-title { width:245px; box-sizing:border-box; padding:10px 14px;
border:1px solid rgba(255,255,255,.45); border-radius:10px;
background:rgba(17,24,39,.9); color:#fff;
box-shadow:0 4px 18px rgba(0,0,0,.22);
font:700 15px/1.25 system-ui,sans-serif; letter-spacing:.01em;
backdrop-filter:blur(8px); }
@media (max-width:480px) {
.series-title { width:160px; padding-inline:11px; }
}
</style>
<div class="series-title">Time Series Visualization on OnlyMapJS</div>
</om-widget>
<!-- A separate statistics widget watches `layers` because moving the
temporal window changes filter-range, not the underlying data. -->
<om-widget id="time-series-stats" position="top-left" watch="data:earthquakes layers"
style="display:none;" aria-hidden="true">
<style>
.stats { width: 245px; box-sizing: border-box; padding: 12px 14px; border-radius: 10px;
background: rgba(255,255,255,.94); color: #172033; box-shadow: 0 3px 14px rgba(0,0,0,.2);
font: 12px/1.35 system-ui, sans-serif; backdrop-filter: blur(8px); }
.stats h3 { margin: 0 0 8px; font-size: 14px; }
.row { display: flex; justify-content: space-between; gap: 18px; margin: 4px 0; }
.row b { font-variant-numeric: tabular-nums; }
.source { display: block; margin-top: 9px; padding-top: 7px; border-top: 1px solid #d8deea;
color: #475569; font-size: 10px; }
.source a { color: #3157c8; }
@media (max-width: 480px) {
.stats { width: 160px; padding-inline: 11px; }
.row { gap: 7px; }
}
</style>
<section class="stats" aria-live="polite">
<h3>Active date range</h3>
<div class="row"><span>Timespan</span><b id="timespan">—</b></div>
<div class="row"><span>Earthquakes</span><b id="event-count">—</b></div>
<div class="row"><span>Maximum</span><b id="max-mag">—</b></div>
<div class="row"><span>Average</span><b id="mean-mag">—</b></div>
<div class="row"><span>Most recent</span><b id="latest-time">—</b></div>
<small class="source">
Source: <a href="https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php"
target="_blank" rel="noopener">U.S. Geological Survey</a><br/>
M2.5+ monthly feed snapshot · 24 Jul 2026
</small>
</section>
<script type="om/widget">
this.watch = ["data:earthquakes", "layers"];
this.render = (ctx) => {
const range = ctx.layers.find((layer) => layer.id === "earthquakes")?.filter?.range;
const magnitude = ctx.stats("earthquakes", "mag");
const time = ctx.stats("earthquakes", "time");
const duration = range ? Math.max(0, range[1] - range[0]) : 0;
const totalHours = Math.round(duration / 3600000);
const days = Math.floor(totalHours / 24);
const hours = totalHours % 24;
this.$("#timespan").textContent = range
? [days && days + (days === 1 ? " day" : " days"), hours && hours + (hours === 1 ? " hour" : " hours")]
.filter(Boolean).join(" ") || "0 hours"
: "—";
this.$("#event-count").textContent = String(magnitude.count);
this.$("#max-mag").textContent = magnitude.count ? "M " + magnitude.max.toFixed(1) : "—";
this.$("#mean-mag").textContent = magnitude.count ? "M " + magnitude.mean.toFixed(2) : "—";
this.$("#latest-time").textContent = time.count
? new Date(time.max).toLocaleDateString("en", { month: "short", day: "numeric", timeZone: "UTC" })
: "—";
};
</script>
</om-widget>
<!-- Time axis + playback. The margin lifts this managed bottom-center
widget above the story player, provider attribution, and license
badge at both desktop and narrow widths. -->
<om-widget id="time-series-controls" position="bottom-center"
style="display:none; margin-bottom:82px;" aria-hidden="true"
watch="data:earthquakes">
<style>
.timeline { width: min(680px, calc(100vw - 32px)); box-sizing: border-box; padding: 12px 14px;
border-radius: 12px; background: rgba(17,24,39,.94); color: #f8fafc;
box-shadow: 0 4px 20px rgba(0,0,0,.3); font: 12px/1.3 system-ui, sans-serif;
backdrop-filter: blur(10px); }
.headline { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.headline strong { font-size: 13px; }
#window-label { color: #bfdbfe; font-variant-numeric: tabular-nums; text-align: right; }
.controls { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 11px; }
#play { min-width: 82px; border: 0; border-radius: 7px; padding: 7px 11px; cursor: pointer;
background: #3b82f6; color: #fff; font: 600 12px system-ui, sans-serif; }
#play:hover { background: #2563eb; }
.range-sliders { display:grid; gap:5px; }
.range-row { display:grid; grid-template-columns:34px 1fr 88px; align-items:center; gap:7px; }
.range-row > span { color:#cbd5e1; font-size:10px; font-weight:600; text-transform:uppercase; }
.range-row output { color:#bfdbfe; font-size:10px; text-align:right; font-variant-numeric:tabular-nums; }
.range-row input { width:100%; min-width:0; accent-color:#60a5fa; cursor:ew-resize; }
.axis { display: flex; justify-content: space-between; margin: 5px 0 0 93px; color: #94a3b8;
font-size: 10px; font-variant-numeric: tabular-nums; }
@media (max-width: 560px) {
.headline { align-items: flex-start; flex-direction: column; gap: 3px; }
.controls { grid-template-columns: 1fr; }
#play { width: 100%; }
.axis { margin-left: 0; }
}
</style>
<section class="timeline">
<div class="headline">
<strong>Earthquakes through time</strong>
<span id="window-label">Loading monthly series…</span>
</div>
<div class="controls">
<button id="play" type="button" aria-label="Play earthquake timeline">▶ Play</button>
<div class="range-sliders">
<label class="range-row">
<span>Start</span>
<input id="start-slider" type="range" aria-label="Earthquake date range start" />
<output id="start-value" for="start-slider">—</output>
</label>
<label class="range-row">
<span>End</span>
<input id="end-slider" type="range" aria-label="Earthquake date range end" />
<output id="end-value" for="end-slider">—</output>
</label>
</div>
</div>
<div class="axis"><span id="axis-start">—</span><span id="axis-end">—</span></div>
</section>
<script type="om/widget">
this.watch = ["data:earthquakes"];
this.render = async (ctx) => {
if (this.state.ready) return;
const rows = await ctx.data("earthquakes", { filtered: false });
const times = rows
.map((row) => Number(row?.properties?.time))
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (!times.length) return;
const HOUR = 60 * 60 * 1000;
const WINDOW = 48 * HOUR;
const STEP = 6 * HOUR;
const min = times[0];
const max = times[times.length - 1];
const startSlider = this.$("#start-slider");
const endSlider = this.$("#end-slider");
const button = this.$("#play");
const label = this.$("#window-label");
const shortDate = (ms) => new Date(ms).toLocaleDateString(
"en", { month: "short", day: "numeric", timeZone: "UTC" }
);
const dateTime = (ms) => new Date(ms).toLocaleString(
"en", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" }
) + " UTC";
const sliderDate = (ms) => new Date(ms).toLocaleString(
"en", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" }
);
for (const slider of [startSlider, endSlider]) {
slider.min = String(min);
slider.max = String(max);
slider.step = String(STEP);
}
startSlider.value = String(min);
endSlider.value = String(Math.min(max, min + WINDOW));
this.$("#axis-start").textContent = shortDate(min);
this.$("#axis-end").textContent = shortDate(max);
const applyWindow = (changed) => {
let start = Number(startSlider.value);
let end = Number(endSlider.value);
if (start > end) {
if (changed === "start") {
end = start;
endSlider.value = String(end);
} else {
start = end;
startSlider.value = String(start);
}
}
label.textContent = dateTime(start) + " — " + dateTime(end);
this.$("#start-value").textContent = sliderDate(start);
this.$("#end-value").textContent = sliderDate(end);
ctx.emit("filter-layer", { layer: "earthquakes", field: "time", range: [start, end] });
};
const stop = (ended = false) => {
if (this.state.timer) clearInterval(this.state.timer);
this.state.timer = null;
this.state.playing = false;
button.textContent = ended ? "↻ Replay" : "▶ Play";
button.setAttribute("aria-label", ended ? "Replay earthquake timeline" : "Play earthquake timeline");
if (ended) {
this.dispatchEvent(new CustomEvent("time-series-playback-ended"));
}
};
const tick = () => {
if (!this.isConnected) return stop();
const start = Number(startSlider.value);
const end = Number(endSlider.value);
const duration = end - start;
const nextEnd = Math.min(max, end + STEP);
const nextStart = Math.max(min, nextEnd - duration);
startSlider.value = String(nextStart);
endSlider.value = String(nextEnd);
applyWindow();
if (nextEnd >= max) stop(true);
};
startSlider.addEventListener("input", () => {
stop();
applyWindow("start");
});
endSlider.addEventListener("input", () => {
stop();
applyWindow("end");
});
button.addEventListener("click", () => {
if (this.state.playing) {
stop();
return;
}
if (Number(endSlider.value) >= max) {
const duration = Number(endSlider.value) - Number(startSlider.value);
startSlider.value = String(min);
endSlider.value = String(Math.min(max, min + duration));
}
this.state.playing = true;
button.textContent = "❚❚ Pause";
button.setAttribute("aria-label", "Pause earthquake timeline");
tick();
if (this.state.playing) this.state.timer = setInterval(tick, 120);
});
this.state.ready = true;
applyWindow();
};
</script>
</om-widget>
<!-- Capability tour. Each card contains a small, highlighted excerpt
from the real markup/script above; the tour explains the page by
showing the code that actually powers it. -->
<om-overlay id="time-series-intro" anchor="[12, 18]" anchor-offset="center" visible="false">
<style>
.card { width:min(390px,calc(100vw - 32px)); box-sizing:border-box; padding:14px;
border-radius:11px; background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:650 13px/1.4 system-ui,sans-serif;
text-align:center; }
</style>
<div class="card">OnlyMapJS makes time series visualizations easy and flexible.</div>
</om-overlay>
<om-overlay id="time-series-layer-card" anchor="[12, 18]" anchor-offset="center" visible="false">
<style>
.card { transform:translate(calc(50vw - 207px),calc(-50vh + 248px));
width:min(390px,calc(100vw - 32px)); box-sizing:border-box; padding:14px;
border-radius:11px; background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:13px/1.4 system-ui,sans-serif; }
.caption { margin-bottom:10px; font-weight:650; }
pre { margin:0; padding:11px 12px; border-radius:8px; overflow:auto; white-space:pre-wrap;
background:#111827; color:#dbeafe; font:10.5px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
.tag { color:#7dd3fc; } .attr { color:#c4b5fd; } .value { color:#86efac; }
.key { color:#fbbf24; font-weight:700; }
@media (max-width:700px) { .card { transform:translate(0,35px); } }
</style>
<div class="card">
<div class="caption">This layer was constructed using the <code>om-layer</code> component.</div>
<pre><code><span class="tag"><om-layer</span> <span class="attr">id</span>=<span class="value">"earthquakes"</span>
<span class="attr">type</span>=<span class="key">"GeoJsonLayer"</span>
<span class="attr">data</span>=<span class="value">"usgs-earthquakes.geojson"</span>
<span class="attr">get-fill-color</span>=<span class="value">"scale($mag, sequential, …)"</span>
<span class="attr">filter-field</span>=<span class="key">"time"</span>
<span class="attr">filter-range</span>=<span class="value">"[start, end]"</span><span class="tag">>
</om-layer></span></code></pre>
</div>
</om-overlay>
<om-overlay id="time-series-stats-card" anchor="[12, 18]" anchor-offset="center" visible="false">
<style>
.card { transform:translate(calc(-50vw + 484px),calc(-50vh + 258px));
width:min(430px,calc(100vw - 32px)); box-sizing:border-box; padding:14px;
border-radius:11px; background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:13px/1.4 system-ui,sans-serif; }
.caption { margin-bottom:10px; font-weight:650; }
pre { margin:0; padding:11px 12px; border-radius:8px; overflow:auto; white-space:pre-wrap;
background:#111827; color:#dbeafe; font:10.5px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
.tag { color:#7dd3fc; } .attr { color:#c4b5fd; } .value { color:#86efac; }
.key { color:#fbbf24; font-weight:700; } .method { color:#f9a8d4; }
@media (max-width:700px) { .card { transform:translate(0,35px); } }
</style>
<div class="card">
<div class="caption">Widgets can be customized to display whatever you want about your data in the current range.</div>
<pre><code><span class="tag"><om-widget</span>
<span class="attr">watch</span>=<span class="key">"data:earthquakes layers"</span><span class="tag">></span>
<span class="tag"><script</span> <span class="attr">type</span>=<span class="value">"om/widget"</span><span class="tag">></span>
<span class="key">this.render</span> = (ctx) => {
const stats = ctx.<span class="method">stats</span>(
<span class="value">"earthquakes"</span>, <span class="value">"mag"</span>
);
};
<span class="tag"></script>
</om-widget></span></code></pre>
</div>
</om-overlay>
<om-overlay id="time-series-slider-card" anchor="[12, 18]" anchor-offset="center" visible="false">
<style>
.card { transform:translate(0,calc(50vh - 343px));
width:min(500px,calc(100vw - 32px)); box-sizing:border-box; padding:14px;
border-radius:11px; background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:13px/1.4 system-ui,sans-serif; }
.caption { margin-bottom:10px; font-weight:650; }
pre { margin:0; padding:11px 12px; border-radius:8px; overflow:auto; white-space:pre-wrap;
background:#111827; color:#dbeafe; font:10.5px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
.tag { color:#7dd3fc; } .attr { color:#c4b5fd; } .value { color:#86efac; }
.key { color:#fbbf24; font-weight:700; } .method { color:#f9a8d4; }
@media (max-width:700px) { .card { transform:translate(0,35px); } }
</style>
<div class="card">
<div class="caption">You can even set the date range interactively using natively supported interfaces.</div>
<pre><code><span class="tag"><input</span> <span class="attr">id</span>=<span class="value">"start-slider"</span>
<span class="attr">type</span>=<span class="key">"range"</span><span class="tag"> /></span>
<span class="tag"><input</span> <span class="attr">id</span>=<span class="value">"end-slider"</span>
<span class="attr">type</span>=<span class="key">"range"</span><span class="tag"> /></span>
ctx.<span class="method">emit</span>(<span class="value">"filter-layer"</span>, {
field: <span class="key">"time"</span>, range: [start, end]
});</code></pre>
</div>
</om-overlay>
<om-overlay id="time-series-action-card" anchor="[12, 18]" anchor-offset="center" visible="false">
<style>
.card { width:min(390px,calc(100vw - 32px)); box-sizing:border-box; padding:14px;
border-radius:11px; background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:650 13px/1.4 system-ui,sans-serif;
text-align:center; }
</style>
<div class="card">Let's see it in action now:</div>
</om-overlay>
<om-overlay id="time-series-cta-card" anchor="[12, 18]" anchor-offset="center" visible="false">
<style>
.card { display:grid; grid-template-columns:minmax(175px,.85fr) minmax(235px,1.15fr);
gap:14px; align-items:center; width:min(520px,calc(100vw - 32px));
box-sizing:border-box; padding:14px 18px; border-radius:11px;
background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:14px/1.4 system-ui,sans-serif; }
.package { color:#2d5be3; font-weight:700; }
pre { margin:0; padding:10px 12px; border-radius:8px; overflow:auto; white-space:pre-wrap;
background:#111827; color:#dbeafe; 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: Check out 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>
<om-story id="time-series-tour" interrupt="pause">
<om-step duration="3500ms" action="show-overlay" target="time-series-intro"></om-step>
<om-step action="hide-overlay" target="time-series-intro"></om-step>
<om-step action="toggle-layer" layer="earthquakes" visible="true"></om-step>
<om-step duration="4500ms" action="show-overlay" target="time-series-layer-card"></om-step>
<om-step action="hide-overlay" target="time-series-layer-card"></om-step>
<om-step duration="4500ms" action="show-overlay" target="time-series-stats-card"></om-step>
<om-step action="hide-overlay" target="time-series-stats-card"></om-step>
<om-step duration="4500ms" action="show-overlay" target="time-series-slider-card"></om-step>
<om-step action="hide-overlay" target="time-series-slider-card"></om-step>
<om-step duration="2000ms" action="show-overlay" target="time-series-action-card"></om-step>
<om-step action="hide-overlay" target="time-series-action-card"></om-step>
</om-story>
<om-widget type="player" story="time-series-tour" position="bottom-left"></om-widget>
<om-behavior on="hover" layer="earthquakes" action="show-tooltip"
template="#earthquake-tooltip"></om-behavior>
</om-map>
<template id="earthquake-tooltip">
<div style="max-width:260px; padding:7px 10px; border-radius:7px; background:#111827; color:#fff;
box-shadow:0 3px 12px rgba(0,0,0,.3); font:12px/1.35 system-ui,sans-serif;">
<strong>M {{mag}}</strong> · {{place}}
</div>
</template>
<script type="module">
// The story player is the one persistent control. Everything it explains
// starts hidden, then appears at the same timestamp as its code card.
await customElements.whenDefined("om-story");
const tour = document.querySelector("#time-series-tour");
const callToAction = document.querySelector("#time-series-cta-card");
const stagedWidgets = {
legend: document.querySelector("#magnitude-legend"),
stats: document.querySelector("#time-series-stats"),
controls: document.querySelector("#time-series-controls"),
};
const setVisible = (widget, visible) => {
widget.style.display = visible ? "" : "none";
widget.setAttribute("aria-hidden", String(!visible));
};
const revealStage = (time) => {
setVisible(stagedWidgets.legend, time >= 3500);
setVisible(stagedWidgets.stats, time >= 8000);
setVisible(stagedWidgets.controls, time >= 12500);
};
let storyStartedScrub = false;
const timelineButton = () => stagedWidgets.controls.shadowRoot?.querySelector("#play");
tour.addEventListener("om-story-tick", (event) => {
const { t, state } = event.detail;
revealStage(t);
// Crossing into the final beat presses the real temporal controller.
// Rewinding the story stops only a scrub that the story itself began.
if (t < 17000 && storyStartedScrub) {
const button = timelineButton();
if (button?.getAttribute("aria-label") === "Pause earthquake timeline") button.click();
storyStartedScrub = false;
}
if (t < 17000) callToAction.setAttribute("visible", "false");
if (t >= 17000 && state === "playing" && !storyStartedScrub) {
const button = timelineButton();
if (button) {
if (button.getAttribute("aria-label") !== "Pause earthquake timeline") button.click();
storyStartedScrub = true;
}
}
});
stagedWidgets.controls.addEventListener("time-series-playback-ended", () => {
if (storyStartedScrub) callToAction.setAttribute("visible", "true");
});
revealStage(0);
</script>
</body>
</html>
The snippet above loads the latest release. The demo above it is pinned to v0.5.2, so it keeps working when a new version ships.