Psychrometric Chart for HVAC — MUI X Charts

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 MUI X Charts

JavaScript source (MUI X Charts)

// anyplot.ai
// psychrometric-basic: Psychrometric Chart for HVAC
// Library: muix 7.29.1 | JavaScript 22.22.3
// Quality: 90/100 | Created: 2026-06-16
//# anyplot-orientation: landscape
// anyplot.ai
// psychrometric-basic: Psychrometric Chart for HVAC
// Library: MUI X Charts | React | Node 22
// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.
// Quality: pending | Created: 2026-06-16
import { LineChart } from "@mui/x-charts/LineChart";
import { useDrawingArea, useXScale, useYScale } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;
const SIZE = window.ANYPLOT_SIZE;

// Imprint palette roles (data colours are theme-independent) ------------------
const C_SAT = t.palette[0]; // brand green  — saturation (100% RH), first series
const C_RH = t.palette[2]; // blue          — relative-humidity curves
const C_WB = t.palette[5]; // cyan          — wet-bulb lines
const C_ENT = t.palette[3]; // ochre         — enthalpy lines
const C_VOL = t.palette[1]; // lavender      — specific-volume lines
const C_PROC = t.palette[4]; // matte red     — example HVAC process path
const INK = t.ink;
const INK_SOFT = t.inkSoft;

// --- Psychrometrics @ sea level (ASHRAE, 101.325 kPa) -----------------------
const P_PA = 101325; // total pressure, Pa
const Y_MAX = 30; // humidity-ratio axis ceiling, g/kg dry air
const X_MIN = -10;
const X_MAX = 50;

// Saturation vapour pressure over water (Alduchov–Eskridge), Pa
const pws = (tc) => 610.94 * Math.exp((17.625 * tc) / (tc + 243.04));
// Humidity ratio (kg/kg) at dry-bulb tc and relative humidity rh (fraction)
const wFrac = (tc, rh) => {
  const pw = rh * pws(tc);
  return (0.621945 * pw) / (P_PA - pw);
};
const wSat = (tc) => wFrac(tc, 1); // kg/kg on the saturation line
const gkg = (wkg) => wkg * 1000; // kg/kg → g/kg

// Humidity ratio along a constant wet-bulb line (ASHRAE), kg/kg
const wWetBulb = (tc, twb) => {
  const wStar = wSat(twb);
  const num = (2501 - 2.326 * twb) * wStar - 1.006 * (tc - twb);
  const den = 2501 + 1.86 * tc - 4.186 * twb;
  return num / den;
};
// Humidity ratio along a constant moist-air enthalpy line (kJ/kg), kg/kg
const wEnthalpy = (tc, h) => (h - 1.006 * tc) / (2501 + 1.86 * tc);
// Humidity ratio along a constant specific-volume line (m3/kg dry air), kg/kg
const wVolume = (tc, v) => {
  const tk = tc + 273.15;
  return ((v * 101.325) / (0.287042 * tk) - 1) / 1.607858;
};

// --- Shared dry-bulb grid (the line series' common x positions) -------------
const temps = [];
for (let tc = X_MIN; tc <= X_MAX + 1e-9; tc += 0.5) temps.push(Math.round(tc * 2) / 2);

// Clip a g/kg value to the visible, physically-valid envelope (below
// saturation, inside the axis box) — otherwise NaN/null breaks the line.
const clip = (wg, tc) => {
  if (!Number.isFinite(wg) || wg < 0 || wg > Y_MAX) return null;
  if (wg > gkg(wSat(tc)) + 1e-6) return null; // never above the saturation curve
  return wg;
};
const lineFor = (fn) => temps.map((tc) => clip(gkg(fn(tc)), tc));

// --- Build the property-line series -----------------------------------------
const RH_LEVELS = [10, 20, 30, 40, 50, 60, 70, 80, 90]; // %, saturation drawn separately
const WB_LEVELS = [5, 10, 15, 20, 25, 30]; // °C
const ENT_LEVELS = [20, 40, 60, 80, 100]; // kJ/kg
const VOL_LEVELS = [0.8, 0.85, 0.9, 0.95]; // m³/kg dry air

const mk = (id, color, data) => ({
  id,
  type: "line",
  data,
  color,
  curve: "monotoneX",
  showMark: false,
  disableHighlight: true,
  connectNulls: false,
});

