{"spec_id":"psychrometric-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: chartjs 4.4.7 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-16\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\nconst THEME = window.ANYPLOT_THEME;\n\n// Imprint palette roles (data colors identical across themes; only chrome flips)\nconst C_COMFORT = t.palette[0]; // #009E73 brand green — comfort / good (first series)\nconst C_RH = t.palette[2]; //      #4467A3 blue   — water / humidity\nconst C_WETBULB = t.palette[1]; // #C475FD lavender — wet-bulb temperature\nconst C_ENTHALPY = t.palette[3]; // #BD8233 ochre  — energy / enthalpy\nconst C_VOLUME = t.palette[5]; //  #2ABCCD cyan   — specific volume\nconst C_PROCESS = t.palette[4]; // #AE3030 red    — highlighted process path\n\n// --- Psychrometrics at sea-level standard pressure (ASHRAE) -----------------\nconst P = 101.325; // total atmospheric pressure, kPa\nconst X_MIN = -10,\n  X_MAX = 50; // dry-bulb range, °C\nconst Y_MAX = 30; // humidity ratio cap, g water / kg dry air\n\n// Saturation vapor pressure over water (kPa) — Magnus form\nconst pws = (tdb) => 0.61094 * Math.exp((17.625 * tdb) / (tdb + 243.04));\n\n// Humidity ratio (g/kg) from dry-bulb (°C) and relative humidity (fraction)\nconst humRatio = (tdb, rh) => {\n  const pw = rh * pws(tdb);\n  return (0.62198 * pw) / (P - pw) * 1000;\n};\n\n// --- Build the property-line families ---------------------------------------\nconst labels = []; // {text, x, y, color, dx, dy, align, size, weight}\nconst datasets = [];\n\nconst lineDataset = (data, color, width, dash) => ({\n  data,\n  borderColor: color,\n  borderWidth: width,\n  borderDash: dash || [],\n  pointRadius: 0,\n  fill: false,\n  tension: 0,\n  order: 5,\n});\n\n// Relative-humidity curves: 10% … 100% (saturation). 100% is the prominent boundary.\nconst rhCurve = (rh) => {\n  const pts = [];\n  for (let tdb = X_MIN; tdb <= X_MAX + 0.001; tdb += 0.5) {\n    const w = humRatio(tdb, rh);\n    pts.push({ x: tdb, y: w });\n    if (w > Y_MAX + 2) break; // let the line reach the top edge, then stop\n  }\n  return pts;\n};\n\nfor (let rhPct = 10; rhPct <= 90; rhPct += 10) {\n  const pts = rhCurve(rhPct / 100);\n  const ds = lineDataset(pts, C_RH, 2, []);\n  ds.tension = 0.3;\n  datasets.push(ds);\n  const anchor = pts[pts.length - 1];\n  labels.push({\n    text: `${rhPct}%`,\n    x: anchor.x,\n    y: Math.min(anchor.y, Y_MAX),\n    color: C_RH,\n    dx: -4,\n    dy: 10,\n    align: \"right\",\n    size: 13,\n  });\n}\n\n// Saturation curve (100% RH) — visually prominent upper boundary\nconst satPts = rhCurve(1.0);\nconst satDs = lineDataset(satPts, C_RH, 4, []);\nsatDs.tension = 0.3;\nsatDs.order = 3;\ndatasets.push(satDs);\nlabels.push({\n  text: \"100% (saturation)\",\n  x: 18,\n  y: humRatio(18, 1.0),\n  color: C_RH,\n  dx: -6,\n  dy: -6,\n  align: \"right\",\n  size: 14,\n  weight: \"600\",\n});\n\n// Wet-bulb temperature lines (diagonal, anchored on the saturation curve)\nfor (const twb of [0, 5, 10, 15, 20, 25, 30]) {\n  const wsWb = humRatio(twb, 1.0) / 1000; // kg/kg at saturation\n  const pts = [];\n  for (let tdb = twb; tdb <= X_MAX + 0.001; tdb += 0.5) {\n    const w =\n      ((2501 - 2.326 * twb) * wsWb - 1.006 * (tdb - twb)) /\n      (2501 + 1.86 * tdb - 4.186 * twb);\n    const wg = w * 1000;\n    if (wg < 0) break;\n    if (wg <= Y_MAX + 2) pts.push({ x: tdb, y: wg });\n  }\n  if (pts.length < 2) continue;\n  datasets.push(lineDataset(pts, C_WETBULB, 1.5, [10, 6]));\n  labels.push({\n    text: `${twb}°`,\n    x: pts[0].x,\n    y: Math.min(pts[0].y, Y_MAX),\n    color: C_WETBULB,\n    dx: -2,\n    dy: -8,\n    align: \"center\",\n    size: 12,\n  });\n}\n\n// Constant-enthalpy lines (oblique, kJ/kg of dry air)\nfor (const h of [20, 40, 60, 80, 100]) {\n  const pts = [];\n  for (let tdb = X_MIN; tdb <= X_MAX + 0.001; tdb += 0.5) {\n    const w = (h - 1.006 * tdb) / (2501 + 1.86 * tdb); // kg/kg\n    const wg = w * 1000;\n    if (wg >= -0.5 && wg <= Y_MAX + 2) pts.push({ x: tdb, y: wg });\n  }\n  if (pts.length < 2) continue;\n  datasets.push(lineDataset(pts, C_ENTHALPY, 1.5, [3, 4]));\n  const top = pts[0]; // upper-left end\n  labels.push({\n    text: `${h}`,\n    x: top.x,\n    y: Math.min(top.y, Y_MAX),\n    color: C_ENTHALPY,\n    dx: 8,\n    dy: 4,\n    align: \"left\",\n    size: 12,\n  });\n}\n\n// Constant-specific-volume lines (steep diagonal, m³/kg of dry air)\nfor (const v of [0.78, 0.82, 0.86, 0.9, 0.94]) {\n  const pts = [];\n  for (let tdb = X_MIN; tdb <= X_MAX + 0.001; tdb += 0.5) {\n    const w = ((v * P) / (0.287042 * (tdb + 273.15)) - 1) / 1.6078; // kg/kg\n    const wg = w * 1000;\n    if (wg >= 0 && wg <= Y_MAX + 2) pts.push({ x: tdb, y: wg });\n  }\n  if (pts.length < 2) continue;\n  datasets.push(lineDataset(pts, C_VOLUME, 1.5, [12, 4, 3, 4]));\n  const bottom = pts[pts.length - 1]; // near the x-axis\n  labels.push({\n    text: `${v.toFixed(2)}`,\n    x: bottom.x,\n    y: bottom.y,\n    color: C_VOLUME,\n    dx: 4,\n    dy: -10,\n    align: \"left\",\n    size: 12,\n  });\n}\n\n// --- Comfort zone (≈20–26 °C, 30–60% RH), traced along the RH curves --------\nconst comfortPts = [];\nfor (let tdb = 20; tdb <= 26 + 0.001; tdb += 0.5) comfortPts.push({ x: tdb, y: humRatio(tdb, 0.3) });\nfor (let tdb = 26; tdb >= 20 - 0.001; tdb -= 0.5) comfortPts.push({ x: tdb, y: humRatio(tdb, 0.6) });\ncomfortPts.push({ x: 20, y: humRatio(20, 0.3) });\ndatasets.unshift({\n  data: comfortPts,\n  borderColor: \"rgba(0,158,115,0.7)\",\n  backgroundColor: \"rgba(0,158,115,0.2)\",\n  borderWidth: 2,\n  pointRadius: 0,\n  fill: true,\n  tension: 0,\n  order: 6,\n});\n\n// --- Example HVAC process: cooling + dehumidification -----------------------\nconst stateA = { x: 30, y: humRatio(30, 0.5) }; // warm humid supply air\nconst stateB = { x: 13, y: humRatio(13, 0.95) }; // cooled, near-saturated air\ndatasets.push({\n  data: [stateA, stateB],\n  borderColor: C_PROCESS,\n  backgroundColor: C_PROCESS,\n  borderWidth: 4.5,\n  pointRadius: [7, 7],\n  pointBackgroundColor: C_PROCESS,\n  pointBorderColor: t.pageBg,\n  pointBorderWidth: 2,\n  fill: false,\n  tension: 0,\n  order: 1,\n});\n\n// --- Direct-label + process-arrow plugin (canvas drawing, no extra deps) -----\nconst annotations = {\n  id: \"annotations\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales, chartArea } = chart;\n    const px = (x) => scales.x.getPixelForValue(x);\n    const py = (y) => scales.y.getPixelForValue(y);\n\n    ctx.save();\n    ctx.textBaseline = \"middle\";\n\n    // property-line labels\n    for (const l of labels) {\n      const X = px(l.x) + (l.dx || 0);\n      const Y = py(l.y) + (l.dy || 0);\n      if (X < chartArea.left - 60 || X > chartArea.right + 60) continue;\n      ctx.fillStyle = l.color;\n      ctx.font = `${l.weight || \"500\"} ${l.size || 12}px sans-serif`;\n      ctx.textAlign = l.align || \"left\";\n      ctx.fillText(l.text, X, Y);\n    }\n\n    // comfort-zone label\n    ctx.fillStyle = t.ink;\n    ctx.textAlign = \"center\";\n    ctx.font = \"600 15px sans-serif\";\n    ctx.fillText(\"Comfort zone\", px(23), py(humRatio(23, 0.45)));\n    ctx.font = \"400 12px sans-serif\";\n    ctx.fillStyle = t.inkSoft;\n    ctx.fillText(\"20–26 °C · 30–60% RH\", px(23), py(humRatio(23, 0.45)) + 16);\n\n    // process arrowhead at state B\n    const ax = px(stateA.x),\n      ay = py(stateA.y);\n    const bx = px(stateB.x),\n      by = py(stateB.y);\n    const ang = Math.atan2(by - ay, bx - ax);\n    const head = 16;\n    ctx.fillStyle = C_PROCESS;\n    ctx.beginPath();\n    ctx.moveTo(bx, by);\n    ctx.lineTo(bx - head * Math.cos(ang - 0.4), by - head * Math.sin(ang - 0.4));\n    ctx.lineTo(bx - head * Math.cos(ang + 0.4), by - head * Math.sin(ang + 0.4));\n    ctx.closePath();\n    ctx.fill();\n\n    // process endpoint labels\n    ctx.fillStyle = t.ink;\n    ctx.font = \"600 13px sans-serif\";\n    ctx.textAlign = \"left\";\n    ctx.fillText(\"A · supply (30 °C, 50% RH)\", ax + 12, ay - 4);\n    ctx.textAlign = \"right\";\n    ctx.fillText(\"B · cooled & dehumidified\", bx - 16, by + 6);\n    ctx.fillStyle = C_PROCESS;\n    ctx.font = \"italic 600 13px sans-serif\";\n    ctx.textAlign = \"center\";\n    ctx.fillText(\n      \"cooling + dehumidification\",\n      (ax + bx) / 2 + 70,\n      (ay + by) / 2 - 14,\n    );\n\n    ctx.restore();\n  },\n};\n\n// --- Mount ------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ------------------------------------------------------------------\nconst legendFamilies = [\n  { text: \"Comfort zone\", color: C_COMFORT },\n  { text: \"Relative humidity\", color: C_RH },\n  { text: \"Wet-bulb temp (°C)\", color: C_WETBULB },\n  { text: \"Enthalpy (kJ/kg)\", color: C_ENTHALPY },\n  { text: \"Specific volume (m³/kg)\", color: C_VOLUME },\n  { text: \"Process path\", color: C_PROCESS },\n];\n\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 24, bottom: 4, left: 8 } },\n    showLine: true,\n    plugins: {\n      title: {\n        display: true,\n        text: \"psychrometric-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"600\" },\n        padding: { bottom: 14 },\n      },\n      tooltip: { enabled: true },\n      legend: {\n        position: \"top\",\n        onClick: () => {},\n        labels: {\n          color: t.ink,\n          font: { size: 15 },\n          boxWidth: 28,\n          usePointStyle: false,\n          generateLabels: () =>\n            legendFamilies.map((f) => ({\n              text: f.text,\n              fillStyle: f.color,\n              strokeStyle: f.color,\n              lineWidth: 3,\n              fontColor: t.ink,\n              hidden: false,\n            })),\n        },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: X_MIN,\n        max: X_MAX,\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 10 },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Dry-Bulb Temperature (°C)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        type: \"linear\",\n        position: \"right\",\n        min: 0,\n        max: Y_MAX,\n        ticks: { color: t.inkSoft, font: { size: 14 }, stepSize: 5 },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Humidity Ratio (g water / kg dry air)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [annotations],\n});\n"}