{"spec_id":"psychrometric-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: muix 7.29.1 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-16\n//# anyplot-orientation: landscape\n// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: MUI X Charts | React | Node 22\n// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.\n// Quality: pending | Created: 2026-06-16\nimport { LineChart } from \"@mui/x-charts/LineChart\";\nimport { useDrawingArea, useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst SIZE = window.ANYPLOT_SIZE;\n\n// Imprint palette roles (data colours are theme-independent) ------------------\nconst C_SAT = t.palette[0]; // brand green  — saturation (100% RH), first series\nconst C_RH = t.palette[2]; // blue          — relative-humidity curves\nconst C_WB = t.palette[5]; // cyan          — wet-bulb lines\nconst C_ENT = t.palette[3]; // ochre         — enthalpy lines\nconst C_VOL = t.palette[1]; // lavender      — specific-volume lines\nconst C_PROC = t.palette[4]; // matte red     — example HVAC process path\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\n\n// --- Psychrometrics @ sea level (ASHRAE, 101.325 kPa) -----------------------\nconst P_PA = 101325; // total pressure, Pa\nconst Y_MAX = 30; // humidity-ratio axis ceiling, g/kg dry air\nconst X_MIN = -10;\nconst X_MAX = 50;\n\n// Saturation vapour pressure over water (Alduchov–Eskridge), Pa\nconst pws = (tc) => 610.94 * Math.exp((17.625 * tc) / (tc + 243.04));\n// Humidity ratio (kg/kg) at dry-bulb tc and relative humidity rh (fraction)\nconst wFrac = (tc, rh) => {\n  const pw = rh * pws(tc);\n  return (0.621945 * pw) / (P_PA - pw);\n};\nconst wSat = (tc) => wFrac(tc, 1); // kg/kg on the saturation line\nconst gkg = (wkg) => wkg * 1000; // kg/kg → g/kg\n\n// Humidity ratio along a constant wet-bulb line (ASHRAE), kg/kg\nconst wWetBulb = (tc, twb) => {\n  const wStar = wSat(twb);\n  const num = (2501 - 2.326 * twb) * wStar - 1.006 * (tc - twb);\n  const den = 2501 + 1.86 * tc - 4.186 * twb;\n  return num / den;\n};\n// Humidity ratio along a constant moist-air enthalpy line (kJ/kg), kg/kg\nconst wEnthalpy = (tc, h) => (h - 1.006 * tc) / (2501 + 1.86 * tc);\n// Humidity ratio along a constant specific-volume line (m3/kg dry air), kg/kg\nconst wVolume = (tc, v) => {\n  const tk = tc + 273.15;\n  return ((v * 101.325) / (0.287042 * tk) - 1) / 1.607858;\n};\n\n// --- Shared dry-bulb grid (the line series' common x positions) -------------\nconst temps = [];\nfor (let tc = X_MIN; tc <= X_MAX + 1e-9; tc += 0.5) temps.push(Math.round(tc * 2) / 2);\n\n// Clip a g/kg value to the visible, physically-valid envelope (below\n// saturation, inside the axis box) — otherwise NaN/null breaks the line.\nconst clip = (wg, tc) => {\n  if (!Number.isFinite(wg) || wg < 0 || wg > Y_MAX) return null;\n  if (wg > gkg(wSat(tc)) + 1e-6) return null; // never above the saturation curve\n  return wg;\n};\nconst lineFor = (fn) => temps.map((tc) => clip(gkg(fn(tc)), tc));\n\n// --- Build the property-line series -----------------------------------------\nconst RH_LEVELS = [10, 20, 30, 40, 50, 60, 70, 80, 90]; // %, saturation drawn separately\nconst WB_LEVELS = [5, 10, 15, 20, 25, 30]; // °C\nconst ENT_LEVELS = [20, 40, 60, 80, 100]; // kJ/kg\nconst VOL_LEVELS = [0.8, 0.85, 0.9, 0.95]; // m³/kg dry air\n\nconst mk = (id, color, data) => ({\n  id,\n  type: \"line\",\n  data,\n  color,\n  curve: \"monotoneX\",\n  showMark: false,\n  disableHighlight: true,\n  connectNulls: false,\n});\n\nconst series = [\n  // Saturation curve (100% RH) — visually dominant, brand green, drawn first.\n  mk(\"sat\", C_SAT, lineFor((tc) => wSat(tc))),\n  ...RH_LEVELS.map((rh) => mk(`rh-${rh}`, C_RH, lineFor((tc) => wFrac(tc, rh / 100)))),\n  ...WB_LEVELS.map((twb) =>\n    mk(`wb-${twb}`, C_WB, temps.map((tc) => (tc < twb ? null : clip(gkg(wWetBulb(tc, twb)), tc)))),\n  ),\n  ...ENT_LEVELS.map((h) => mk(`ent-${h}`, C_ENT, lineFor((tc) => wEnthalpy(tc, h)))),\n  ...VOL_LEVELS.map((v) => mk(`vol-${v}`, C_VOL, lineFor((tc) => wVolume(tc, v)))),\n];\n\n// Per-family stroke styling (MUI X has no per-series dash prop → target the\n// generated `.MuiLineElement-series-<id>` classes via the chart `sx`).\nconst familyStyle = {\n  sat: { strokeWidth: 3.6 },\n  rh: { strokeWidth: 1.7, opacity: 0.9 },\n  wb: { strokeWidth: 1.3, opacity: 0.8, strokeDasharray: \"8 5\" },\n  ent: { strokeWidth: 1.3, opacity: 0.8, strokeDasharray: \"12 4 3 4\" },\n  vol: { strokeWidth: 1.1, opacity: 0.75, strokeDasharray: \"2 5\" },\n};\nconst lineSx = { \"& .MuiMarkElement-root\": { display: \"none\" } };\nfor (const s of series) {\n  const fam = s.id.split(\"-\")[0];\n  lineSx[`& .MuiLineElement-series-${s.id}`] = familyStyle[fam];\n}\n\n// --- Direct-on-chart labels (data-space coords; positioned via the scales) ---\n// Find the dry-bulb temperature where a curve reaches a target humidity ratio.\nconst tempForW = (rhFrac, targetWg) => {\n  let best = X_MIN;\n  let bestD = Infinity;\n  for (let tc = X_MIN; tc <= X_MAX; tc += 0.25) {\n    const d = Math.abs(gkg(wFrac(tc, rhFrac)) - targetWg);\n    if (d < bestD) {\n      bestD = d;\n      best = tc;\n    }\n  }\n  return best;\n};\n\n// RH curve labels, spread vertically so they sit along their own curve.\nconst RH_LABELS = [\n  { rh: 80, w: 23 },\n  { rh: 60, w: 18 },\n  { rh: 40, w: 12 },\n  { rh: 20, w: 6.5 },\n].map(({ rh, w }) => ({\n  tc: tempForW(rh / 100, w),\n  w,\n  text: `${rh}%`,\n  color: C_RH,\n  anchor: \"middle\",\n  dx: 0,\n  dy: -7,\n  fs: 14,\n  weight: 600,\n}));\n\n// Wet-bulb values, placed at each line's origin on the saturation curve.\nconst WB_LABELS = [10, 15, 20, 25].map((twb) => ({\n  tc: twb,\n  w: gkg(wSat(twb)),\n  text: `${twb}°`,\n  color: C_WB,\n  anchor: \"end\",\n  dx: -6,\n  dy: -3,\n  fs: 13.5,\n  weight: 600,\n}));\n\n// Enthalpy values, offset further up-left of the saturation curve so they read\n// as the outer oblique enthalpy scale (the classic psychrometric convention).\nconst ENT_LABELS = [40, 60, 80].map((h) => {\n  // origin on the saturation curve: solve h_sat(tc) = h by scanning\n  let originT = 0;\n  let bestD = Infinity;\n  for (let tt = X_MIN; tt <= X_MAX; tt += 0.1) {\n    const hSat = 1.006 * tt + wSat(tt) * (2501 + 1.86 * tt);\n    const d = Math.abs(hSat - h);\n    if (d < bestD) {\n      bestD = d;\n      originT = tt;\n    }\n  }\n  return {\n    tc: originT,\n    w: gkg(wSat(originT)),\n    text: `${h}`,\n    color: C_ENT,\n    anchor: \"end\",\n    dx: -16,\n    dy: -11,\n    fs: 13.5,\n    weight: 600,\n  };\n});\n\n// Specific-volume values, placed at each line's lower-right visible end.\nconst VOL_LABELS = VOL_LEVELS.slice(1).map((v) => {\n  // last temperature on the grid where the volume line is still inside the box\n  let endT = X_MAX;\n  for (let i = temps.length - 1; i >= 0; i--) {\n    const wg = clip(gkg(wVolume(temps[i], v)), temps[i]);\n    if (wg != null) {\n      endT = temps[i];\n      break;\n    }\n  }\n  return {\n    tc: endT,\n    w: clip(gkg(wVolume(endT, v)), endT) ?? 0,\n    text: `${v.toFixed(2)}`,\n    color: C_VOL,\n    anchor: \"start\",\n    dx: 5,\n    dy: 13,\n    fs: 13,\n    weight: 600,\n  };\n});\n\n// One green caption sits directly on the saturation curve; the per-family\n// colour meanings are clarified by the compact key (see KEY_ITEMS / Overlay).\nconst SAT_LABEL = {\n  tc: tempForW(0.96, 25),\n  w: 25.5,\n  text: \"Saturation · 100% RH\",\n  color: C_SAT,\n  anchor: \"end\",\n  dx: -8,\n  dy: 0,\n  fs: 15,\n  weight: 700,\n};\n\nconst ALL_LABELS = [...RH_LABELS, ...WB_LABELS, ...ENT_LABELS, ...VOL_LABELS, SAT_LABEL];\n\n// Compact colour key, drawn in the empty upper-left wedge above the saturation\n// curve. Each property line is *also* labelled directly with its value on-chart.\nconst KEY_ITEMS = [\n  { color: C_SAT, dash: null, text: \"Saturation (100% RH)\" },\n  { color: C_RH, dash: null, text: \"Relative humidity\" },\n  { color: C_WB, dash: \"8 5\", text: \"Wet-bulb (°C)\" },\n  { color: C_ENT, dash: \"12 4 3 4\", text: \"Enthalpy (kJ/kg)\" },\n  { color: C_VOL, dash: \"2 5\", text: \"Specific volume (m³/kg)\" },\n];\n\n// --- Comfort zone (≈20–26 °C, 30–60 % RH) as a smooth filled polygon --------\nconst comfortTemps = [];\nfor (let tc = 20; tc <= 26 + 1e-9; tc += 0.5) comfortTemps.push(tc);\nconst comfortTop = comfortTemps.map((tc) => ({ tc, w: gkg(wFrac(tc, 0.6)) }));\nconst comfortBot = comfortTemps.map((tc) => ({ tc, w: gkg(wFrac(tc, 0.3)) })).reverse();\nconst comfortPoly = [...comfortTop, ...comfortBot];\nconst comfortCenter = { tc: 23, w: gkg(wFrac(23, 0.45)) };\n\n// --- Example HVAC process: cooling & dehumidification (state 1 → state 2) ----\nconst P1 = { tc: 30, w: gkg(wFrac(30, 0.5)) };\nconst P2 = { tc: 14, w: gkg(wFrac(14, 0.9)) };\n\n// X-axis ticks every 5 °C.\nconst xTicks = temps.filter((tc) => Number.isInteger(tc) && tc % 5 === 0);\n\n// ---------------------------------------------------------------------------\n// Overlay drawn inside the chart SVG: comfort zone, process path, direct\n// labels and title. Uses the chart scales so everything tracks the axes.\nfunction Overlay() {\n  const xs = useXScale();\n  const ys = useYScale();\n  const area = useDrawingArea();\n\n  const px = (d) => xs(d.tc);\n  const py = (d) => ys(d.w);\n\n  const polyPts = comfortPoly.map((d) => `${px(d)},${py(d)}`).join(\" \");\n\n  // Process arrow geometry (pixel space).\n  const x1 = px(P1);\n  const y1 = py(P1);\n  const x2 = px(P2);\n  const y2 = py(P2);\n  const ang = Math.atan2(y2 - y1, x2 - x1);\n  const ah = 16;\n  const aw = 8;\n  const head = [\n    [x2, y2],\n    [x2 - ah * Math.cos(ang) + aw * Math.sin(ang), y2 - ah * Math.sin(ang) - aw * Math.cos(ang)],\n    [x2 - ah * Math.cos(ang) - aw * Math.sin(ang), y2 - ah * Math.sin(ang) + aw * Math.cos(ang)],\n  ]\n    .map((p) => p.join(\",\"))\n    .join(\" \");\n\n  // Colour key anchored in the empty upper-left wedge (data-space top-left).\n  const keyX = xs(X_MIN) + 14;\n  const keyY0 = ys(28.5);\n  const keyDY = 22;\n\n  return (\n    <g>\n      {/* Colour key */}\n      {KEY_ITEMS.map((k, i) => {\n        const ky = keyY0 + i * keyDY;\n        return (\n          <g key={`key-${i}`}>\n            <line\n              x1={keyX}\n              y1={ky}\n              x2={keyX + 30}\n              y2={ky}\n              stroke={k.color}\n              strokeWidth={k.dash ? 2.4 : 3.4}\n              strokeDasharray={k.dash ?? undefined}\n              strokeLinecap=\"round\"\n            />\n            <text x={keyX + 38} y={ky + 4.5} fill={INK_SOFT} fontSize={13.5} fontWeight={500} textAnchor=\"start\">\n              {k.text}\n            </text>\n          </g>\n        );\n      })}\n\n      {/* Comfort zone */}\n      <polygon points={polyPts} fill={C_SAT} fillOpacity={0.16} stroke={C_SAT} strokeOpacity={0.55} strokeWidth={1.5} strokeDasharray=\"6 4\" />\n      <text x={xs(comfortCenter.tc)} y={ys(comfortCenter.w)} fill={INK} fontSize={13} fontWeight={700} textAnchor=\"middle\">\n        Comfort\n      </text>\n      <text x={xs(comfortCenter.tc)} y={ys(comfortCenter.w) + 16} fill={INK_SOFT} fontSize={11.5} textAnchor=\"middle\">\n        20–26 °C · 30–60% RH\n      </text>\n\n      {/* Direct property-line labels */}\n      {ALL_LABELS.map((l, i) => (\n        <text\n          key={i}\n          x={xs(l.tc) + l.dx}\n          y={ys(l.w) + l.dy}\n          fill={l.color}\n          fontSize={l.fs}\n          fontWeight={l.weight}\n          textAnchor={l.anchor}\n        >\n          {l.text}\n        </text>\n      ))}\n\n      {/* HVAC process path: cooling & dehumidification */}\n      <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={C_PROC} strokeWidth={3} strokeLinecap=\"round\" />\n      <polygon points={head} fill={C_PROC} />\n      <circle cx={x1} cy={y1} r={5.5} fill={C_PROC} stroke={t.pageBg} strokeWidth={1.5} />\n      <circle cx={x2} cy={y2} r={5.5} fill={C_PROC} stroke={t.pageBg} strokeWidth={1.5} />\n      <text x={x1 + 10} y={y1 - 8} fill={C_PROC} fontSize={13} fontWeight={700} textAnchor=\"start\">\n        1\n      </text>\n      <text x={x2 - 10} y={y2 + 18} fill={C_PROC} fontSize={13} fontWeight={700} textAnchor=\"end\">\n        2\n      </text>\n      <text x={(x1 + x2) / 2 + 14} y={(y1 + y2) / 2 - 6} fill={C_PROC} fontSize={12.5} fontWeight={600} textAnchor=\"start\">\n        Cooling &amp; dehumidification\n      </text>\n\n      {/* Title */}\n      <text x={area.left + area.width / 2} y={area.top - 26} fill={INK} fontSize={23} fontWeight={700} textAnchor=\"middle\">\n        psychrometric-basic · javascript · muix · anyplot.ai\n      </text>\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  return (\n    <LineChart\n      width={SIZE.width}\n      height={SIZE.height}\n      margin={{ top: 72, right: 108, bottom: 70, left: 64 }}\n      skipAnimation\n      disableAxisListener\n      series={series}\n      grid={{ horizontal: true, vertical: true }}\n      leftAxis={null}\n      rightAxis=\"hum\"\n      slotProps={{ legend: { hidden: true }, tooltip: { trigger: \"none\" } }}\n      sx={lineSx}\n      xAxis={[\n        {\n          data: temps,\n          scaleType: \"linear\",\n          min: X_MIN,\n          max: X_MAX,\n          domainLimit: \"strict\",\n          tickInterval: xTicks,\n          valueFormatter: (v) => `${v}`,\n          label: \"Dry-Bulb Temperature (°C)\",\n          labelStyle: { fontSize: 18, fill: INK, fontWeight: 600 },\n          tickLabelStyle: { fontSize: 14, fill: INK_SOFT },\n        },\n      ]}\n      yAxis={[\n        {\n          id: \"hum\",\n          min: 0,\n          max: Y_MAX,\n          domainLimit: \"strict\",\n          tickInterval: [0, 5, 10, 15, 20, 25, 30],\n          label: \"Humidity Ratio (g water / kg dry air)\",\n          labelStyle: { fontSize: 18, fill: INK, fontWeight: 600 },\n          tickLabelStyle: { fontSize: 14, fill: INK_SOFT },\n        },\n      ]}\n    >\n      <Overlay />\n    </LineChart>\n  );\n}\n"}