Find your ideal climate map with MapLibre GL JS

Set the coldest and hottest weather you'll put up with, and how many rainy days a year. The darker the orange, the more days a year a place in the lower 48 states fits. Click anywhere for that place's year.

Good days a year
1365

The days come from NOAA's climate normals, the averages of 1991 to 2020 that forecasters compare each day's weather with. NOAA's grid has the average high and low for each month in squares about 3 miles across, made from thousands of weather stations, so it has the mountains and coasts between them too. The map averages them into squares about 8 miles across, and takes each day's high and low from between the months on either side of it, so a good day is the usual weather for that time of year, not every year's. The grid doesn't count rainy days, so each square takes them from the four nearest of NOAA's 8,925 weather stations: the days a year with at least a hundredth of an inch of rain or melted snow.

The normals cover the lower 48 states. NOAA makes them for Alaska too, on a different grid, but not for Hawaii.

Build it yourself

A Node.js script turns NOAA's grids of monthly highs and lows into a file of squares, and a page counts each square's good days between two sliders, the coldest low and the hottest high. It's the map above without the rainy days or the clicks. It was tested with MapLibre GL JS 6.15 in Chromium and Node.js 22.

What you need

  • Node.js 18 or later.
  • A browser key with the Map tiles product, made as API keys shows. Add http://localhost:* to its allowed origins, so it works from your computer while you build. Map tiles are free, and signing up takes a minute, with no credit card. NOAA's data needs no key.

Collect the highs and lows

Make a folder for the map, and in it a file called climate.mjs:

climate.mjs
import { writeFile } from "node:fs/promises";

const base = "https://www.ncei.noaa.gov/pub/data/cirs/climgrid/normals";
async function rows(name) {
  const response = await fetch(`${base}/us_${name}_1991-2020_normal.dat`);
  if (!response.ok) throw new Error(`NOAA returned ${response.status}`);
  return (await response.text()).trim().split("\n");
}
const [highs, lows] = await Promise.all([rows("tmax"), rows("tmin")]);

// Each row is a square's latitude and longitude, then its average for each month, in °F. Both files list the same
// squares in the same order.
const size = 0.25;
const squares = new Map();
highs.forEach((row, i) => {
  const [lat, lon, ...high] = row.trim().split(/\s+/).map(Number);
  const low = lows[i].trim().split(/\s+/).slice(2).map(Number);
  const key = `${Math.floor(lon / size)},${Math.floor(lat / size)}`;
  const square = squares.get(key) ?? { count: 0, highs: new Array(12).fill(0), lows: new Array(12).fill(0) };
  square.count++;
  high.forEach((value, month) => (square.highs[month] += value));
  low.forEach((value, month) => (square.lows[month] += value));
  squares.set(key, square);
});

const features = [...squares].map(([key, square]) => {
  const [x, y] = key.split(",").map(Number);
  const [west, south, east, north] = [x * size, y * size, (x + 1) * size, (y + 1) * size];
  const average = (sums) => sums.map((sum) => Math.round(sum / square.count));
  return {
    type: "Feature",
    properties: { highs: average(square.highs), lows: average(square.lows) },
    geometry: { type: "Polygon", coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] },
  };
});
await writeFile("climate.json", JSON.stringify({ type: "FeatureCollection", features }));
console.log(`${features.length} squares`);
  • NOAA's files are plain text, about 50 MB each, with a row for each of its squares, about 3 miles across. The script averages them into squares a quarter of a degree across, about 17 miles, so the file the page loads is small.
  • Each square is a GeoJSON polygon with its 12 monthly highs and lows, for the page to count from.

Run it:

Terminal
node climate.mjs

It prints about 13,000 squares, and writes climate.json next to it.

Make the page

