{"spec_id":"psychrometric-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: d3 7.9.0 | JavaScript 22.22.3\n// Quality: 92/100 | Created: 2026-06-16\n//# anyplot-orientation: landscape\n// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: d3 7.9.0 | JavaScript 22\n// Quality: pending | Created: 2026-06-16\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// ── Moist-air psychrometrics at sea-level pressure (ASHRAE, 101.325 kPa) ──────\n// All thermodynamic property lines are derived analytically from these\n// closed-form relations — nothing is loaded from disk or the network.\nconst P = 101.325; // total atmospheric pressure, kPa\n\n// Saturation vapour pressure over water (kPa), Tetens approximation, T in °C\nconst pws = (T) => 0.61078 * Math.exp((17.27 * T) / (T + 237.3));\n\n// Humidity ratio (g water / kg dry air) from a vapour pressure (kPa)\nconst wFromPw = (pw) => (621.945 * pw) / (P - pw);\n\n// W along a constant relative-humidity curve (rh as a fraction 0–1)\nconst wFromRH = (T, rh) => wFromPw(rh * pws(T));\n\n// Saturation humidity ratio (100 % RH)\nconst wSat = (T) => wFromPw(pws(T));\n\n// W along a constant wet-bulb line (ASHRAE psychrometric energy balance)\nconst wFromWetBulb = (Tdb, Twb) => {\n  const wsWb = wSat(Twb) / 1000; // kg/kg at saturation, evaluated at T_wb\n  const w =\n    ((2501 - 2.326 * Twb) * wsWb - 1.006 * (Tdb - Twb)) /\n    (2501 + 1.86 * Tdb - 4.186 * Twb);\n  return w * 1000;\n};\n\n// W along a constant-enthalpy line, h in kJ/kg dry air\nconst wFromEnthalpy = (T, h) => ((h - 1.006 * T) / (2501 + 1.86 * T)) * 1000;\n\n// W along a constant specific-volume line, v in m³/kg dry air\nconst wFromVolume = (T, v) =>\n  (((v * P) / (0.287042 * (T + 273.15)) - 1) / 1.607858) * 1000;\n\n// ── Domain ───────────────────────────────────────────────────────────────────\nconst T_MIN = -10;\nconst T_MAX = 50;\nconst W_MIN = 0;\nconst W_MAX = 30; // g/kg — caps the chart; the saturation curve exits the top\n\n// Property-line families to draw\nconst rhValues = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];\nconst wetBulbValues = [-5, 0, 5, 10, 15, 20, 25, 30];\nconst enthalpyValues = [20, 40, 60, 80, 100];\nconst volumeValues = [0.78, 0.82, 0.86, 0.9, 0.94];\n\n// ── Theme-mapped Imprint colours ─────────────────────────────────────────────\nconst RH_COLOR = t.palette[0]; // brand green — primary family\nconst WB_COLOR = t.palette[2]; // blue   — wet-bulb (evaporative cooling / water)\nconst ENTH_COLOR = t.palette[4]; // red    — enthalpy (energy / heat)\nconst VOL_COLOR = t.palette[3]; // ochre  — specific volume\nconst COMFORT_COLOR = t.palette[1]; // lavender — comfort zone\nconst PROCESS_COLOR = t.ink; // neutral ink — HVAC process path\n\n// ── Layout ───────────────────────────────────────────────────────────────────\nconst margin = { top: 92, right: 158, bottom: 82, left: 70 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst x = d3.scaleLinear().domain([T_MIN, T_MAX]).range([0, iw]);\nconst y = d3.scaleLinear().domain([W_MIN, W_MAX]).range([ih, 0]);\n\nconst svg = d3\n  .select(\"#container\")\n  .append(\"svg\")\n  .attr(\"width\", width)\n  .attr(\"height\", height);\n\n// Clip so property lines that leave the saturation envelope are trimmed cleanly\nconst defs = svg.append(\"defs\");\ndefs\n  .append(\"clipPath\")\n  .attr(\"id\", \"plotClip\")\n  .append(\"rect\")\n  .attr(\"width\", iw)\n  .attr(\"height\", ih);\n\ndefs\n  .append(\"marker\")\n  .attr(\"id\", \"processArrow\")\n  .attr(\"viewBox\", \"0 0 10 10\")\n  .attr(\"refX\", 8)\n  .attr(\"refY\", 5)\n  .attr(\"markerWidth\", 7)\n  .attr(\"markerHeight\", 7)\n  .attr(\"orient\", \"auto-start-reverse\")\n  .append(\"path\")\n  .attr(\"d\", \"M0,0 L10,5 L0,10 Z\")\n  .attr(\"fill\", PROCESS_COLOR);\n\n// The moist-air region lives below the saturation curve; wet-bulb, enthalpy and\n// specific-volume lines are only physical there, so clip them to it.\nconst satClipPts = [[x(T_MIN), ih]];\nfor (let T = T_MIN; T <= T_MAX + 1e-9; T += 0.4) {\n  satClipPts.push([x(T), y(Math.min(wSat(T), W_MAX))]);\n}\nsatClipPts.push([x(T_MAX), ih]);\ndefs\n  .append(\"clipPath\")\n  .attr(\"id\", \"satClip\")\n  .append(\"path\")\n  .attr(\"d\", \"M\" + satClipPts.map((p) => p.join(\",\")).join(\"L\") + \"Z\");\n\nconst g = svg\n  .append(\"g\")\n  .attr(\"transform\", `translate(${margin.left},${margin.top})`);\nconst plot = g.append(\"g\").attr(\"clip-path\", \"url(#plotClip)\");\nconst moist = plot.append(\"g\").attr(\"clip-path\", \"url(#satClip)\");\n\n// ── Reference grid (subtle) ──────────────────────────────────────────────────\nconst xTicks = d3.range(T_MIN, T_MAX + 1, 5);\nconst yTicks = d3.range(W_MIN, W_MAX + 1, 5);\nplot\n  .append(\"g\")\n  .selectAll(\"line\")\n  .data(xTicks)\n  .join(\"line\")\n  .attr(\"x1\", (d) => x(d))\n  .attr(\"x2\", (d) => x(d))\n  .attr(\"y1\", 0)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 0.8);\nplot\n  .append(\"g\")\n  .selectAll(\"line\")\n  .data(yTicks)\n  .join(\"line\")\n  .attr(\"x1\", 0)\n  .attr(\"x2\", iw)\n  .attr(\"y1\", (d) => y(d))\n  .attr(\"y2\", (d) => y(d))\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 0.8);\n\n// ── Sampling + curve helpers ─────────────────────────────────────────────────\nconst sample = (fn, step = 0.4) => {\n  const pts = [];\n  for (let T = T_MIN; T <= T_MAX + 1e-9; T += step) {\n    const W = fn(T);\n    if (Number.isFinite(W)) pts.push({ T, W });\n  }\n  return pts;\n};\n\nconst lineGen = d3\n  .line()\n  .x((d) => x(d.T))\n  .y((d) => y(d.W))\n  .curve(d3.curveMonotoneX);\n\nconst drawCurve = (pts, color, width, dash, opacity, group = plot) =>\n  group\n    .append(\"path\")\n    .datum(pts)\n    .attr(\"d\", lineGen)\n    .attr(\"fill\", \"none\")\n    .attr(\"stroke\", color)\n    .attr(\"stroke-width\", width)\n    .attr(\"stroke-dasharray\", dash)\n    .attr(\"stroke-opacity\", opacity)\n    .attr(\"stroke-linecap\", \"round\");\n\n// In-bounds sample point whose W sits closest to a target — used to anchor\n// labels. `belowSat` keeps anchors inside the moist-air region (below 100 % RH).\nconst anchorAtW = (pts, targetW, pad = 1, belowSat = false) => {\n  let best = null;\n  let bestD = Infinity;\n  for (const p of pts) {\n    if (p.W < W_MIN + pad || p.W > W_MAX - pad) continue;\n    if (p.T < T_MIN + pad || p.T > T_MAX - pad) continue;\n    if (belowSat && p.W > wSat(p.T) - 0.6) continue;\n    const d = Math.abs(p.W - targetW);\n    if (d < bestD) {\n      bestD = d;\n      best = p;\n    }\n  }\n  return best;\n};\n\n// Highest valid point still below saturation — anchors a diagonal family's label\n// up near where the line meets the saturation curve (classic chart placement).\nconst anchorNearSat = (pts, pad = 1) => {\n  let best = null;\n  for (const p of pts) {\n    if (p.W < W_MIN + pad || p.W > W_MAX - pad) continue;\n    if (p.T < T_MIN + pad || p.T > T_MAX - pad) continue;\n    if (p.W > wSat(p.T) - 0.6) continue;\n    if (!best || p.W > best.W) best = p;\n  }\n  return best;\n};\n\n// Local pixel-space tangent angle (degrees) at a temperature, for rotated labels\nconst tangentDeg = (fn, T) => {\n  const dx = x(T + 1) - x(T - 1);\n  const dy = y(fn(T + 1)) - y(fn(T - 1));\n  return (Math.atan2(dy, dx) * 180) / Math.PI;\n};\n\nconst halo = (sel) =>\n  sel\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 3.2)\n    .attr(\"paint-order\", \"stroke\")\n    .attr(\"stroke-linejoin\", \"round\");\n\n// ── Specific-volume lines (drawn first, behind everything) ───────────────────\nvolumeValues.forEach((v) => {\n  const pts = sample((T) => wFromVolume(T, v));\n  drawCurve(pts, VOL_COLOR, 1.1, \"1 4\", 0.7, moist);\n  const a = anchorAtW(pts, 3, 1.5, true);\n  if (a) {\n    halo(\n      plot\n        .append(\"text\")\n        .attr(\"transform\", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromVolume(T, v), a.T)})`)\n        .attr(\"text-anchor\", \"middle\")\n        .attr(\"dy\", -4)\n        .attr(\"fill\", VOL_COLOR)\n        .style(\"font-size\", \"12px\")\n        .text(v.toFixed(2)),\n    );\n  }\n});\n\n// ── Constant-enthalpy lines ──────────────────────────────────────────────────\nenthalpyValues.forEach((h) => {\n  const pts = sample((T) => wFromEnthalpy(T, h));\n  drawCurve(pts, ENTH_COLOR, 1.1, \"5 4\", 0.62, moist);\n  const a = anchorNearSat(pts, 1.5);\n  if (a) {\n    halo(\n      plot\n        .append(\"text\")\n        .attr(\"transform\", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromEnthalpy(T, h), a.T)})`)\n        .attr(\"text-anchor\", \"middle\")\n        .attr(\"dy\", -4)\n        .attr(\"fill\", ENTH_COLOR)\n        .style(\"font-size\", \"12px\")\n        .text(h),\n    );\n  }\n});\n\n// ── Constant wet-bulb lines ──────────────────────────────────────────────────\nwetBulbValues.forEach((twb) => {\n  const pts = sample((T) => (T >= twb ? wFromWetBulb(T, twb) : NaN)).filter(\n    (p) => Number.isFinite(p.W),\n  );\n  if (pts.length < 2) return;\n  drawCurve(pts, WB_COLOR, 1.1, null, 0.6, moist);\n  const a = anchorNearSat(pts, 1.5);\n  if (a) {\n    halo(\n      plot\n        .append(\"text\")\n        .attr(\"transform\", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromWetBulb(T, twb), a.T)})`)\n        .attr(\"text-anchor\", \"middle\")\n        .attr(\"dy\", -4)\n        .attr(\"fill\", WB_COLOR)\n        .style(\"font-size\", \"12px\")\n        .text(twb),\n    );\n  }\n});\n\n// ── Comfort zone (≈20–26 °C, 30–60 % RH) — bounded by RH curves ──────────────\nconst comfortPts = [];\nfor (let T = 20; T <= 26.001; T += 0.5) comfortPts.push([x(T), y(wFromRH(T, 0.3))]);\nfor (let rh = 0.3; rh <= 0.601; rh += 0.05) comfortPts.push([x(26), y(wFromRH(26, rh))]);\nfor (let T = 26; T >= 19.999; T -= 0.5) comfortPts.push([x(T), y(wFromRH(T, 0.6))]);\nfor (let rh = 0.6; rh >= 0.299; rh -= 0.05) comfortPts.push([x(20), y(wFromRH(20, rh))]);\nplot\n  .append(\"path\")\n  .attr(\"d\", \"M\" + comfortPts.map((p) => p.join(\",\")).join(\"L\") + \"Z\")\n  .attr(\"fill\", COMFORT_COLOR)\n  .attr(\"fill-opacity\", 0.16)\n  .attr(\"stroke\", COMFORT_COLOR)\n  .attr(\"stroke-width\", 2)\n  .attr(\"stroke-opacity\", 0.9);\nhalo(\n  plot\n    .append(\"text\")\n    .attr(\"x\", x(23))\n    .attr(\"y\", y(wFromRH(23, 0.45)))\n    .attr(\"text-anchor\", \"middle\")\n    .attr(\"fill\", COMFORT_COLOR)\n    .style(\"font-size\", \"13px\")\n    .style(\"font-weight\", \"700\")\n    .text(\"Comfort\"),\n);\n\n// ── Relative-humidity curves (10–100 %); saturation is prominent ─────────────\nrhValues.forEach((rh) => {\n  const frac = rh / 100;\n  const pts = sample((T) => wFromRH(T, frac));\n  const saturation = rh === 100;\n  drawCurve(\n    pts,\n    RH_COLOR,\n    saturation ? 3.6 : 1.5,\n    null,\n    saturation ? 1 : 0.55,\n  );\n  // RH labels ride up each curve; stagger by targeting progressively higher W\n  const targetW = saturation ? 24 : Math.min(26, 6 + rh * 0.18);\n  const a = anchorAtW(pts, targetW, saturation ? 2 : 1.2);\n  if (a) {\n    halo(\n      plot\n        .append(\"text\")\n        .attr(\"transform\", `translate(${x(a.T)},${y(a.W)}) rotate(${tangentDeg((T) => wFromRH(T, frac), a.T)})`)\n        .attr(\"text-anchor\", \"middle\")\n        .attr(\"dy\", -5)\n        .attr(\"fill\", RH_COLOR)\n        .style(\"font-size\", saturation ? \"15px\" : \"12.5px\")\n        .style(\"font-weight\", saturation ? \"700\" : \"600\")\n        .text(saturation ? \"100% (Saturation)\" : rh + \"%\"),\n    );\n  }\n});\n\n// ── Example HVAC process: cooling & dehumidification ─────────────────────────\nconst state1 = { T: 30, rh: 0.55 };\nconst state2 = { T: 13, rh: 0.9 };\nconst p1 = { x: x(state1.T), y: y(wFromRH(state1.T, state1.rh)) };\nconst p2 = { x: x(state2.T), y: y(wFromRH(state2.T, state2.rh)) };\nplot\n  .append(\"line\")\n  .attr(\"x1\", p1.x)\n  .attr(\"y1\", p1.y)\n  .attr(\"x2\", p2.x)\n  .attr(\"y2\", p2.y)\n  .attr(\"stroke\", PROCESS_COLOR)\n  .attr(\"stroke-width\", 3.2)\n  .attr(\"marker-end\", \"url(#processArrow)\");\n[\n  { p: p1, label: \"1\" },\n  { p: p2, label: \"2\" },\n].forEach(({ p, label }) => {\n  plot\n    .append(\"circle\")\n    .attr(\"cx\", p.x)\n    .attr(\"cy\", p.y)\n    .attr(\"r\", 6)\n    .attr(\"fill\", PROCESS_COLOR)\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 2);\n  halo(\n    plot\n      .append(\"text\")\n      .attr(\"x\", p.x + 11)\n      .attr(\"y\", p.y + 5)\n      .attr(\"fill\", t.ink)\n      .style(\"font-size\", \"14px\")\n      .style(\"font-weight\", \"700\")\n      .text(label),\n  );\n});\n\n// ── Axes ─────────────────────────────────────────────────────────────────────\nconst axB = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(0,${ih})`)\n  .call(d3.axisBottom(x).tickValues(xTicks).tickSize(6).tickFormat(d3.format(\"d\")));\naxB.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\naxB.selectAll(\".tick line\").attr(\"stroke\", t.inkSoft);\naxB.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\nconst axR = g\n  .append(\"g\")\n  .attr(\"transform\", `translate(${iw},0)`)\n  .call(d3.axisRight(y).tickValues(yTicks).tickSize(6).tickFormat(d3.format(\"d\")));\naxR.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\naxR.selectAll(\".tick line\").attr(\"stroke\", t.inkSoft);\naxR.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Dry-Bulb Temperature (°C)\");\n\ng.append(\"text\")\n  .attr(\"transform\", `translate(${iw + 70},${ih / 2}) rotate(90)`)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Humidity Ratio (g water / kg dry air)\");\n\n// ── Legend (upper-left, in the impossible region above saturation) ───────────\nconst legend = g.append(\"g\").attr(\"transform\", `translate(8,6)`);\nconst legendItems = [\n  { color: RH_COLOR, dash: null, w: 3, label: \"Relative humidity (%)\" },\n  { color: WB_COLOR, dash: null, w: 2, label: \"Wet-bulb temp (°C)\" },\n  { color: ENTH_COLOR, dash: \"5 4\", w: 2, label: \"Enthalpy (kJ/kg)\" },\n  { color: VOL_COLOR, dash: \"1 4\", w: 2, label: \"Specific volume (m³/kg)\" },\n  { color: COMFORT_COLOR, dash: null, w: 2, label: \"Comfort zone\", swatch: true },\n  { color: PROCESS_COLOR, dash: null, w: 3, label: \"Process: cool + dehumidify\", arrow: true },\n];\nlegendItems.forEach((it, i) => {\n  const yy = i * 26 + 4;\n  if (it.swatch) {\n    legend\n      .append(\"rect\")\n      .attr(\"x\", 0)\n      .attr(\"y\", yy - 8)\n      .attr(\"width\", 34)\n      .attr(\"height\", 14)\n      .attr(\"fill\", it.color)\n      .attr(\"fill-opacity\", 0.16)\n      .attr(\"stroke\", it.color)\n      .attr(\"stroke-width\", 1.5);\n  } else {\n    legend\n      .append(\"line\")\n      .attr(\"x1\", 0)\n      .attr(\"x2\", 34)\n      .attr(\"y1\", yy)\n      .attr(\"y2\", yy)\n      .attr(\"stroke\", it.color)\n      .attr(\"stroke-width\", it.w)\n      .attr(\"stroke-dasharray\", it.dash)\n      .attr(\"marker-end\", it.arrow ? \"url(#processArrow)\" : null);\n  }\n  legend\n    .append(\"text\")\n    .attr(\"x\", 44)\n    .attr(\"y\", yy + 5)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"13.5px\")\n    .text(it.label);\n});\n\n// ── Title ────────────────────────────────────────────────────────────────────\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 46)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"psychrometric-basic · javascript · d3 · anyplot.ai\");\n\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 70)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text(\"Moist air at sea-level pressure (101.325 kPa) — ASHRAE properties\");\n"}