const series = [
  // Saturation curve (100% RH) — visually dominant, brand green, drawn first.
  mk("sat", C_SAT, lineFor((tc) => wSat(tc))),
  ...RH_LEVELS.map((rh) => mk(`rh-${rh}`, C_RH, lineFor((tc) => wFrac(tc, rh / 100)))),
  ...WB_LEVELS.map((twb) =>
    mk(`wb-${twb}`, C_WB, temps.map((tc) => (tc < twb ? null : clip(gkg(wWetBulb(tc, twb)), tc)))),
  ),
  ...ENT_LEVELS.map((h) => mk(`ent-${h}`, C_ENT, lineFor((tc) => wEnthalpy(tc, h)))),
  ...VOL_LEVELS.map((v) => mk(`vol-${v}`, C_VOL, lineFor((tc) => wVolume(tc, v)))),
];

// Per-family stroke styling (MUI X has no per-series dash prop → target the
// generated `.MuiLineElement-series-<id>` classes via the chart `sx`).
const familyStyle = {
  sat: { strokeWidth: 3.6 },
  rh: { strokeWidth: 1.7, opacity: 0.9 },
  wb: { strokeWidth: 1.3, opacity: 0.8, strokeDasharray: "8 5" },
  ent: { strokeWidth: 1.3, opacity: 0.8, strokeDasharray: "12 4 3 4" },
  vol: { strokeWidth: 1.1, opacity: 0.75, strokeDasharray: "2 5" },
};
const lineSx = { "& .MuiMarkElement-root": { display: "none" } };
for (const s of series) {
  const fam = s.id.split("-")[0];
  lineSx[`& .MuiLineElement-series-${s.id}`] = familyStyle[fam];
}

// --- Direct-on-chart labels (data-space coords; positioned via the scales) ---
// Find the dry-bulb temperature where a curve reaches a target humidity ratio.
const tempForW = (rhFrac, targetWg) => {
  let best = X_MIN;
  let bestD = Infinity;
  for (let tc = X_MIN; tc <= X_MAX; tc += 0.25) {
    const d = Math.abs(gkg(wFrac(tc, rhFrac)) - targetWg);
    if (d < bestD) {
      bestD = d;
      best = tc;
    }
  }
  return best;
};

// RH curve labels, spread vertically so they sit along their own curve.
const RH_LABELS = [
  { rh: 80, w: 23 },
  { rh: 60, w: 18 },
  { rh: 40, w: 12 },
  { rh: 20, w: 6.5 },
].map(({ rh, w }) => ({
  tc: tempForW(rh / 100, w),
  w,
  text: `${rh}%`,
  color: C_RH,
  anchor: "middle",
  dx: 0,
  dy: -7,
  fs: 14,
  weight: 600,
}));

// Wet-bulb values, placed at each line's origin on the saturation curve.
const WB_LABELS = [10, 15, 20, 25].map((twb) => ({
  tc: twb,
  w: gkg(wSat(twb)),
  text: `${twb}°`,
  color: C_WB,
  anchor: "end",
  dx: -6,
  dy: -3,
  fs: 13.5,
  weight: 600,
}));

// Enthalpy values, offset further up-left of the saturation curve so they read
// as the outer oblique enthalpy scale (the classic psychrometric convention).
const ENT_LABELS = [40, 60, 80].map((h) => {
  // origin on the saturation curve: solve h_sat(tc) = h by scanning
  let originT = 0;
  let bestD = Infinity;
  for (let tt = X_MIN; tt <= X_MAX; tt += 0.1) {
    const hSat = 1.006 * tt + wSat(tt) * (2501 + 1.86 * tt);
    const d = Math.abs(hSat - h);
    if (d < bestD) {
      bestD = d;
      originT = tt;
    }
  }
  return {
    tc: originT,
    w: gkg(wSat(originT)),
    text: `${h}`,
    color: C_ENT,
    anchor: "end",
    dx: -16,
    dy: -11,
    fs: 13.5,
    weight: 600,
  };
});