In the same folder, make index.html, with your browser key in place of mapricot_pk_…:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Find your ideal climate</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
    <script type="importmap">
      {
        "imports": {
          "maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm"
        }
      }
    </script>
    <style>
      body { margin: 0; font-family: system-ui, sans-serif; }
      #map { height: 100vh; }
      #panel { position: absolute; top: 10px; left: 10px; background: white; padding: 8px 12px; border-radius: 6px; }
      label { display: block; }
    </style>
  </head>
  <body>
    <div id="map"></div>
    <div id="panel">
      <label>Coldest low <span id="coldest-label"></span><br><input id="coldest" type="range" min="-10" max="70" value="40"></label>
      <label>Hottest high <span id="hottest-label"></span><br><input id="hottest" type="range" min="50" max="110" value="85"></label>
    </div>
    <script type="module">
      import * as maplibregl from "maplibre-gl";

      const key = "mapricot_pk_…";
      const map = new maplibregl.Map({
        container: "map",
        style: `https://api.mapricot.com/tiles/v1/styles/light.json?key=${key}`,
        bounds: [[-124.8, 24.5], [-66.9, 49.4]],
      });

      const squares = await (await fetch("climate.json")).json();
      const coldest = document.querySelector("#coldest");
      const hottest = document.querySelector("#hottest");

      // Each day's high and low, between the averages of the months on either side of it. A good day's low is no
      // colder than the coldest, and its high no hotter than the hottest.
      function goodDays({ highs, lows }) {
        let days = 0;
        for (let day = 0; day < 365; day++) {
          const position = (day + 0.5) / (365 / 12) - 0.5;
          const before = (Math.floor(position) + 12) % 12;
          const after = (before + 1) % 12;
          const weight = position - Math.floor(position);
          const high = highs[before] + (highs[after] - highs[before]) * weight;
          const low = lows[before] + (lows[after] - lows[before]) * weight;
          if (low >= Number(coldest.value) && high <= Number(hottest.value)) days++;
        }
        return days;
      }

      function count() {
        document.querySelector("#coldest-label").textContent = `${coldest.value}°F`;
        document.querySelector("#hottest-label").textContent = `${hottest.value}°F`;
        for (const square of squares.features) square.properties.days = goodDays(square.properties);
        map.getSource("climate")?.setData(squares);
      }
      coldest.addEventListener("input", count);
      hottest.addEventListener("input", count);
      count();

      await map.once("load");
      map.addSource("climate", { type: "geojson", data: squares, attribution: "NOAA climate normals" });
      map.addLayer(
        {
          id: "climate",
          type: "fill",
          source: "climate",
          paint: {
            "fill-color": ["interpolate", ["linear"], ["get", "days"], 0, "#ffffff", 1, "#ffecd2", 180, "#fd7e14", 365, "#7c2d12"],
            "fill-opacity": 0.85,
            "fill-antialias": false,
          },
        },
        "water",
      );
    </script>
  </body>
</html>
  • goodDays takes each month's average high and low as the ones in the middle of the month, and each day's from between the two months on either side of it, so a place doesn't jump from one month's normals to the next on the 1st.
  • Each move of a slider counts every square's good days again and hands the squares back to MapLibre with setData.
  • The squares go in under the style's water layer, so lakes and the coast stay blue, and roads and names stay on top. fill-antialias is off so the edges between squares don't show as faint lines.

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 the lower 48 states in orange, darkest along the coast of California, where nearly every day's low is above 40°F and its high below 85°F. Drag the hottest high down to 75°F, and Florida and the South fade while the coast of California stays dark.

NOAA has the same file for precipitation (prcp), and the station normals in its archive count the days with rain or snow. Alaska's grids are in the same folder as the lower 48's.

Data

  • Highs and lows: NOAA National Centers for Environmental Information, U.S. Gridded Climate Normals for 1991–2020, from nClimGrid (public domain)
  • Rainy days: NOAA National Centers for Environmental Information, U.S. Climate Normals for 1991–2020, annual days with at least 0.01 in. of precipitation at each station (public domain)
  • Place names: Mapricot's geocoding, from OpenStreetMap, OpenAddresses, Who's On First, and GeoNames
  • Map: OpenStreetMap contributors (ODbL), in Mapricot's map tiles