Psychrometric Chart for HVAC — D3.js

A psychrometric chart plots dry-bulb temperature against humidity ratio, overlaid with curves for relative humidity, wet-bulb temperature, enthalpy, and specific volume. It is the fundamental tool for HVAC system design and air conditioning process analysis. The chart reveals the thermodynamic properties of moist air at a glance, enabling engineers to trace heating, cooling, humidification, and dehumidification processes as paths on the diagram.

Psychrometric Chart for HVAC rendered with D3.js

JavaScript source (D3.js)

// anyplot.ai
// psychrometric-basic: Psychrometric Chart for HVAC
// Library: d3 7.9.0 | JavaScript 22.22.3
// Quality: 92/100 | Created: 2026-06-16
//# anyplot-orientation: landscape
// anyplot.ai
// psychrometric-basic: Psychrometric Chart for HVAC
// Library: d3 7.9.0 | JavaScript 22
// Quality: pending | Created: 2026-06-16

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

// ── Moist-air psychrometrics at sea-level pressure (ASHRAE, 101.325 kPa) ──────
// All thermodynamic property lines are derived analytically from these
// closed-form relations — nothing is loaded from disk or the network.
const P = 101.325; // total atmospheric pressure, kPa

// Saturation vapour pressure over water (kPa), Tetens approximation, T in °C
const pws = (T) => 0.61078 * Math.exp((17.27 * T) / (T + 237.3));

// Humidity ratio (g water / kg dry air) from a vapour pressure (kPa)
const wFromPw = (pw) => (621.945 * pw) / (P - pw);

// W along a constant relative-humidity curve (rh as a fraction 0–1)
const wFromRH = (T, rh) => wFromPw(rh * pws(T));

// Saturation humidity ratio (100 % RH)
const wSat = (T) => wFromPw(pws(T));

// W along a constant wet-bulb line (ASHRAE psychrometric energy balance)
const wFromWetBulb = (Tdb, Twb) => {
  const wsWb = wSat(Twb) / 1000; // kg/kg at saturation, evaluated at T_wb
  const w =
    ((2501 - 2.326 * Twb) * wsWb - 1.006 * (Tdb - Twb)) /
    (2501 + 1.86 * Tdb - 4.186 * Twb);
  return w * 1000;
};

// W along a constant-enthalpy line, h in kJ/kg dry air
const wFromEnthalpy = (T, h) => ((h - 1.006 * T) / (2501 + 1.86 * T)) * 1000;

// W along a constant specific-volume line, v in m³/kg dry air
const wFromVolume = (T, v) =>
  (((v * P) / (0.287042 * (T + 273.15)) - 1) / 1.607858) * 1000;

// ── Domain ───────────────────────────────────────────────────────────────────
const T_MIN = -10;
const T_MAX = 50;
const W_MIN = 0;
const W_MAX = 30; // g/kg — caps the chart; the saturation curve exits the top

// Property-line families to draw
const rhValues = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
const wetBulbValues = [-5, 0, 5, 10, 15, 20, 25, 30];
const enthalpyValues = [20, 40, 60, 80, 100];
const volumeValues = [0.78, 0.82, 0.86, 0.9, 0.94];

// ── Theme-mapped Imprint colours ─────────────────────────────────────────────
const RH_COLOR = t.palette[0]; // brand green — primary family
const WB_COLOR = t.palette[2]; // blue   — wet-bulb (evaporative cooling / water)
const ENTH_COLOR = t.palette[4]; // red    — enthalpy (energy / heat)
const VOL_COLOR = t.palette[3]; // ochre  — specific volume
const COMFORT_COLOR = t.palette[1]; // lavender — comfort zone
const PROCESS_COLOR = t.ink; // neutral ink — HVAC process path

