Walter-Lieth Climate Diagram — D3.js

A Walter-Lieth climate diagram (climograph) is the canonical visualization of a location's annual climate, plotting monthly mean temperature and monthly precipitation over the twelve months on a single panel. It uses the classic 1:2 axis-scaling convention where 10 °C on the temperature axis aligns with 20 mm on the precipitation axis, so the relationship between the two curves directly reveals water availability: where precipitation exceeds the temperature curve the period is humid (filled blue/hatched), and where the temperature curve rises above precipitation the period is arid (filled red/dotted). A header carries station metadata and annual means, while frost indicators along the baseline mark cold months.

Walter-Lieth Climate Diagram rendered with D3.js

JavaScript source (D3.js)

// anyplot.ai
// climograph-walter-lieth: Walter-Lieth Climate Diagram
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 93/100 | Created: 2026-06-15

const t = window.ANYPLOT_TOKENS;
const { width, height } = window.ANYPLOT_SIZE;

// Palermo, Sicily — classic Mediterranean climate, 1991–2020 normals
const station = {
  name: "Palermo",
  region: "Sicily, Italy",
  elevation: 15,
  tempMean: 18.3,
  precipTotal: 551,
  period: "1991–2020",
};
const MONTHS  = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const temps   = [11.1, 11.8, 13.2, 15.7, 19.3, 23.6, 26.7, 27.1, 24.1, 19.8, 15.4, 12.2];
const precips = [  67,   57,   47,   36,   20,    7,    3,   14,   38,   85,   91,   86];
const data    = MONTHS.map((month, i) => ({ month, temp: temps[i], precip: precips[i] }));

// Walter-Lieth convention: 10 °C ↔ 20 mm (2:1 ratio)
// Bottom = 0 °C / 0 mm; top = 50 °C / 100 mm (perhumid threshold)
const TEMP_MIN = 0;
const TEMP_MAX = 50;

const margin = { top: 145, right: 115, bottom: 62, left: 82 };
const iw = width  - margin.left - margin.right;
const ih = height - margin.top  - margin.bottom;

// Semantic colors: temperature → Imprint red, precipitation → Imprint blue
const TEMP_COLOR = "#AE3030";
const PRCP_COLOR = "#4467A3";

// ── SVG root ────────────────────────────────────────────────────────────────
const svg = d3.select("#container").append("svg")
  .attr("width", width).attr("height", height);

const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

// ── Scales ──────────────────────────────────────────────────────────────────
const xScale  = d3.scaleBand().domain(MONTHS).range([0, iw]).padding(0);
const yTemp   = d3.scaleLinear().domain([TEMP_MIN, TEMP_MAX]).range([ih, 0]);
// Right-axis scale mirrors temperature scale at 2:1 ratio (0–100 mm = 0–50 °C)
const yPrcpAx = d3.scaleLinear().domain([0, 100]).range([ih, 0]);

// Precipitation → pixel (2:1 ratio; above 100 mm compressed to 10:1)
const yP = (mm) => mm <= 100 ? yTemp(mm / 2) : yTemp(50 + (mm - 100) * 0.1);

// x center of each month band
const xMid = (d) => xScale(d.month) + xScale.bandwidth() / 2;

// Coordinate arrays in group-local space
const tPts = data.map((d) => [xMid(d), yTemp(d.temp)]);
const pPts = data.map((d) => [xMid(d), yP(d.precip)]);

// ── SVG <defs>: fill patterns + masks ──────────────────────────────────────
const defs = svg.append("defs");

// Arid fill pattern — red dots on translucent red ground (Walter-Lieth "dotted")
const aPat = defs.append("pattern").attr("id", "aridPat")
  .attr("patternUnits", "userSpaceOnUse").attr("width", 10).attr("height", 10);
aPat.append("rect").attr("width", 10).attr("height", 10).attr("fill", "rgba(174,48,48,0.20)");
aPat.append("circle").attr("cx", 5).attr("cy", 5).attr("r", 1.8)
  .attr("fill", "rgba(174,48,48,0.50)");

