Hurricane tracks
Every named storm that formed in the Atlantic or the Eastern Pacific in the last three seasons, traced every few hours from the first warning to the last. Each track darkens and widens as the storm strengthens, and fades as it weakens over land or cold water. Pick a season, and click a track for the storm's name and peak.
Build it yourself
A Node.js script turns NOAA's IBTrACS file from CSV into GeoJSON lines, one for each stretch of a storm at the same strength, and a page draws them colored and sized by category. It was tested with MapLibre GL JS 6.15 in Chromium and Node.js 22.
What you need
- Node.js 18 or later.
- The map tiles and IBTrACS need no key.
Collect the storms
Make a folder for the map, and in it a file called storms.mjs:
import { writeFile } from "node:fs/promises";
const url =
"https://www.ncei.noaa.gov/data/international-best-track-archive-for-climate-stewardship-ibtracs/v04r01/access/csv/ibtracs.last3years.list.v04r01.csv";
const response = await fetch(url);
if (!response.ok) throw new Error(`IBTrACS returned ${response.status}`);
const [header, , ...rows] = (await response.text()).trim().split("\n").map((row) => row.split(","));
const column = Object.fromEntries(header.map((name, i) => [name, i]));
const storms = new Map();
for (const row of rows) {
const id = row[column.SID];
if (!storms.has(id)) storms.set(id, { name: row[column.NAME], season: Number(row[column.SEASON]), basin: row[column.BASIN], points: [] });
const category = Number(row[column.USA_SSHS]);
storms.get(id).points.push([Number(row[column.LON]), Number(row[column.LAT]), Number.isFinite(category) ? category : -5]);
}
const first = new Date().getFullYear() - 2;
const features = [];
for (const { name, season, basin, points } of storms.values()) {
if (!["NA", "EP"].includes(basin) || season < first || name === "UNNAMED") continue;
let line;
for (let i = 0; i < points.length; i++) {
const [lon, lat, category] = points[i];
line?.geometry.coordinates.push([lon, lat]);
if (i < points.length - 1 && line?.properties.category !== category) {
line = { type: "Feature", properties: { name, season, category }, geometry: { type: "LineString", coordinates: [[lon, lat]] } };
features.push(line);
}
}
}
await writeFile("storms.geojson", JSON.stringify({ type: "FeatureCollection", features }));
console.log(`Saved ${new Set(features.map((line) => line.properties.name + line.properties.season)).size} storms`);
- IBTrACS has a row for each storm every three hours, with columns from each agency that tracks it.
USA_SSHSis the US National Hurricane Center's category, from -1 for a tropical depression to 5. Rows with none get -5. - The second row of the file is units, not data, so it's skipped.
- The file has no quoted fields, so splitting on commas is enough.
NAis the North Atlantic andEPthe Eastern Pacific. Storms that never got a name are left out.- A storm's track is split into lines where its category changes, each sharing a point with the next, so the track stays joined while each part takes its own color.
Run it:
node storms.mjs
It downloads about 12 MB, then prints Saved 97 storms, or however many there are today, and writes storms.geojson.
Make the page
Next to storms.geojson, make index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hurricane tracks</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
</head>
<body style="margin: 0">
<select id="season" style="position: absolute; z-index: 1; top: 10px; left: 10px; font-size: 16px" aria-label="Season">
<option value="">All seasons</option>
</select>
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
const map = new maplibregl.Map({
container: "map",
style: "https://tiles.mapricot.com/styles/light.json",
center: [-95, 25],
zoom: 2.5,
});
await map.once("load");
map.addSource("storms", { type: "geojson", data: "storms.geojson", attribution: "Storms: NOAA IBTrACS" });
map.addLayer({
id: "storms",
type: "line",
source: "storms",
layout: { "line-cap": "round", "line-join": "round", "line-sort-key": ["get", "category"] },
paint: {
"line-color": ["step", ["get", "category"], "#adb5bd", 0, "#ffc078", 1, "#fd7e14", 3, "#c2410c", 5, "#7c2d12"],
"line-width": ["max", 1, ["*", 0.8, ["get", "category"]]],
},
});
const season = document.querySelector("#season");
const year = new Date().getFullYear();
for (const option of [year - 2, year - 1, year]) season.add(new Option(option));
season.addEventListener("change", () => {
map.setFilter("storms", season.value ? ["==", ["get", "season"], Number(season.value)] : null);
});
map.on("click", "storms", (event) => {
const { name, season, category } = event.features[0].properties;
const strength = category >= 1 ? `category ${category} hurricane` : category === 0 ? "tropical storm" : "depression";
new maplibregl.Popup().setLngLat(event.lngLat).setText(`${name}, ${season}: a ${strength} here`).addTo(map);
});
</script>
</body>
</html>
steppicks a color for each category: gray for depressions, then light orange for tropical storms, darkening through to brown for category 5.line-sort-keydraws the strongest stretches on top where tracks cross.- The menu sets a filter on
season, so it redraws without loading the file again.
Open it
Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. You should see three seasons of tracks curving out of the tropics, across the Caribbean and up the Atlantic, and west along Mexico's Pacific coast.
IBTrACS has files for every basin and every year back to 1842, in the same columns.
Data
- Storms: IBTrACS version 4, NOAA National Centers for Environmental Information (public domain), with categories from the US National Hurricane Center
- Map: OpenStreetMap contributors (ODbL), in Mapricot's map tiles