// ── Layout ───────────────────────────────────────────────────────────────────
const margin = { top: 92, right: 158, bottom: 82, left: 70 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;

const x = d3.scaleLinear().domain([T_MIN, T_MAX]).range([0, iw]);
const y = d3.scaleLinear().domain([W_MIN, W_MAX]).range([ih, 0]);

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

// Clip so property lines that leave the saturation envelope are trimmed cleanly
const defs = svg.append("defs");
defs
  .append("clipPath")
  .attr("id", "plotClip")
  .append("rect")
  .attr("width", iw)
  .attr("height", ih);

defs
  .append("marker")
  .attr("id", "processArrow")
  .attr("viewBox", "0 0 10 10")
  .attr("refX", 8)
  .attr("refY", 5)
  .attr("markerWidth", 7)
  .attr("markerHeight", 7)
  .attr("orient", "auto-start-reverse")
  .append("path")
  .attr("d", "M0,0 L10,5 L0,10 Z")
  .attr("fill", PROCESS_COLOR);

// The moist-air region lives below the saturation curve; wet-bulb, enthalpy and
// specific-volume lines are only physical there, so clip them to it.
const satClipPts = [[x(T_MIN), ih]];
for (let T = T_MIN; T <= T_MAX + 1e-9; T += 0.4) {
  satClipPts.push([x(T), y(Math.min(wSat(T), W_MAX))]);
}
satClipPts.push([x(T_MAX), ih]);
defs
  .append("clipPath")
  .attr("id", "satClip")
  .append("path")
  .attr("d", "M" + satClipPts.map((p) => p.join(",")).join("L") + "Z");

const g = svg
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);
const plot = g.append("g").attr("clip-path", "url(#plotClip)");
const moist = plot.append("g").attr("clip-path", "url(#satClip)");

// ── Reference grid (subtle) ──────────────────────────────────────────────────
const xTicks = d3.range(T_MIN, T_MAX + 1, 5);
const yTicks = d3.range(W_MIN, W_MAX + 1, 5);
plot
  .append("g")
  .selectAll("line")
  .data(xTicks)
  .join("line")
  .attr("x1", (d) => x(d))
  .attr("x2", (d) => x(d))
  .attr("y1", 0)
  .attr("y2", ih)
  .attr("stroke", t.grid)
  .attr("stroke-width", 0.8);
plot
  .append("g")
  .selectAll("line")
  .data(yTicks)
  .join("line")
  .attr("x1", 0)
  .attr("x2", iw)
  .attr("y1", (d) => y(d))
  .attr("y2", (d) => y(d))
  .attr("stroke", t.grid)
  .attr("stroke-width", 0.8);

// ── Sampling + curve helpers ─────────────────────────────────────────────────
const sample = (fn, step = 0.4) => {
  const pts = [];
  for (let T = T_MIN; T <= T_MAX + 1e-9; T += step) {
    const W = fn(T);
    if (Number.isFinite(W)) pts.push({ T, W });
  }
  return pts;
};

const lineGen = d3
  .line()
  .x((d) => x(d.T))
  .y((d) => y(d.W))
  .curve(d3.curveMonotoneX);

const drawCurve = (pts, color, width, dash, opacity, group = plot) =>
  group
    .append("path")
    .datum(pts)
    .attr("d", lineGen)
    .attr("fill", "none")
    .attr("stroke", color)
    .attr("stroke-width", width)
    .attr("stroke-dasharray", dash)
    .attr("stroke-opacity", opacity)
    .attr("stroke-linecap", "round");

// In-bounds sample point whose W sits closest to a target — used to anchor
// labels. `belowSat` keeps anchors inside the moist-air region (below 100 % RH).
const anchorAtW = (pts, targetW, pad = 1, belowSat = false) => {
  let best = null;
  let bestD = Infinity;
  for (const p of pts) {
    if (p.W < W_MIN + pad || p.W > W_MAX - pad) continue;
    if (p.T < T_MIN + pad || p.T > T_MAX - pad) continue;
    if (belowSat && p.W > wSat(p.T) - 0.6) continue;
    const d = Math.abs(p.W - targetW);
    if (d < bestD) {
      bestD = d;
      best = p;
    }
  }
  return best;
};

// Highest valid point still below saturation — anchors a diagonal family's label
// up near where the line meets the saturation curve (classic chart placement).
const anchorNearSat = (pts, pad = 1) => {
  let best = null;
  for (const p of pts) {
    if (p.W < W_MIN + pad || p.W > W_MAX - pad) continue;
    if (p.T < T_MIN + pad || p.T > T_MAX - pad) continue;
    if (p.W > wSat(p.T) - 0.6) continue;
    if (!best || p.W > best.W) best = p;
  }
  return best;
};

// Local pixel-space tangent angle (degrees) at a temperature, for rotated labels
const tangentDeg = (fn, T) => {
  const dx = x(T + 1) - x(T - 1);
  const dy = y(fn(T + 1)) - y(fn(T - 1));
  return (Math.atan2(dy, dx) * 180) / Math.PI;
};

const halo = (sel) =>
  sel
    .attr("stroke", t.pageBg)
    .attr("stroke-width", 3.2)
    .attr("paint-order", "stroke")
    .attr("stroke-linejoin", "round");