// Humid fill pattern — diagonal hatching on translucent blue ground (Walter-Lieth "hatched")
const hPat = defs.append("pattern").attr("id", "humidPat")
  .attr("patternUnits", "userSpaceOnUse").attr("width", 10).attr("height", 10);
hPat.append("rect").attr("width", 10).attr("height", 10).attr("fill", "rgba(68,103,163,0.20)");
[[-5, 5, 5, -5],[0, 10, 10, 0],[5, 15, 15, 5]].forEach(([x1, y1, x2, y2]) => {
  hPat.append("line").attr("x1", x1).attr("y1", y1).attr("x2", x2).attr("y2", y2)
    .attr("stroke", "rgba(68,103,163,0.55)").attr("stroke-width", 1.5);
});

// Closed polygon "below the curve", extended to the full plot width
// Mask coordinates are in group-local space (0→iw, 0→ih)
const belowPath = (pts) => {
  let d = `M0,${ih} L0,${pts[0][1]}`;
  pts.forEach(([x, y]) => { d += ` L${x},${y}`; });
  d += ` L${iw},${pts[pts.length - 1][1]} L${iw},${ih} Z`;
  return d;
};

// SVG mask: show (below primaryPts) minus (below subtractPts)
const addMask = (id, primaryPts, subtractPts) => {
  const m = defs.append("mask").attr("id", id);
  m.append("rect").attr("x", 0).attr("y", 0).attr("width", iw).attr("height", ih)
    .attr("fill", "black");
  m.append("path").attr("d", belowPath(primaryPts)).attr("fill", "white");
  m.append("path").attr("d", belowPath(subtractPts)).attr("fill", "black");
};

addMask("aridMask",  tPts, pPts); // below temp curve AND above precip curve → arid
addMask("humidMask", pPts, tPts); // below precip curve AND above temp curve → humid

// ── Grid lines ───────────────────────────────────────────────────────────────
g.append("g").selectAll("line").data([0, 10, 20, 30, 40, 50]).join("line")
  .attr("x1", 0).attr("x2", iw)
  .attr("y1", (v) => yTemp(v)).attr("y2", (v) => yTemp(v))
  .attr("stroke", t.grid).attr("stroke-width", 1);

// Vertical month dividers (subtle)
MONTHS.forEach((m) => {
  g.append("line")
    .attr("x1", xScale(m)).attr("x2", xScale(m))
    .attr("y1", 0).attr("y2", ih)
    .attr("stroke", t.grid).attr("stroke-width", 0.5);
});

// ── Filled areas (pattern + mask) ────────────────────────────────────────────
g.append("rect").attr("width", iw).attr("height", ih)
  .attr("fill", "url(#aridPat)").attr("mask", "url(#aridMask)");
g.append("rect").attr("width", iw).attr("height", ih)
  .attr("fill", "url(#humidPat)").attr("mask", "url(#humidMask)");

// ── Data lines ───────────────────────────────────────────────────────────────
// Lines extended to full plot width for a clean edge-to-edge look
const extLinePath = (pts) => {
  const ex = [[0, pts[0][1]], ...pts, [iw, pts[pts.length - 1][1]]];
  return "M" + ex.map(([x, y]) => `${x},${y}`).join(" L");
};

g.append("path").attr("d", extLinePath(tPts))
  .attr("fill", "none").attr("stroke", TEMP_COLOR)
  .attr("stroke-width", 2.5).attr("stroke-linejoin", "round");

g.append("path").attr("d", extLinePath(pPts))
  .attr("fill", "none").attr("stroke", PRCP_COLOR)
  .attr("stroke-width", 2.5).attr("stroke-linejoin", "round");

// ── Left axis: Temperature (°C) ──────────────────────────────────────────────
const axL = g.append("g").call(
  d3.axisLeft(yTemp).tickValues([0, 10, 20, 30, 40, 50])
    .tickSize(5).tickFormat(d3.format("d"))
);
axL.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
axL.selectAll(".tick line").attr("stroke", t.inkSoft);
axL.select(".domain").attr("stroke", t.inkSoft);

g.append("text")
  .attr("transform", `translate(-60,${ih / 2}) rotate(-90)`)
  .attr("text-anchor", "middle").attr("fill", t.ink).style("font-size", "15px")
  .text("Temperature (°C)");