// Specific-volume values, placed at each line's lower-right visible end.
const VOL_LABELS = VOL_LEVELS.slice(1).map((v) => {
  // last temperature on the grid where the volume line is still inside the box
  let endT = X_MAX;
  for (let i = temps.length - 1; i >= 0; i--) {
    const wg = clip(gkg(wVolume(temps[i], v)), temps[i]);
    if (wg != null) {
      endT = temps[i];
      break;
    }
  }
  return {
    tc: endT,
    w: clip(gkg(wVolume(endT, v)), endT) ?? 0,
    text: `${v.toFixed(2)}`,
    color: C_VOL,
    anchor: "start",
    dx: 5,
    dy: 13,
    fs: 13,
    weight: 600,
  };
});

// One green caption sits directly on the saturation curve; the per-family
// colour meanings are clarified by the compact key (see KEY_ITEMS / Overlay).
const SAT_LABEL = {
  tc: tempForW(0.96, 25),
  w: 25.5,
  text: "Saturation · 100% RH",
  color: C_SAT,
  anchor: "end",
  dx: -8,
  dy: 0,
  fs: 15,
  weight: 700,
};

const ALL_LABELS = [...RH_LABELS, ...WB_LABELS, ...ENT_LABELS, ...VOL_LABELS, SAT_LABEL];

// Compact colour key, drawn in the empty upper-left wedge above the saturation
// curve. Each property line is *also* labelled directly with its value on-chart.
const KEY_ITEMS = [
  { color: C_SAT, dash: null, text: "Saturation (100% RH)" },
  { color: C_RH, dash: null, text: "Relative humidity" },
  { color: C_WB, dash: "8 5", text: "Wet-bulb (°C)" },
  { color: C_ENT, dash: "12 4 3 4", text: "Enthalpy (kJ/kg)" },
  { color: C_VOL, dash: "2 5", text: "Specific volume (m³/kg)" },
];

// --- Comfort zone (≈20–26 °C, 30–60 % RH) as a smooth filled polygon --------
const comfortTemps = [];
for (let tc = 20; tc <= 26 + 1e-9; tc += 0.5) comfortTemps.push(tc);
const comfortTop = comfortTemps.map((tc) => ({ tc, w: gkg(wFrac(tc, 0.6)) }));
const comfortBot = comfortTemps.map((tc) => ({ tc, w: gkg(wFrac(tc, 0.3)) })).reverse();
const comfortPoly = [...comfortTop, ...comfortBot];
const comfortCenter = { tc: 23, w: gkg(wFrac(23, 0.45)) };

// --- Example HVAC process: cooling & dehumidification (state 1 → state 2) ----
const P1 = { tc: 30, w: gkg(wFrac(30, 0.5)) };
const P2 = { tc: 14, w: gkg(wFrac(14, 0.9)) };

// X-axis ticks every 5 °C.
const xTicks = temps.filter((tc) => Number.isInteger(tc) && tc % 5 === 0);