// ── Specific-volume lines (drawn first, behind everything) ───────────────────
volumeValues.forEach((v) => {
  const pts = sample((T) => wFromVolume(T, v));
  drawCurve(pts, VOL_COLOR, 1.1, "1 4", 0.7, moist);
  const a = anchorAtW(pts, 3, 1.5, true);
  if (a) {
    halo(
      plot
        .append("text")
        .attr("transform", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromVolume(T, v), a.T)})`)
        .attr("text-anchor", "middle")
        .attr("dy", -4)
        .attr("fill", VOL_COLOR)
        .style("font-size", "12px")
        .text(v.toFixed(2)),
    );
  }
});

// ── Constant-enthalpy lines ──────────────────────────────────────────────────
enthalpyValues.forEach((h) => {
  const pts = sample((T) => wFromEnthalpy(T, h));
  drawCurve(pts, ENTH_COLOR, 1.1, "5 4", 0.62, moist);
  const a = anchorNearSat(pts, 1.5);
  if (a) {
    halo(
      plot
        .append("text")
        .attr("transform", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromEnthalpy(T, h), a.T)})`)
        .attr("text-anchor", "middle")
        .attr("dy", -4)
        .attr("fill", ENTH_COLOR)
        .style("font-size", "12px")
        .text(h),
    );
  }
});

// ── Constant wet-bulb lines ──────────────────────────────────────────────────
wetBulbValues.forEach((twb) => {
  const pts = sample((T) => (T >= twb ? wFromWetBulb(T, twb) : NaN)).filter(
    (p) => Number.isFinite(p.W),
  );
  if (pts.length < 2) return;
  drawCurve(pts, WB_COLOR, 1.1, null, 0.6, moist);
  const a = anchorNearSat(pts, 1.5);
  if (a) {
    halo(
      plot
        .append("text")
        .attr("transform", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromWetBulb(T, twb), a.T)})`)
        .attr("text-anchor", "middle")
        .attr("dy", -4)
        .attr("fill", WB_COLOR)
        .style("font-size", "12px")
        .text(twb),
    );
  }
});

// ── Comfort zone (≈20–26 °C, 30–60 % RH) — bounded by RH curves ──────────────
const comfortPts = [];
for (let T = 20; T <= 26.001; T += 0.5) comfortPts.push([x(T), y(wFromRH(T, 0.3))]);
for (let rh = 0.3; rh <= 0.601; rh += 0.05) comfortPts.push([x(26), y(wFromRH(26, rh))]);
for (let T = 26; T >= 19.999; T -= 0.5) comfortPts.push([x(T), y(wFromRH(T, 0.6))]);
for (let rh = 0.6; rh >= 0.299; rh -= 0.05) comfortPts.push([x(20), y(wFromRH(20, rh))]);
plot
  .append("path")
  .attr("d", "M" + comfortPts.map((p) => p.join(",")).join("L") + "Z")
  .attr("fill", COMFORT_COLOR)
  .attr("fill-opacity", 0.16)
  .attr("stroke", COMFORT_COLOR)
  .attr("stroke-width", 2)
  .attr("stroke-opacity", 0.9);
halo(
  plot
    .append("text")
    .attr("x", x(23))
    .attr("y", y(wFromRH(23, 0.45)))
    .attr("text-anchor", "middle")
    .attr("fill", COMFORT_COLOR)
    .style("font-size", "13px")
    .style("font-weight", "700")
    .text("Comfort"),
);

// ── Relative-humidity curves (10–100 %); saturation is prominent ─────────────
rhValues.forEach((rh) => {
  const frac = rh / 100;
  const pts = sample((T) => wFromRH(T, frac));
  const saturation = rh === 100;
  drawCurve(
    pts,
    RH_COLOR,
    saturation ? 3.6 : 1.5,
    null,
    saturation ? 1 : 0.55,
  );
  // RH labels ride up each curve; stagger by targeting progressively higher W
  const targetW = saturation ? 24 : Math.min(26, 6 + rh * 0.18);
  const a = anchorAtW(pts, targetW, saturation ? 2 : 1.2);
  if (a) {
    halo(
      plot
        .append("text")
        .attr("transform", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromRH(T, frac), a.T)})`)
        .attr("text-anchor", "middle")
        .attr("dy", -5)
        .attr("fill", RH_COLOR)
        .style("font-size", saturation ? "15px" : "12.5px")
        .style("font-weight", saturation ? "700" : "600")
        .text(saturation ? "100% (Saturation)" : rh + "%"),
    );
  }
});