// ── Right axis: Precipitation (mm) ───────────────────────────────────────────
const axR = g.append("g").attr("transform", `translate(${iw},0)`).call(
  d3.axisRight(yPrcpAx).tickValues([0, 20, 40, 60, 80, 100])
    .tickSize(5).tickFormat(d3.format("d"))
);
axR.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
axR.selectAll(".tick line").attr("stroke", t.inkSoft);
axR.select(".domain").attr("stroke", t.inkSoft);

g.append("text")
  .attr("transform", `translate(${iw + 74},${ih / 2}) rotate(90)`)
  .attr("text-anchor", "middle").attr("fill", t.ink).style("font-size", "15px")
  .text("Precipitation (mm)");

// ── Bottom axis: Months ───────────────────────────────────────────────────────
const axB = g.append("g").attr("transform", `translate(0,${ih})`).call(
  d3.axisBottom(xScale).tickSize(4)
);
axB.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px");
axB.selectAll(".tick line").attr("stroke", t.inkSoft);
axB.select(".domain").attr("stroke", t.inkSoft);

// ── Header ────────────────────────────────────────────────────────────────────
// Mandated anyplot title
svg.append("text").attr("x", width / 2).attr("y", 30)
  .attr("text-anchor", "middle").attr("fill", t.ink)
  .style("font-size", "20px").style("font-weight", "600")
  .text("climograph-walter-lieth · javascript · d3 · anyplot.ai");

// Station name
svg.append("text").attr("x", margin.left).attr("y", 70)
  .attr("fill", t.ink).style("font-size", "26px").style("font-weight", "700")
  .text(station.name);

// Location and period
svg.append("text").attr("x", margin.left).attr("y", 96)
  .attr("fill", t.inkSoft).style("font-size", "13px")
  .text(`${station.region}  |  ${station.elevation} m a.s.l.  |  ${station.period}`);

// Annual mean temperature (right side of header)
const hx = width - margin.right;
svg.append("text").attr("x", hx).attr("y", 70).attr("text-anchor", "end")
  .attr("fill", TEMP_COLOR).style("font-size", "22px").style("font-weight", "700")
  .text(`${station.tempMean} °C`);

svg.append("text").attr("x", hx).attr("y", 96).attr("text-anchor", "end")
  .attr("fill", PRCP_COLOR).style("font-size", "18px").style("font-weight", "600")
  .text(`${station.precipTotal} mm/yr`);

// Separator line below header
svg.append("line")
  .attr("x1", margin.left).attr("x2", width - margin.right)
  .attr("y1", margin.top - 9).attr("y2", margin.top - 9)
  .attr("stroke", t.grid).attr("stroke-width", 1.5);

// ── Plot border ───────────────────────────────────────────────────────────────
g.append("rect").attr("width", iw).attr("height", ih)
  .attr("fill", "none").attr("stroke", t.inkSoft).attr("stroke-width", 1);

// ── Legend ────────────────────────────────────────────────────────────────────
const lx = iw - 198, ly = 18;
const legItems = [
  { patId: "aridPat",  lineColor: TEMP_COLOR, label: "Arid  (T > P/2)" },
  { patId: "humidPat", lineColor: PRCP_COLOR, label: "Humid  (P > 2T)" },
];
legItems.forEach(({ patId, lineColor, label }, i) => {
  const y0 = ly + i * 30;
  g.append("rect").attr("x", lx).attr("y", y0).attr("width", 30).attr("height", 16)
    .attr("fill", `url(#${patId})`).attr("stroke", lineColor).attr("stroke-width", 1.5)
    .attr("rx", 2);
  g.append("line").attr("x1", lx).attr("x2", lx + 30)
    .attr("y1", y0 + 8).attr("y2", y0 + 8)
    .attr("stroke", lineColor).attr("stroke-width", 2);
  g.append("text").attr("x", lx + 38).attr("y", y0 + 12)
    .attr("fill", t.inkSoft).style("font-size", "13px").text(label);
});

Part of Walter-Lieth Climate Diagram on anyplot.ai.

Other implementations