{"spec_id":"psychrometric-basic","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: highcharts 12.6.0 | 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: Highcharts 12.6.0 | Node 22\n// License: Highcharts — commercial license, free for non-commercial use (highcharts.com/license)\n// Quality: pending | Created: 2026-06-16\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\n\n// Theme-adaptive chrome\nconst PAGE_BG = t.pageBg;\nconst INK = t.ink;\nconst INK_SOFT = t.inkSoft;\nconst GRID = t.grid;\n\n// Imprint palette roles (water/humidity → blue; comfort → lavender; loss/process → matte red)\nconst GREEN = t.palette[0]; // brand — saturation curve (hero series)\nconst LAV = t.palette[1]; // comfort zone\nconst BLUE = t.palette[2]; // relative-humidity family (water semantic)\nconst OCHRE = t.palette[3]; // constant-enthalpy family\nconst RED = t.palette[4]; // HVAC process path (active, highlighted)\nconst CYAN = t.palette[5]; // constant wet-bulb family (evaporative-cool semantic)\nconst ROSE = t.palette[6]; // constant specific-volume family\nconst COMFORT_FILL =\n  THEME === \"light\" ? \"rgba(196,117,253,0.16)\" : \"rgba(196,117,253,0.22)\";\n\n// --- Psychrometrics (ASHRAE, standard sea-level pressure) -------------------\nconst P = 101.325; // kPa\nconst YMAX = 30; // humidity-ratio axis cap (g water / kg dry air)\n\n// Saturation vapor pressure over water (kPa), T in °C — ASHRAE RP-1485\nfunction pws(T) {\n  const Tk = T + 273.15;\n  const e =\n    -5800.2206 / Tk +\n    1.3914993 +\n    -0.04860239 * Tk +\n    4.1764768e-5 * Tk * Tk +\n    -1.4452093e-8 * Tk * Tk * Tk +\n    6.5459673 * Math.log(Tk);\n  return Math.exp(e) / 1000; // Pa → kPa\n}\n\n// Humidity ratio (g/kg) from dry-bulb T (°C) and relative humidity (0–1)\nfunction humidityRatio(T, rh) {\n  const pw = rh * pws(T);\n  return (1000 * 0.621945 * pw) / (P - pw);\n}\n\n// --- Curve builders ---------------------------------------------------------\n// Relative-humidity curve (constant RH) across the dry-bulb range\nfunction rhCurve(rh) {\n  const pts = [];\n  for (let T = -10; T <= 50; T += 0.5) {\n    const w = humidityRatio(T, rh);\n    if (w >= 0 && w <= YMAX) pts.push([T, w]);\n  }\n  return pts;\n}\n\n// Constant-enthalpy line, clipped to the valid region below saturation\nfunction enthalpyLine(h) {\n  const pts = [];\n  for (let T = -10; T <= 50; T += 0.5) {\n    const wg = (1000 * (h - 1.006 * T)) / (2501 + 1.86 * T);\n    const wsat = humidityRatio(T, 1.0);\n    if (wg >= 0 && wg <= YMAX && wg <= wsat + 0.01) pts.push([T, wg]);\n  }\n  return pts;\n}\n\n// Constant wet-bulb line (ASHRAE energy balance). It springs from the\n// saturation curve at T = twb and runs diagonally down to the right.\nfunction wetBulbLine(twb) {\n  const wsatWb = humidityRatio(twb, 1.0) / 1000; // kg/kg at saturation\n  const pts = [];\n  for (let T = twb; T <= 50; T += 0.5) {\n    const w =\n      ((2501 - 2.326 * twb) * wsatWb - 1.006 * (T - twb)) /\n      (2501 + 1.86 * T - 4.186 * twb); // kg/kg dry air\n    const wg = w * 1000;\n    if (wg >= 0 && wg <= YMAX) pts.push([T, wg]);\n  }\n  return pts;\n}\n\n// Constant specific-volume line (ideal moist-air state equation, Ra = 0.287042\n// kJ/kg·K). These run at a steeper angle than the enthalpy/wet-bulb diagonals.\nfunction specVolumeLine(v) {\n  const Ra = 0.287042;\n  const pts = [];\n  for (let T = -10; T <= 50; T += 0.5) {\n    const w = ((v * P) / (Ra * (T + 273.15)) - 1) / 1.6078; // kg/kg dry air\n    const wg = w * 1000;\n    const wsat = humidityRatio(T, 1.0);\n    if (wg >= 0 && wg <= YMAX && wg <= wsat + 0.01) pts.push([T, wg]);\n  }\n  return pts;\n}\n\n// Label styling: ink halo so direct labels stay legible over crossing lines\nfunction labelStyle(color) {\n  return {\n    color,\n    fontSize: \"13px\",\n    fontWeight: \"600\",\n    textOutline: `2px ${PAGE_BG}`,\n  };\n}\n\n// --- Series -----------------------------------------------------------------\nconst series = [];\n\n// Relative-humidity curves (10%–100%); 100% saturation is the bold hero curve\n// Label fraction along each curve; 60% sits lower-left so it clears State 1.\nconst rhLabels = {\n  20: { text: \"20%\", frac: 0.82 },\n  40: { text: \"40%\", frac: 0.82 },\n  60: { text: \"60%\", frac: 0.55 },\n  80: { text: \"80%\", frac: 0.82 },\n};\nfor (let pct = 10; pct <= 90; pct += 10) {\n  const pts = rhCurve(pct / 100);\n  if (rhLabels[pct]) {\n    const i = Math.round(pts.length * rhLabels[pct].frac);\n    pts[i] = {\n      x: pts[i][0],\n      y: pts[i][1],\n      dataLabels: {\n        enabled: true,\n        format: rhLabels[pct].text,\n        align: \"center\",\n        verticalAlign: \"bottom\",\n        y: -2,\n        style: labelStyle(BLUE),\n      },\n    };\n  }\n  series.push({\n    type: \"spline\",\n    data: pts,\n    color: BLUE,\n    lineWidth: 1.5,\n    enableMouseTracking: false,\n    zIndex: 2,\n  });\n}\n\n// Saturation curve (100% RH) — visually prominent upper boundary\nconst satPts = rhCurve(1.0);\nconst si = Math.round(satPts.length * 0.62);\nsatPts[si] = {\n  x: satPts[si][0],\n  y: satPts[si][1],\n  dataLabels: {\n    enabled: true,\n    format: \"Saturation · 100% RH\",\n    align: \"right\",\n    x: -6,\n    y: 2,\n    style: labelStyle(GREEN),\n  },\n};\nseries.push({\n  type: \"spline\",\n  data: satPts,\n  color: GREEN,\n  lineWidth: 4,\n  enableMouseTracking: false,\n  zIndex: 4,\n});\n\n// Constant-enthalpy lines (diagonal, upper-left → lower-right), dashed\nconst enthalpies = [20, 40, 60, 80, 100];\nfor (const h of enthalpies) {\n  const pts = enthalpyLine(h);\n  if (pts.length < 2) continue;\n  pts[0] = {\n    x: pts[0][0],\n    y: pts[0][1],\n    dataLabels: {\n      enabled: true,\n      format: String(h),\n      align: \"right\",\n      x: -4,\n      y: -2,\n      style: labelStyle(OCHRE),\n    },\n  };\n  series.push({\n    type: \"line\",\n    data: pts,\n    color: OCHRE,\n    lineWidth: 1.5,\n    dashStyle: \"ShortDash\",\n    enableMouseTracking: false,\n    zIndex: 3,\n  });\n}\n\n// Constant wet-bulb lines (diagonal, springing off the saturation curve).\n// Drawn thin & dotted so they stay distinct from the enthalpy diagonals.\nconst wetBulbs = [5, 10, 15, 20, 25];\nfor (const twb of wetBulbs) {\n  const pts = wetBulbLine(twb);\n  if (pts.length < 2) continue;\n  const li = pts.length - 1;\n  pts[li] = {\n    x: pts[li][0],\n    y: pts[li][1],\n    dataLabels: {\n      enabled: true,\n      format: `${twb}°`,\n      align: \"left\",\n      x: 3,\n      y: 3,\n      style: labelStyle(CYAN),\n    },\n  };\n  series.push({\n    type: \"line\",\n    data: pts,\n    color: CYAN,\n    lineWidth: 1,\n    dashStyle: \"Dot\",\n    opacity: 0.7,\n    enableMouseTracking: false,\n    zIndex: 2,\n  });\n}\n\n// Constant specific-volume lines (steeper diagonal). Thin & long-dashed.\nconst specVolumes = [0.82, 0.86, 0.9, 0.94];\nfor (const v of specVolumes) {\n  const pts = specVolumeLine(v);\n  if (pts.length < 2) continue;\n  pts[0] = {\n    x: pts[0][0],\n    y: pts[0][1],\n    dataLabels: {\n      enabled: true,\n      format: v.toFixed(2),\n      align: \"left\",\n      x: 2,\n      y: -2,\n      style: labelStyle(ROSE),\n    },\n  };\n  series.push({\n    type: \"line\",\n    data: pts,\n    color: ROSE,\n    lineWidth: 1,\n    dashStyle: \"LongDash\",\n    opacity: 0.7,\n    enableMouseTracking: false,\n    zIndex: 2,\n  });\n}\n\n// HVAC process path: cooling & dehumidification between two state points\nconst stateA = { T: 32, rh: 0.6 };\nconst stateB = { T: 13, rh: 0.9 };\nconst wA = humidityRatio(stateA.T, stateA.rh);\nconst wB = humidityRatio(stateB.T, stateB.rh);\nseries.push({\n  type: \"line\",\n  color: RED,\n  lineWidth: 3.5,\n  zIndex: 6,\n  enableMouseTracking: false,\n  marker: { enabled: true, radius: 7, lineColor: PAGE_BG, lineWidth: 2, symbol: \"circle\" },\n  data: [\n    {\n      x: stateA.T,\n      y: wA,\n      dataLabels: {\n        enabled: true,\n        format: \"State 1 · 32 °C, 60% RH\",\n        align: \"left\",\n        x: 12,\n        y: -16,\n        style: labelStyle(INK),\n      },\n    },\n    {\n      x: stateB.T,\n      y: wB,\n      dataLabels: {\n        enabled: true,\n        format: \"State 2 · 13 °C, 90% RH\",\n        align: \"right\",\n        x: -10,\n        y: 20,\n        style: labelStyle(INK),\n      },\n    },\n  ],\n});\n\n// --- Chart ------------------------------------------------------------------\nHighcharts.chart(\"container\", {\n  chart: {\n    type: \"spline\",\n    backgroundColor: \"transparent\",\n    animation: false,\n    style: { fontFamily: \"inherit\" },\n    marginRight: 110,\n    events: {\n      // Comfort zone (≈20–26 °C, 30–60% RH), process arrowhead, and zone label\n      // are drawn with the SVG renderer so they sit at exact data coordinates.\n      load() {\n        const ax = this.xAxis[0];\n        const ay = this.yAxis[0];\n        const px = (v) => ax.toPixels(v);\n        const py = (v) => ay.toPixels(v);\n\n        // Comfort zone polygon — sample its RH/temperature boundary\n        const zone = [];\n        for (let T = 20; T <= 26; T += 0.5) zone.push([T, humidityRatio(T, 0.3)]);\n        for (let r = 0.3; r <= 0.6; r += 0.05) zone.push([26, humidityRatio(26, r)]);\n        for (let T = 26; T >= 20; T -= 0.5) zone.push([T, humidityRatio(T, 0.6)]);\n        for (let r = 0.6; r >= 0.3; r -= 0.05) zone.push([20, humidityRatio(20, r)]);\n        const path = [];\n        zone.forEach((p, idx) => {\n          path.push(idx === 0 ? \"M\" : \"L\", px(p[0]), py(p[1]));\n        });\n        path.push(\"Z\");\n        this.renderer\n          .path(path)\n          .attr({ fill: COMFORT_FILL, stroke: LAV, \"stroke-width\": 2, zIndex: 1 })\n          .add();\n        this.renderer\n          .text(\"Comfort<br/>Zone\", px(23), py(humidityRatio(23, 0.45)) + 4)\n          .attr({ align: \"center\", zIndex: 2 })\n          .css({\n            color: INK,\n            fontSize: \"13px\",\n            fontWeight: \"600\",\n            textOutline: `3px ${PAGE_BG}`,\n          })\n          .add();\n\n        // Process arrowhead at State 2\n        const x1 = px(stateA.T);\n        const y1 = py(wA);\n        const x2 = px(stateB.T);\n        const y2 = py(wB);\n        const ang = Math.atan2(y2 - y1, x2 - x1);\n        const s = 15;\n        const a1 = ang + Math.PI * 0.82;\n        const a2 = ang - Math.PI * 0.82;\n        this.renderer\n          .path([\n            \"M\", x2, y2,\n            \"L\", x2 + s * Math.cos(a1), y2 + s * Math.sin(a1),\n            \"L\", x2 + s * Math.cos(a2), y2 + s * Math.sin(a2),\n            \"Z\",\n          ])\n          .attr({ fill: RED, zIndex: 7 })\n          .add();\n        this.renderer\n          .text(\"Cooling &amp; Dehumidification\", (x1 + x2) / 2 + 8, (y1 + y2) / 2 - 6)\n          .attr({ zIndex: 7 })\n          .css({\n            color: RED,\n            fontSize: \"13px\",\n            fontWeight: \"600\",\n            textOutline: `3px ${PAGE_BG}`,\n          })\n          .add();\n\n        // Property-family key (upper-left, where the diagonal lines originate)\n        const note = (label, color, w) =>\n          this.renderer\n            .text(label, px(-9), py(w))\n            .css({\n              color,\n              fontSize: \"13px\",\n              fontWeight: \"600\",\n              textOutline: `3px ${PAGE_BG}`,\n            })\n            .add();\n        note(\"Constant enthalpy (kJ/kg)\", OCHRE, 28.5);\n        note(\"Constant wet-bulb (°C)\", CYAN, 26.6);\n        note(\"Specific volume (m³/kg)\", ROSE, 24.7);\n      },\n    },\n  },\n  credits: { enabled: false },\n  colors: t.palette,\n  title: {\n    text: \"psychrometric-basic · javascript · highcharts · anyplot.ai\",\n    style: { color: INK, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Moist-air properties at 101.325 kPa (sea level)\",\n    style: { color: INK_SOFT, fontSize: \"14px\" },\n  },\n  xAxis: {\n    min: -10,\n    max: 50,\n    tickInterval: 5,\n    title: {\n      text: \"Dry-bulb Temperature (°C)\",\n      style: { color: INK_SOFT, fontSize: \"16px\" },\n    },\n    lineColor: INK_SOFT,\n    tickColor: INK_SOFT,\n    gridLineColor: GRID,\n    gridLineWidth: 1,\n    labels: { style: { color: INK_SOFT, fontSize: \"14px\" } },\n  },\n  yAxis: {\n    min: 0,\n    max: YMAX,\n    tickInterval: 5,\n    opposite: true,\n    title: {\n      text: \"Humidity Ratio (g water / kg dry air)\",\n      style: { color: INK_SOFT, fontSize: \"16px\" },\n    },\n    lineColor: INK_SOFT,\n    gridLineColor: GRID,\n    gridLineWidth: 1,\n    labels: { style: { color: INK_SOFT, fontSize: \"14px\" } },\n  },\n  legend: { enabled: false },\n  plotOptions: {\n    series: {\n      animation: false,\n      marker: { enabled: false },\n      states: { hover: { enabled: false }, inactive: { enabled: false } },\n    },\n  },\n  series,\n});\n"}