// ── Example HVAC process: cooling & dehumidification ─────────────────────────
const state1 = { T: 30, rh: 0.55 };
const state2 = { T: 13, rh: 0.9 };
const p1 = { x: x(state1.T), y: y(wFromRH(state1.T, state1.rh)) };
const p2 = { x: x(state2.T), y: y(wFromRH(state2.T, state2.rh)) };
plot
  .append("line")
  .attr("x1", p1.x)
  .attr("y1", p1.y)
  .attr("x2", p2.x)
  .attr("y2", p2.y)
  .attr("stroke", PROCESS_COLOR)
  .attr("stroke-width", 3.2)
  .attr("marker-end", "url(#processArrow)");
[
  { p: p1, label: "1" },
  { p: p2, label: "2" },
].forEach(({ p, label }) => {
  plot
    .append("circle")
    .attr("cx", p.x)
    .attr("cy", p.y)
    .attr("r", 6)
    .attr("fill", PROCESS_COLOR)
    .attr("stroke", t.pageBg)
    .attr("stroke-width", 2);
  halo(
    plot
      .append("text")
      .attr("x", p.x + 11)
      .attr("y", p.y + 5)
      .attr("fill", t.ink)
      .style("font-size", "14px")
      .style("font-weight", "700")
      .text(label),
  );
});

// ── Axes ─────────────────────────────────────────────────────────────────────
const axB = g
  .append("g")
  .attr("transform", `translate(0,${ih})`)
  .call(d3.axisBottom(x).tickValues(xTicks).tickSize(6).tickFormat(d3.format("d")));
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);

const axR = g
  .append("g")
  .attr("transform", `translate(${iw},0)`)
  .call(d3.axisRight(y).tickValues(yTicks).tickSize(6).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("x", iw / 2)
  .attr("y", ih + 52)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Dry-Bulb Temperature (°C)");

g.append("text")
  .attr("transform", `translate(${iw + 70},${ih / 2}) rotate(90)`)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "16px")
  .text("Humidity Ratio (g water / kg dry air)");

// ── Legend (upper-left, in the impossible region above saturation) ───────────
const legend = g.append("g").attr("transform", `translate(8,6)`);
const legendItems = [
  { color: RH_COLOR, dash: null, w: 3, label: "Relative humidity (%)" },
  { color: WB_COLOR, dash: null, w: 2, label: "Wet-bulb temp (°C)" },
  { color: ENTH_COLOR, dash: "5 4", w: 2, label: "Enthalpy (kJ/kg)" },
  { color: VOL_COLOR, dash: "1 4", w: 2, label: "Specific volume (m³/kg)" },
  { color: COMFORT_COLOR, dash: null, w: 2, label: "Comfort zone", swatch: true },
  { color: PROCESS_COLOR, dash: null, w: 3, label: "Process: cool + dehumidify", arrow: true },
];
legendItems.forEach((it, i) => {
  const yy = i * 26 + 4;
  if (it.swatch) {
    legend
      .append("rect")
      .attr("x", 0)
      .attr("y", yy - 8)
      .attr("width", 34)
      .attr("height", 14)
      .attr("fill", it.color)
      .attr("fill-opacity", 0.16)
      .attr("stroke", it.color)
      .attr("stroke-width", 1.5);
  } else {
    legend
      .append("line")
      .attr("x1", 0)
      .attr("x2", 34)
      .attr("y1", yy)
      .attr("y2", yy)
      .attr("stroke", it.color)
      .attr("stroke-width", it.w)
      .attr("stroke-dasharray", it.dash)
      .attr("marker-end", it.arrow ? "url(#processArrow)" : null);
  }
  legend
    .append("text")
    .attr("x", 44)
    .attr("y", yy + 5)
    .attr("fill", t.inkSoft)
    .style("font-size", "13.5px")
    .text(it.label);
});

// ── Title ────────────────────────────────────────────────────────────────────
svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 46)
  .attr("text-anchor", "middle")
  .attr("fill", t.ink)
  .style("font-size", "22px")
  .style("font-weight", "600")
  .text("psychrometric-basic · javascript · d3 · anyplot.ai");

svg
  .append("text")
  .attr("x", width / 2)
  .attr("y", 70)
  .attr("text-anchor", "middle")
  .attr("fill", t.inkSoft)
  .style("font-size", "14px")
  .text("Moist air at sea-level pressure (101.325 kPa) — ASHRAE properties");

Part of Psychrometric Chart for HVAC on anyplot.ai.

Other implementations