From 0f24223ea772ea936c794b6f7bd3c17195a96ba9 Mon Sep 17 00:00:00 2001 From: IGUCHI Masato <67450288+iguchi-lab@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:14:46 +0900 Subject: [PATCH 1/5] Match enthalpy lines and axis layout --- web-release/air/psychrometric-chart.js | 91 ++++++++++++++++++-------- 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/web-release/air/psychrometric-chart.js b/web-release/air/psychrometric-chart.js index 9129da0..8e69ba1 100644 --- a/web-release/air/psychrometric-chart.js +++ b/web-release/air/psychrometric-chart.js @@ -74,6 +74,34 @@ export function renderPsychrometricChart(svg, state, pressureKpa = 101.325) { const enthalpyAxisSlope = (0.037 - 0.004) / (29 - (-10.3)); const enthalpyAxisIntercept = 0.004 - enthalpyAxisSlope * (-10.3); const enthalpyAxisHumidity = (x) => enthalpyAxisSlope * x + enthalpyAxisIntercept; + const [enthalpyAxisStartX, enthalpyAxisStartY] = project(-10.3, 0.004); + const [enthalpyAxisEndX, enthalpyAxisEndY] = project(29, 0.037); + const enthalpyAxisDx = enthalpyAxisEndX - enthalpyAxisStartX; + const enthalpyAxisDy = enthalpyAxisEndY - enthalpyAxisStartY; + const enthalpyAxisLength = Math.hypot(enthalpyAxisDx, enthalpyAxisDy); + const enthalpyAxisAngle = Math.atan2(enthalpyAxisDy, enthalpyAxisDx) * 180 / Math.PI; + const enthalpyAxisNormalX = -enthalpyAxisDy / enthalpyAxisLength; + const enthalpyAxisNormalY = enthalpyAxisDx / enthalpyAxisLength; + const findEnthalpyAxisIntersection = (enthalpy) => { + let previous = null; + for (const temperature of sequence(-20, 60, 0.05)) { + const humidity = humidityFromEnthalpy(temperature, enthalpy); + const x = chartTemperature(temperature, humidity); + const delta = humidity - enthalpyAxisHumidity(x); + if (previous && previous.delta <= 0 && delta >= 0) { + const fraction = previous.delta / (previous.delta - delta); + const intersectionTemperature = previous.temperature + + fraction * (temperature - previous.temperature); + const intersectionHumidity = humidityFromEnthalpy(intersectionTemperature, enthalpy); + const intersectionX = chartTemperature(intersectionTemperature, intersectionHumidity); + return visible(intersectionX, intersectionHumidity) + ? { temperature: intersectionTemperature, x: intersectionX, humidity: intersectionHumidity } + : null; + } + previous = { temperature, delta }; + } + return null; + }; svg.replaceChildren(); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); @@ -118,33 +146,42 @@ export function renderPsychrometricChart(svg, state, pressureKpa = 101.325) { } for (const enthalpy of sequence(0, 120, 10)) { - const points = sequence(-10, 50, 0.25).map((temperature) => { + const axisPoint = findEnthalpyAxisIntersection(enthalpy); + const points = axisPoint ? [[axisPoint.x, axisPoint.humidity]] : []; + const startTemperature = axisPoint ? axisPoint.temperature + 0.05 : -10; + for (const temperature of sequence(startTemperature, 50, 0.1)) { const humidity = humidityFromEnthalpy(temperature, enthalpy); - const saturation = humidityRatio(temperature, 100, pressureKpa); const x = chartTemperature(temperature, humidity); - const belowAxis = humidity <= enthalpyAxisHumidity(x); - return humidity >= 0 && humidity <= saturation && belowAxis && visible(x, humidity) ? [x, humidity] : null; + const reachesInsideChart = humidity >= 0 + && humidity <= enthalpyAxisHumidity(x) + && visible(x, humidity); + if (reachesInsideChart) points.push([x, humidity]); + } + addPath(grid, points, project, { + stroke: "#5b8fd9", "stroke-width": 0.85, + "data-role": "enthalpy-line", + "data-enthalpy": enthalpy, + "data-axis-hit": String(Boolean(axisPoint)), }); - addPath(grid, points, project, { stroke: "#5b8fd9", "stroke-width": 0.75 }); - const scalePoints = sequence(-10, 50, 0.05).map((temperature) => { - const humidity = humidityFromEnthalpy(temperature, enthalpy); - const x = chartTemperature(temperature, humidity); - const axisHumidity = enthalpyAxisHumidity(x); - return humidity >= axisHumidity && humidity <= axisHumidity + 0.0005 && visible(x, humidity) - ? [x, humidity] - : null; - }); - addPath(grid, scalePoints, project, { - stroke: "#1d4ed8", "stroke-width": 1.1, "data-role": "enthalpy-scale", - }); - const labelPoint = scalePoints.find(Boolean); - if (labelPoint) { - const [labelX, labelY] = project(labelPoint[0], labelPoint[1]); + if (axisPoint) { + const [tickX, tickY] = project(axisPoint.x, axisPoint.humidity); + const tickHalfLength = 4; + grid.append(svgElement("line", { + x1: tickX - enthalpyAxisNormalX * tickHalfLength, + y1: tickY - enthalpyAxisNormalY * tickHalfLength, + x2: tickX + enthalpyAxisNormalX * tickHalfLength, + y2: tickY + enthalpyAxisNormalY * tickHalfLength, + stroke: "#1d4ed8", "stroke-width": 1, + "data-role": "enthalpy-scale", + })); + const labelOffset = 11; + const labelX = tickX - enthalpyAxisNormalX * labelOffset; + const labelY = tickY - enthalpyAxisNormalY * labelOffset; grid.append(svgElement("text", { - x: labelX - 4, y: labelY + 3, "text-anchor": "end", + x: labelX, y: labelY + 3, "text-anchor": "middle", fill: "#1d4ed8", "font-size": 10, "font-weight": 650, - transform: `rotate(-40 ${labelX - 4} ${labelY + 3})`, + transform: `rotate(${enthalpyAxisAngle} ${labelX} ${labelY + 3})`, "data-role": "enthalpy-tick", }, String(enthalpy))); } @@ -170,22 +207,24 @@ export function renderPsychrometricChart(svg, state, pressureKpa = 101.325) { } } - const [enthalpyAxisStartX, enthalpyAxisStartY] = project(-10.3, 0.004); - const [enthalpyAxisEndX, enthalpyAxisEndY] = project(29, 0.037); grid.append(svgElement("line", { x1: enthalpyAxisStartX, y1: enthalpyAxisStartY, x2: enthalpyAxisEndX, y2: enthalpyAxisEndY, stroke: "#17212b", "stroke-width": 1.4, "data-role": "enthalpy-axis", })); - const [enthalpyTitleX, enthalpyTitleY] = project(3, enthalpyAxisHumidity(3)); + const [enthalpyTitleBaseX, enthalpyTitleBaseY] = project(3, enthalpyAxisHumidity(3)); + const enthalpyTitleOffset = 24; + const enthalpyTitleX = enthalpyTitleBaseX + enthalpyAxisNormalX * enthalpyTitleOffset; + const enthalpyTitleY = enthalpyTitleBaseY + enthalpyAxisNormalY * enthalpyTitleOffset; grid.append(svgElement("text", { - x: enthalpyTitleX, y: enthalpyTitleY - 8, + x: enthalpyTitleX, y: enthalpyTitleY, "text-anchor": "middle", fill: "#17212b", "font-size": 13, "font-weight": 700, - transform: `rotate(-40 ${enthalpyTitleX} ${enthalpyTitleY - 8})`, + transform: `rotate(${enthalpyAxisAngle} ${enthalpyTitleX} ${enthalpyTitleY})`, stroke: "#ffffff", "stroke-width": 4, "paint-order": "stroke", "data-role": "enthalpy-title", + "data-axis-offset": enthalpyTitleOffset, }, "比エンタルピー h [kJ/kg(DA)]")); const axes = svgElement("g", { fill: "#334155", "font-size": 11 }); From 91e8ba5044b7c175f40c511f1958c1c2ddb016eb Mon Sep 17 00:00:00 2001 From: IGUCHI Masato <67450288+iguchi-lab@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:15:00 +0900 Subject: [PATCH 2/5] Handle both enthalpy axis crossing directions --- web-release/air/psychrometric-chart.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web-release/air/psychrometric-chart.js b/web-release/air/psychrometric-chart.js index 8e69ba1..fa3e94e 100644 --- a/web-release/air/psychrometric-chart.js +++ b/web-release/air/psychrometric-chart.js @@ -88,7 +88,9 @@ export function renderPsychrometricChart(svg, state, pressureKpa = 101.325) { const humidity = humidityFromEnthalpy(temperature, enthalpy); const x = chartTemperature(temperature, humidity); const delta = humidity - enthalpyAxisHumidity(x); - if (previous && previous.delta <= 0 && delta >= 0) { + const crossedAxis = previous + && ((previous.delta <= 0 && delta >= 0) || (previous.delta >= 0 && delta <= 0)); + if (crossedAxis) { const fraction = previous.delta / (previous.delta - delta); const intersectionTemperature = previous.temperature + fraction * (temperature - previous.temperature); From ecadb31af10864557cbf2366a8083bf8fa1df900 Mon Sep 17 00:00:00 2001 From: IGUCHI Masato <67450288+iguchi-lab@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:15:17 +0900 Subject: [PATCH 3/5] Test enthalpy lines reaching the axis --- tests_verified/psychrometric-chart.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests_verified/psychrometric-chart.test.js b/tests_verified/psychrometric-chart.test.js index 331fe3f..d25940a 100644 --- a/tests_verified/psychrometric-chart.test.js +++ b/tests_verified/psychrometric-chart.test.js @@ -71,6 +71,12 @@ test("chart renders SVG paths and the calculated state marker", () => { assert.ok(svg.children.length > 5); assert.ok(svg.children.some((child) => child.name === "g")); assert.equal(findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-axis").length, 1); - assert.equal(findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-title")[0].textContent, "比エンタルピー h [kJ/kg(DA)]"); - assert.ok(findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-tick").length >= 10); + const enthalpyTitle = findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-title")[0]; + assert.equal(enthalpyTitle.textContent, "比エンタルピー h [kJ/kg(DA)]"); + assert.equal(enthalpyTitle.attributes["data-axis-offset"], "24"); + const enthalpyLines = findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-line"); + assert.equal(enthalpyLines.length, 13); + assert.ok(enthalpyLines.every((node) => node.attributes["data-axis-hit"] === "true")); + assert.ok(enthalpyLines.every((node) => node.attributes.d.startsWith("M"))); + assert.equal(findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-tick").length, 13); }); From 2ab01fa00df01611dee8a7798cf1500cd02e2534 Mon Sep 17 00:00:00 2001 From: IGUCHI Masato <67450288+iguchi-lab@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:18:37 +0900 Subject: [PATCH 4/5] Anchor zero enthalpy line at chart boundary --- web-release/air/psychrometric-chart.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web-release/air/psychrometric-chart.js b/web-release/air/psychrometric-chart.js index fa3e94e..ca7a7e7 100644 --- a/web-release/air/psychrometric-chart.js +++ b/web-release/air/psychrometric-chart.js @@ -148,7 +148,13 @@ export function renderPsychrometricChart(svg, state, pressureKpa = 101.325) { } for (const enthalpy of sequence(0, 120, 10)) { - const axisPoint = findEnthalpyAxisIntersection(enthalpy); + const calculatedAxisPoint = findEnthalpyAxisIntersection(enthalpy); + const axisStartTemperature = ( + -10.3 + 0.004 * CP_V * 50 / CP_DA + ) / (1 + 0.004 * CP_V / CP_DA); + const axisPoint = calculatedAxisPoint ?? (enthalpy === 0 + ? { temperature: axisStartTemperature, x: -10.3, humidity: 0.004 } + : null); const points = axisPoint ? [[axisPoint.x, axisPoint.humidity]] : []; const startTemperature = axisPoint ? axisPoint.temperature + 0.05 : -10; for (const temperature of sequence(startTemperature, 50, 0.1)) { @@ -166,7 +172,7 @@ export function renderPsychrometricChart(svg, state, pressureKpa = 101.325) { "data-axis-hit": String(Boolean(axisPoint)), }); - if (axisPoint) { + if (axisPoint && enthalpy > 0) { const [tickX, tickY] = project(axisPoint.x, axisPoint.humidity); const tickHalfLength = 4; grid.append(svgElement("line", { From 65a34652b066448f8caf66d46a0bf021cfc759ee Mon Sep 17 00:00:00 2001 From: IGUCHI Masato <67450288+iguchi-lab@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:18:38 +0900 Subject: [PATCH 5/5] Match reference enthalpy tick range --- tests_verified/psychrometric-chart.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests_verified/psychrometric-chart.test.js b/tests_verified/psychrometric-chart.test.js index d25940a..e400cb1 100644 --- a/tests_verified/psychrometric-chart.test.js +++ b/tests_verified/psychrometric-chart.test.js @@ -78,5 +78,9 @@ test("chart renders SVG paths and the calculated state marker", () => { assert.equal(enthalpyLines.length, 13); assert.ok(enthalpyLines.every((node) => node.attributes["data-axis-hit"] === "true")); assert.ok(enthalpyLines.every((node) => node.attributes.d.startsWith("M"))); - assert.equal(findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-tick").length, 13); + const enthalpyTicks = findNodes(svg, (node) => node.attributes["data-role"] === "enthalpy-tick"); + assert.equal(enthalpyTicks.length, 12); + assert.deepEqual(enthalpyTicks.map((node) => node.textContent), [ + "10", "20", "30", "40", "50", "60", "70", "80", "90", "100", "110", "120", + ]); });