{"spec_id":"psychrometric-basic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// psychrometric-basic: Psychrometric Chart for HVAC\n// Library: echarts 5.5.1 | JavaScript 22.22.3\n// Quality: 90/100 | Created: 2026-06-16\n//# anyplot-orientation: landscape\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Psychrometric model (ASHRAE, standard sea-level pressure) --------------\nconst P = 101325; // atmospheric pressure, Pa\nconst Pk = 101.325; // atmospheric pressure, kPa\nconst Ra = 0.287042; // gas constant for dry air, kJ/(kg·K)\nconst T_MIN = -10;\nconst T_MAX = 50;\nconst W_MAX = 30; // humidity-ratio ceiling, g/kg dry air\n\n// Saturation vapour pressure over water (Pa), ASHRAE fit, tc in °C.\nconst pws = (tc) => {\n  const T = tc + 273.15;\n  return Math.exp(\n    -5800.2206 / T +\n      1.3914993 -\n      0.048640239 * T +\n      0.000041764768 * T * T -\n      0.000000014452093 * T * T * T +\n      6.5459673 * Math.log(T),\n  );\n};\n\n// Humidity ratio (g/kg dry air) from a vapour partial pressure (Pa).\nconst wFromPw = (pw) => (1000 * 0.621945 * pw) / (P - pw);\n\n// Saturation humidity ratio (g/kg) at a given dry-bulb temperature.\nconst wSat = (tc) => wFromPw(pws(tc));\n\n// Humidity ratio (g/kg) at dry-bulb tc and relative humidity rh (0–1).\nconst wAtRH = (tc, rh) => wFromPw(rh * pws(tc));\n\n// Sample a property line over the dry-bulb range, keeping only points that\n// stay inside the chart and below the saturation curve.\nconst sample = (fromT, wOfT) => {\n  const pts = [];\n  for (let T = fromT; T <= T_MAX + 1e-9; T += 0.5) {\n    const W = wOfT(T);\n    if (W >= 0 && W <= W_MAX + 1e-6 && W <= wSat(T) + 0.05) pts.push([T, W]);\n  }\n  return pts;\n};\n\n// Constant relative-humidity curve (10–100 %).\nconst rhCurve = (rh) => sample(T_MIN, (T) => wAtRH(T, rh));\n\n// Constant-enthalpy line (kJ/kg dry air): h = 1.006·T + W·(2501 + 1.86·T).\nconst enthalpyCurve = (h) =>\n  sample(T_MIN, (T) => (1000 * (h - 1.006 * T)) / (2501 + 1.86 * T));\n\n// Constant specific-volume line (m³/kg dry air).\nconst volumeCurve = (v) =>\n  sample(\n    T_MIN,\n    (T) => ((v * Pk) / (Ra * (T + 273.15)) - 1) / 1.607858 * 1000,\n  );\n\n// Constant wet-bulb line (°C), starting from saturation at tw.\nconst wetBulbCurve = (tw) => {\n  const wsw = wSat(tw) / 1000; // kg/kg at saturation\n  return sample(tw, (T) => {\n    const Wkg =\n      ((2501 - 2.326 * tw) * wsw - 1.006 * (T - tw)) /\n      (2501 + 1.86 * T - 4.186 * tw);\n    return Wkg * 1000;\n  });\n};\n\n// --- Build the property-line families ---------------------------------------\nconst series = [];\n\n// Constant relative-humidity curves: saturation (100 %) is the brand-green\n// boundary; the rest are blue. Label every other curve directly on its top end.\nconst RH_VALUES = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];\nRH_VALUES.forEach((rh) => {\n  const saturation = rh === 100;\n  const labelled = rh % 20 === 0;\n  series.push({\n    name: saturation ? \"Saturation (100% RH)\" : rh === 60 ? \"Relative humidity\" : undefined,\n    type: \"line\",\n    data: rhCurve(rh / 100),\n    smooth: true,\n    showSymbol: false,\n    silent: true,\n    z: saturation ? 6 : 3,\n    itemStyle: { color: saturation ? t.palette[0] : t.palette[2] },\n    lineStyle: {\n      color: saturation ? t.palette[0] : t.palette[2],\n      width: saturation ? 4 : 1.6,\n      opacity: saturation ? 1 : 0.85,\n    },\n    endLabel: {\n      show: labelled,\n      formatter: `${rh}%`,\n      color: saturation ? t.palette[0] : t.palette[2],\n      fontSize: 14,\n      fontWeight: saturation ? \"bold\" : \"normal\",\n      distance: 4,\n    },\n  });\n});\n\n// Constant-enthalpy lines (ochre, dashed), labelled at their upper-left end.\nconst ENTHALPY_VALUES = [20, 40, 60, 80, 100];\nENTHALPY_VALUES.forEach((h, i) => {\n  series.push({\n    name: i === 0 ? \"Enthalpy (kJ/kg)\" : undefined,\n    type: \"line\",\n    data: enthalpyCurve(h).reverse(),\n    showSymbol: false,\n    silent: true,\n    z: 2,\n    itemStyle: { color: t.palette[3] },\n    lineStyle: { color: t.palette[3], width: 1.5, type: \"dashed\" },\n    endLabel: {\n      show: true,\n      formatter: `${h}`,\n      color: t.palette[3],\n      fontSize: 13,\n      distance: 5,\n    },\n  });\n});\n\n// Constant wet-bulb lines (cyan, dotted, subtle).\nconst WETBULB_VALUES = [5, 10, 15, 20, 25];\nWETBULB_VALUES.forEach((tw, i) => {\n  series.push({\n    name: i === 0 ? \"Wet-bulb (°C)\" : undefined,\n    type: \"line\",\n    data: wetBulbCurve(tw).reverse(),\n    showSymbol: false,\n    silent: true,\n    z: 1,\n    itemStyle: { color: t.palette[5] },\n    lineStyle: { color: t.palette[5], width: 1.2, type: \"dotted\", opacity: 0.8 },\n    endLabel: {\n      show: true,\n      formatter: `${tw}`,\n      color: t.palette[5],\n      fontSize: 12,\n      distance: 4,\n    },\n  });\n});\n\n// Constant specific-volume lines (lavender, dashed), labelled at the bottom end.\nconst VOLUME_VALUES = [0.78, 0.82, 0.86, 0.9, 0.94];\nVOLUME_VALUES.forEach((v, i) => {\n  series.push({\n    name: i === 0 ? \"Specific volume (m³/kg)\" : undefined,\n    type: \"line\",\n    data: volumeCurve(v),\n    showSymbol: false,\n    silent: true,\n    z: 1,\n    itemStyle: { color: t.palette[1] },\n    lineStyle: { color: t.palette[1], width: 1.2, type: \"dashed\", opacity: 0.8 },\n    endLabel: {\n      show: true,\n      formatter: `${v.toFixed(2)}`,\n      color: t.palette[1],\n      fontSize: 12,\n      distance: 4,\n    },\n  });\n});\n\n// --- Comfort zone (20–26 °C, 30–60 % RH) ------------------------------------\nconst comfortLow = wAtRH(20, 0.3);\nconst comfortHigh = wAtRH(26, 0.6);\nseries.push({\n  name: \"Comfort zone\",\n  type: \"line\",\n  data: [],\n  silent: true,\n  z: 4,\n  lineStyle: { opacity: 0 },\n  itemStyle: { color: t.palette[0] },\n  markArea: {\n    itemStyle: { color: t.palette[0], opacity: 0.14 },\n    label: {\n      show: true,\n      position: \"inside\",\n      formatter: \"Comfort\\nzone\",\n      color: t.ink,\n      fontSize: 15,\n      fontWeight: \"bold\",\n    },\n    data: [\n      [\n        { xAxis: 20, yAxis: comfortLow },\n        { xAxis: 26, yAxis: comfortHigh },\n      ],\n    ],\n  },\n});\n\n// --- Example HVAC process: cooling & dehumidification ------------------------\nconst stateA = [30, wAtRH(30, 0.6)]; // warm, humid return air\nconst stateB = [14, wAtRH(14, 0.95)]; // cooled, dehumidified supply air\nseries.push({\n  name: \"Process path\",\n  type: \"line\",\n  data: [stateA, stateB],\n  z: 7,\n  symbol: [\"circle\", \"arrow\"],\n  symbolSize: [12, 16],\n  silent: true,\n  lineStyle: { color: t.palette[4], width: 4 },\n  itemStyle: { color: t.palette[4] },\n  label: {\n    show: true,\n    color: t.ink,\n    fontSize: 14,\n    fontWeight: \"bold\",\n    formatter: (p) =>\n      p.dataIndex === 0 ? \"A · 30°C / 60%\" : \"B · 14°C / 95%\",\n    position: (p) => (p.dataIndex === 0 ? \"right\" : \"left\"),\n  },\n});\n\n// --- Init & render ----------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nchart.setOption({\n  animation: false,\n  color: t.palette,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"psychrometric-basic · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 10,\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  legend: {\n    top: 48,\n    left: \"center\",\n    textStyle: { color: t.inkSoft, fontSize: 14 },\n    itemWidth: 26,\n    icon: \"roundRect\",\n    data: [\n      \"Saturation (100% RH)\",\n      \"Relative humidity\",\n      \"Enthalpy (kJ/kg)\",\n      \"Wet-bulb (°C)\",\n      \"Specific volume (m³/kg)\",\n      \"Comfort zone\",\n      \"Process path\",\n    ],\n  },\n  grid: { left: 80, right: 110, top: 110, bottom: 80 },\n  xAxis: {\n    type: \"value\",\n    name: \"Dry-bulb temperature (°C)\",\n    nameLocation: \"middle\",\n    nameGap: 42,\n    nameTextStyle: { color: t.ink, fontSize: 17 },\n    min: T_MIN,\n    max: T_MAX,\n    interval: 5,\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  yAxis: {\n    type: \"value\",\n    position: \"right\",\n    name: \"Humidity ratio (g/kg dry air)\",\n    nameLocation: \"middle\",\n    nameGap: 52,\n    nameRotate: -90,\n    nameTextStyle: { color: t.ink, fontSize: 17 },\n    min: 0,\n    max: W_MAX,\n    interval: 5,\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { show: true, lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  series,\n});\n"}