← Gallery / Climate & hazards
Tornado Alley, 1950–2024
One filter-range on a ScatterplotLayer drives the map, a ctx.stats casualty panel, a Vega-Lite histogram and undo. 13,182 storms.
<!DOCTYPE html>
<!--
One filter, four consumers. `filter-range` on the layer is the only piece of
state this page has: the GPU cut, the casualty panel, the by-year histogram
and the undo stack all read it, so nothing on screen can drift out of step
with anything else. Reach for this shape when a dashboard has several views
of one dataset and they must agree.
The map: 13,182 significant U.S. tornadoes (EF2 and above), from the NOAA
Storm Prediction Center's 1950–2024 database. Drag the EF slider or press a
preset — 13,182 storms fall to 59 at EF5, while deaths only fall from 5,997
to 3,865, because the violent few percent cause most of them. Click a storm
for its record, or press ▶ (bottom-left) for the tour.
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OnlyMapJS — Tornado Alley, 1950–2024</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; }
html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
/* The --om-widget-* variables restyle every built-in widget at once — the
EF slider and the story player pick this up without being touched. */
om-map {
display: block; height: 100vh;
--om-widget-bg: #1a1a19;
--om-widget-fg: #ffffff;
--om-widget-muted: #898781;
--om-widget-border: #383835;
--om-widget-hover-bg: #2c2c2a;
--om-widget-accent: #bf403d;
}
/* The vega-lite built-in renders a bare chart, so give it a panel. */
om-widget[type="vega-lite"] {
background: #1a1a19; border: 1px solid #383835; border-radius: 6px;
padding: 10px 12px 6px; box-shadow: 0 4px 18px rgba(0,0,0,.45);
}
</style>
</head>
<body>
<om-map center="[-93.5, 37.8]" zoom="4.1" basemap="dark-matter">
<!-- A `threshold` scale maps the EF bands to discrete colours; its domain
takes the BREAKS between classes, so four colours need three numbers.
Radius grows faster than the rating so violent storms still read at
national zoom, and radius-units="pixels" keeps them that size —
radius is measured in metres otherwise. -->
<om-layer id="tornadoes" type="ScatterplotLayer"
data="../../data/tornadoes-ef2plus.csv"
label="Tornadoes" color="#ef5b52" pickable
filter-field="mag" filter-range="[2, 5]"
get-position="[$lon, $lat]"
get-fill-color="scale($mag, threshold, ['#8c2c2b','#bf403d','#ef5b52','#ffa198'], domain=[3,4,5])"
get-radius="$mag * $mag * 0.55"
radius-units="pixels" radius-min-pixels="1.4"
opacity="0.75"></om-layer>
<!-- Filter-aware AND viewport-scoped: scope:"viewport" counts only what is
both inside the EF window and on screen. Multiply mean by count when
you want a total, as the death and injury figures do here. -->
<om-widget position="top-start" order="1">
<style>
.panel { background: var(--om-widget-bg); color: var(--om-widget-fg);
border: 1px solid var(--om-widget-border); border-radius: 10px;
padding: 13px 15px; width: 218px; font-size: 12px;
box-shadow: 0 4px 18px rgba(0,0,0,.45); }
.kicker { font-size: 10.5px; font-weight: 650; letter-spacing: .08em;
text-transform: uppercase; color: var(--om-widget-muted); }
.hero { font-size: 27px; font-weight: 700; line-height: 1.15; margin: 2px 0 0; }
.ctx { color: var(--om-widget-muted); margin: 1px 0 10px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 10px;
border-top: 1px solid var(--om-widget-border); padding-top: 10px; }
.cell .v { font-size: 15px; font-weight: 650; }
.cell .l { color: var(--om-widget-muted); font-size: 10.5px; }
.deadliest { border-top: 1px solid var(--om-widget-border); margin-top: 10px;
padding-top: 8px; color: #c3c2b7; line-height: 1.45; }
.deadliest b { color: #ffa198; }
</style>
<div class="panel">
<div class="kicker">In current view</div>
<div class="hero" id="count">—</div>
<div class="ctx" id="ctx">loading 75 years of records…</div>
<div class="grid">
<div class="cell"><div class="v" id="fat">—</div><div class="l">deaths</div></div>
<div class="cell"><div class="v" id="inj">—</div><div class="l">injuries</div></div>
<div class="cell"><div class="v" id="len">—</div><div class="l">longest track</div></div>
<div class="cell"><div class="v" id="worst">—</div><div class="l">strongest</div></div>
</div>
<div class="deadliest" id="deadliest"></div>
</div>
<script type="om/widget">
this.watch = ['data:tornadoes', 'viewport', 'layers'];
this.render = async (ctx) => {
const n = ctx.stats('tornadoes', 'mag', { scope: 'viewport' });
const fat = ctx.stats('tornadoes', 'fat', { scope: 'viewport' });
const inj = ctx.stats('tornadoes', 'inj', { scope: 'viewport' });
const len = ctx.stats('tornadoes', 'len', { scope: 'viewport' });
const match = ctx.stats('tornadoes', 'mag'); // filtered, whole file
const total = ctx.stats('tornadoes', 'mag', { filtered: false }); // every row
this.$('#count').textContent = n.count.toLocaleString();
this.$('#ctx').textContent =
`${match.count.toLocaleString()} of ${total.count.toLocaleString()} tornadoes match the filter`;
this.$('#fat').textContent = n.count ? Math.round(fat.mean * fat.count).toLocaleString() : '—';
this.$('#inj').textContent = n.count ? Math.round(inj.mean * inj.count).toLocaleString() : '—';
this.$('#len').textContent = n.count ? len.max.toFixed(0) + ' mi' : '—';
this.$('#worst').textContent = n.count ? 'EF' + n.max : '—';
const rows = await ctx.dataInViewport('tornadoes');
const top = rows.reduce((a, r) => (r.fat > (a?.fat ?? -1) ? r : a), null);
this.$('#deadliest').innerHTML = (top && top.fat > 0)
? `Deadliest in view: <b>${top.fat} killed</b> — EF${top.mag}, ${top.st}, ${top.date}`
: 'No fatal tornadoes in view';
};
</script>
</om-widget>
<!-- The built-in filter slider. Dragging it writes filter-range back onto
the layer element, which is why everything else on the page follows. -->
<om-widget type="filter" layer="tornadoes" field="mag" title="EF rating range"
position="top-start" order="2" watch="layers data:tornadoes"></om-widget>
<!-- Presets emit the same filter-layer action the slider does, and read
their active state back off the manifest — so undo (Cmd/Ctrl-Z) and
the tour, which clicks these very buttons, both stay in sync. -->
<om-widget id="ef-presets" position="top-start" order="3">
<style>
.presets { display: flex; gap: 5px; background: var(--om-widget-bg);
border: 1px solid var(--om-widget-border); border-radius: 10px;
padding: 6px; box-shadow: 0 4px 18px rgba(0,0,0,.45); }
button { flex: 1; border: none; border-radius: 6px; padding: 6px 4px;
background: transparent; color: #c3c2b7; font: 600 11.5px system-ui;
cursor: pointer; white-space: nowrap; }
button:hover { background: var(--om-widget-hover-bg); }
button.on { background: var(--om-widget-accent); color: #fff; }
</style>
<div class="presets">
<button data-lo="2">EF2+</button>
<button data-lo="3">EF3+</button>
<button data-lo="4">EF4+</button>
<button data-lo="5">EF5</button>
</div>
<script type="om/widget">
this.watch = ['layers'];
let last;
if (!this.state.wired) {
this.state.wired = true;
this.root.addEventListener('click', (e) => {
const b = e.target.closest('button[data-lo]');
if (b && last) last.emit('filter-layer',
{ layer: 'tornadoes', field: 'mag', range: [Number(b.dataset.lo), 5] });
});
}
this.render = (ctx) => {
last = ctx;
const lo = ctx.layers.find(l => l.id === 'tornadoes')?.filter?.range?.[0] ?? 2;
for (const b of this.root.querySelectorAll('button[data-lo]'))
b.classList.toggle('on', Math.abs(Number(b.dataset.lo) - lo) < 0.01);
};
</script>
</om-widget>
<!-- A Vega-Lite spec in a <script type="application/json"> child, re-binned
on every pan and every filter change. At EF4+, 1974 (the Super
Outbreak) and 2011 (Joplin, Tuscaloosa) stand clear of everything. -->
<om-widget type="vega-lite" layer="tornadoes" watch="data:tornadoes viewport layers"
position="top-end" width="272" title="Tornadoes per year — in view">
<script type="application/json">
{
"background": "transparent",
"height": 108,
"mark": { "type": "bar", "color": "#ef5b52", "tooltip": true, "binSpacing": 1,
"cornerRadiusTopLeft": 2, "cornerRadiusTopRight": 2 },
"encoding": {
"x": { "bin": { "maxbins": 38 }, "field": "yr", "type": "quantitative",
"title": "year", "axis": { "format": "d" } },
"y": { "aggregate": "count", "type": "quantitative", "title": null }
},
"config": {
"view": { "stroke": null },
"axis": { "labelColor": "#898781", "titleColor": "#898781",
"gridColor": "#2c2c2a", "domainColor": "#383835",
"tickColor": "#383835", "labelFont": "system-ui",
"titleFont": "system-ui", "labelFontSize": 10, "titleFontSize": 10 }
}
}
</script>
</om-widget>
<!-- Write the key by hand when the classes carry something a reader needs
named — the built-in legend draws a scale's swatches, and the words
beside them are yours. -->
<om-widget position="bottom-end">
<style>
.legend { background: var(--om-widget-bg); color: var(--om-widget-fg);
border: 1px solid var(--om-widget-border); border-radius: 10px;
padding: 11px 13px; font-size: 11.5px; min-width: 148px;
box-shadow: 0 4px 18px rgba(0,0,0,.45); }
.legend h4 { margin: 0 0 7px; font-size: 12px; }
.row { display: flex; align-items: center; gap: 7px; margin: 4px 0; }
.dot { border-radius: 50%; flex: none; }
.row span:last-child { color: #c3c2b7; }
.row b { min-width: 26px; }
</style>
<div class="legend">
<h4>Enhanced Fujita scale</h4>
<div class="row"><span class="dot" style="width:7px;height:7px;background:#8c2c2b"></span><b>EF2</b><span>111–135 mph</span></div>
<div class="row"><span class="dot" style="width:9px;height:9px;background:#bf403d"></span><b>EF3</b><span>136–165 mph</span></div>
<div class="row"><span class="dot" style="width:11px;height:11px;background:#ef5b52"></span><b>EF4</b><span>166–200 mph</span></div>
<div class="row"><span class="dot" style="width:13px;height:13px;background:#ffa198"></span><b>EF5</b><span>>200 mph</span></div>
</div>
</om-widget>
<!-- anchor-from="selection" pins the overlay to whatever was last clicked,
and {{field}} reads that feature's own CSV row. -->
<om-overlay id="storm-detail" anchor-from="selection" anchor-offset="bottom-center" visible="false">
<style>
.popup { background: #1a1a19; color: #fff; border: 1px solid #383835;
border-radius: 10px; padding: 13px 15px; min-width: 200px;
font: 12.5px system-ui; box-shadow: 0 8px 26px rgba(0,0,0,.55); }
.close { float: right; cursor: pointer; color: #898781; }
.when { font-weight: 650; }
.ef { font-size: 25px; font-weight: 700; color: #ef5b52; margin: 3px 0 7px; }
table { width: 100%; border-collapse: collapse; }
td { padding: 1.5px 0; color: #c3c2b7; }
td:last-child { text-align: right; color: #898781; }
</style>
<div class="popup">
<span class="close" data-emit="hide-overlay" data-target="storm-detail">✕</span>
<div class="when">{{date}} — {{st}}</div>
<div class="ef">EF{{mag}}</div>
<table>
<tr><td>Deaths</td><td>{{fat}}</td></tr>
<tr><td>Injuries</td><td>{{inj}}</td></tr>
<tr><td>Path</td><td>{{len}} mi × {{wid}} yd</td></tr>
</table>
</div>
</om-overlay>
<om-behavior on="click" layer="tornadoes" action="show-overlay" target="storm-detail"></om-behavior>
<!-- Tour cards. Each one shows the real markup powering what is on screen
at that moment. -->
<om-overlay id="tour-intro" anchor="[-93.5, 37.8]" anchor-offset="center" visible="false">
<style>
.card { width:min(400px,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 13.5px/1.45 system-ui,sans-serif;
text-align:center; }
</style>
<div class="card">75 years of significant U.S. tornadoes — 13,182 records,
one declarative HTML file, zero build.</div>
</om-overlay>
<om-overlay id="tour-layer-card" anchor="[-93.5, 37.8]" anchor-offset="center" visible="false">
<style>
.card { width:min(410px,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; }
</style>
<div class="card">
<div class="caption">All 13,182 dots come from one <code>om-layer</code> element — the EF
bands are a declarative <code>threshold</code> scale, filtered on the GPU.</div>
<pre><code><span class="tag"><om-layer</span> <span class="attr">id</span>=<span class="value">"tornadoes"</span> <span class="attr">type</span>=<span class="key">"ScatterplotLayer"</span>
<span class="attr">data</span>=<span class="value">"tornadoes-ef2plus.csv"</span>
<span class="attr">get-fill-color</span>=<span class="value">"scale($mag, threshold, …)"</span>
<span class="attr">filter-field</span>=<span class="key">"mag"</span> <span class="attr">filter-range</span>=<span class="value">"[2, 5]"</span><span class="tag">>
</om-layer></span></code></pre>
</div>
</om-overlay>
<!-- A widget sits in a managed screen slot and holds still while the
camera moves under it; an overlay is geo-anchored and travels with
the map. Pick the one that matches what the card is about. The module
script at the end toggles this one on the story's own clock. -->
<om-widget id="tour-stats-card" position="bottom-center" style="display:none" aria-hidden="true">
<style>
.card { 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; }
</style>
<div class="card">
<div class="caption">The casualty panel is a widget script — <code>ctx.stats()</code> is
filter-aware and viewport-scoped, so the numbers can never disagree with the dots.</div>
<pre><code><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 deaths = ctx.<span class="method">stats</span>(<span class="value">"tornadoes"</span>, <span class="value">"fat"</span>,
{ scope: <span class="key">"viewport"</span> });
};
<span class="tag"></script></span></code></pre>
</div>
</om-widget>
<om-overlay id="tour-filter-card" anchor="[-93.5, 37.8]" anchor-offset="center" visible="false">
<style>
.card { transform:translate(calc(-50vw + 505px),calc(-50vh + 430px));
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">One attribute drives everything. The slider, the preset buttons, and
the story you're watching all emit the same action — layer, stats, histogram, and undo
history follow.</div>
<pre><code><span class="tag"><om-widget</span> <span class="attr">type</span>=<span class="key">"filter"</span> <span class="attr">layer</span>=<span class="value">"tornadoes"</span> <span class="attr">field</span>=<span class="value">"mag"</span><span class="tag">></span>
ctx.<span class="method">emit</span>(<span class="value">"filter-layer"</span>,
{ field: <span class="key">"mag"</span>, range: [4, 5] });</code></pre>
</div>
</om-overlay>
<om-overlay id="tour-action-card" anchor="[-93.5, 37.8]" 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 13.5px/1.4 system-ui,sans-serif;
text-align:center; }
</style>
<div class="card">Now watch it climb the Enhanced Fujita scale —</div>
</om-overlay>
<om-overlay id="tour-joplin-card" anchor="[-94.5932, 37.0524]" anchor-offset="bottom-center" visible="false">
<style>
.card { width:min(370px,calc(100vw - 32px)); box-sizing:border-box; padding:14px 16px;
border-radius:11px; background:rgba(255,255,255,.97); color:#172033;
box-shadow:0 6px 24px rgba(0,0,0,.28); font:13px/1.45 system-ui,sans-serif;
margin-bottom:14px; }
.ef5 { color:#bf403d; font-weight:750; font-size:15px; }
</style>
<div class="card">
<div class="ef5">EF5 — Joplin, Missouri · May 22, 2011</div>
158 killed, 1,150 injured along a 21.6-mile track — the deadliest U.S. tornado
since modern records began, one of only 59 EF5s in 75 years.
</div>
</om-overlay>
<om-overlay id="tour-cta-card" anchor="[-93.5, 37.8]" 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:#bf403d; 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: 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>
<!-- The timeline. Steps fire the same actions a button or a behavior does,
and the story is a SIBLING of the scene — delete it and the map above
is unchanged. The EF beats are fly-to steps; the module script below
watches the clock and presses the matching preset at each one. -->
<om-story id="tornado-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="5000ms" action="show-overlay" target="tour-layer-card"></om-step>
<om-step action="hide-overlay" target="tour-layer-card"></om-step>
<!-- Dive into the dense east, sweep west, settle home. Every in-view
number re-derives on each move — the proof the panel cannot lie. -->
<om-step duration="2100ms" action="fly-to" center="[-86.5, 38.6]" zoom="4.9"></om-step>
<om-step duration="1600ms" action="fly-to" center="[-99.8, 36.5]" zoom="4.7"></om-step>
<om-step duration="1300ms" action="fly-to" center="[-93.5, 37.8]" zoom="4.1"></om-step>
<om-step duration="5000ms" action="show-overlay" target="tour-filter-card"></om-step>
<om-step action="hide-overlay" target="tour-filter-card"></om-step>
<om-step duration="2200ms" action="show-overlay" target="tour-action-card"></om-step>
<om-step action="hide-overlay" target="tour-action-card"></om-step>
<om-step duration="2400ms" action="fly-to" center="[-93.5, 37.8]" zoom="4.15"></om-step>
<om-step duration="2400ms" action="fly-to" center="[-93.5, 37.8]" zoom="4.25"></om-step>
<om-step duration="3000ms" action="fly-to" center="[-94.5932, 37.0524]" zoom="6.3" curve></om-step>
<om-step duration="5000ms" action="show-overlay" target="tour-joplin-card"></om-step>
<om-step action="hide-overlay" target="tour-joplin-card"></om-step>
<om-step duration="2400ms" action="fly-to" center="[-93.5, 37.8]" zoom="4.1"></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>
<om-widget type="player" story="tornado-tour" position="bottom-start"></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 from a web server in a browser such as Chrome, Safari, or Firefox.
</p>
</om-fallback>
</om-map>
<script type="module">
// The EF beats press the page's OWN preset buttons, so the buttons light
// up and the layer, stats and histogram react exactly as they would for a
// human click. `om-story-tick` carries the story clock; the thresholds
// mirror the timeline above.
await customElements.whenDefined("om-story");
const tour = document.querySelector("#tornado-tour");
const presets = document.querySelector("#ef-presets");
const statsCard = document.querySelector("#tour-stats-card");
const BEATS = [ // [story time ms, EF preset to press]
[34000, 2], // flight home → reset to EF2+
[26000, 5], // Joplin flight + card
[23600, 4],
[21200, 3],
[0, 2],
];
let applied = 2;
tour.addEventListener("om-story-tick", (event) => {
const t = event.detail.t;
const cardOn = t >= 9000 && t < 14000; // rides the camera-sweep window
statsCard.style.display = cardOn ? "" : "none";
statsCard.setAttribute("aria-hidden", String(!cardOn));
const lo = BEATS.find(([at]) => t >= at)[1];
if (lo === applied) return;
applied = lo;
presets.shadowRoot?.querySelector(`button[data-lo="${lo}"]`)?.click();
});
</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.