// ---------------------------------------------------------------------------
// Overlay drawn inside the chart SVG: comfort zone, process path, direct
// labels and title. Uses the chart scales so everything tracks the axes.
function Overlay() {
  const xs = useXScale();
  const ys = useYScale();
  const area = useDrawingArea();

  const px = (d) => xs(d.tc);
  const py = (d) => ys(d.w);

  const polyPts = comfortPoly.map((d) => `${px(d)},${py(d)}`).join(" ");

  // Process arrow geometry (pixel space).
  const x1 = px(P1);
  const y1 = py(P1);
  const x2 = px(P2);
  const y2 = py(P2);
  const ang = Math.atan2(y2 - y1, x2 - x1);
  const ah = 16;
  const aw = 8;
  const head = [
    [x2, y2],
    [x2 - ah * Math.cos(ang) + aw * Math.sin(ang), y2 - ah * Math.sin(ang) - aw * Math.cos(ang)],
    [x2 - ah * Math.cos(ang) - aw * Math.sin(ang), y2 - ah * Math.sin(ang) + aw * Math.cos(ang)],
  ]
    .map((p) => p.join(","))
    .join(" ");

  // Colour key anchored in the empty upper-left wedge (data-space top-left).
  const keyX = xs(X_MIN) + 14;
  const keyY0 = ys(28.5);
  const keyDY = 22;

  return (
    <g>
      {/* Colour key */}
      {KEY_ITEMS.map((k, i) => {
        const ky = keyY0 + i * keyDY;
        return (
          <g key={`key-${i}`}>
            <line
              x1={keyX}
              y1={ky}
              x2={keyX + 30}
              y2={ky}
              stroke={k.color}
              strokeWidth={k.dash ? 2.4 : 3.4}
              strokeDasharray={k.dash ?? undefined}
              strokeLinecap="round"
            />
            <text x={keyX + 38} y={ky + 4.5} fill={INK_SOFT} fontSize={13.5} fontWeight={500} textAnchor="start">
              {k.text}
            </text>
          </g>
        );
      })}

      {/* Comfort zone */}
      <polygon points={polyPts} fill={C_SAT} fillOpacity={0.16} stroke={C_SAT} strokeOpacity={0.55} strokeWidth={1.5} strokeDasharray="6 4" />
      <text x={xs(comfortCenter.tc)} y={ys(comfortCenter.w)} fill={INK} fontSize={13} fontWeight={700} textAnchor="middle">
        Comfort
      </text>
      <text x={xs(comfortCenter.tc)} y={ys(comfortCenter.w) + 16} fill={INK_SOFT} fontSize={11.5} textAnchor="middle">
        20–26 °C · 30–60% RH
      </text>

      {/* Direct property-line labels */}
      {ALL_LABELS.map((l, i) => (
        <text
          key={i}
          x={xs(l.tc) + l.dx}
          y={ys(l.w) + l.dy}
          fill={l.color}
          fontSize={l.fs}
          fontWeight={l.weight}
          textAnchor={l.anchor}
        >
          {l.text}
        </text>
      ))}

      {/* HVAC process path: cooling & dehumidification */}
      <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={C_PROC} strokeWidth={3} strokeLinecap="round" />
      <polygon points={head} fill={C_PROC} />
      <circle cx={x1} cy={y1} r={5.5} fill={C_PROC} stroke={t.pageBg} strokeWidth={1.5} />
      <circle cx={x2} cy={y2} r={5.5} fill={C_PROC} stroke={t.pageBg} strokeWidth={1.5} />
      <text x={x1 + 10} y={y1 - 8} fill={C_PROC} fontSize={13} fontWeight={700} textAnchor="start">
        1
      </text>
      <text x={x2 - 10} y={y2 + 18} fill={C_PROC} fontSize={13} fontWeight={700} textAnchor="end">
        2
      </text>
      <text x={(x1 + x2) / 2 + 14} y={(y1 + y2) / 2 - 6} fill={C_PROC} fontSize={12.5} fontWeight={600} textAnchor="start">
        Cooling &amp; dehumidification
      </text>

      {/* Title */}
      <text x={area.left + area.width / 2} y={area.top - 26} fill={INK} fontSize={23} fontWeight={700} textAnchor="middle">
        psychrometric-basic · javascript · muix · anyplot.ai
      </text>
    </g>
  );
}

// --- Chart (default-exported component — the harness mounts it) -------------
export default function Chart() {
  return (
    <LineChart
      width={SIZE.width}
      height={SIZE.height}
      margin={{ top: 72, right: 108, bottom: 70, left: 64 }}
      skipAnimation
      disableAxisListener
      series={series}
      grid={{ horizontal: true, vertical: true }}
      leftAxis={null}
      rightAxis="hum"
      slotProps={{ legend: { hidden: true }, tooltip: { trigger: "none" } }}
      sx={lineSx}
      xAxis={[
        {
          data: temps,
          scaleType: "linear",
          min: X_MIN,
          max: X_MAX,
          domainLimit: "strict",
          tickInterval: xTicks,
          valueFormatter: (v) => `${v}`,
          label: "Dry-Bulb Temperature (°C)",
          labelStyle: { fontSize: 18, fill: INK, fontWeight: 600 },
          tickLabelStyle: { fontSize: 14, fill: INK_SOFT },
        },
      ]}
      yAxis={[
        {
          id: "hum",
          min: 0,
          max: Y_MAX,
          domainLimit: "strict",
          tickInterval: [0, 5, 10, 15, 20, 25, 30],
          label: "Humidity Ratio (g water / kg dry air)",
          labelStyle: { fontSize: 18, fill: INK, fontWeight: 600 },
          tickLabelStyle: { fontSize: 14, fill: INK_SOFT },
        },
      ]}
    >
      <Overlay />
    </LineChart>
  );
}

Part of Psychrometric Chart for HVAC on anyplot.ai.

Other implementations