diff --git a/web/app.js b/web/app.js
index c8296e3..e2e0475 100644
--- a/web/app.js
+++ b/web/app.js
@@ -150,42 +150,14 @@ function renderSimpleStates(states) {
function buildSimpleCurvePoints(states, model, fluid) {
const [s1, s2, s3, s4, s5, s6] = states;
- const n = N_CURVE_POINTS;
- const base = { model, fluid };
-
- const segments = [
+ return buildCurveFromSegments([
{ from: s1, to: s2, method: 'ps', label_from: '1' },
{ from: s2, to: s3, method: 'ph', label_from: '2' },
{ from: s3, to: s4, method: 'ph', label_from: '3' },
{ from: s4, to: s5, method: 'ps', label_from: '4' },
{ from: s5, to: s6, method: 'ph', label_from: '5' },
{ from: s6, to: s1, method: 'ph', label_from: '6' },
- ];
-
- const allPoints = [];
- for (const seg of segments) {
- const pressures = linspace(seg.from.pressure_mpa, seg.to.pressure_mpa, n);
- let curveStates;
- if (seg.method === 'ph') {
- curveStates = statesFromPh({
- ...base,
- pressures_mpa: pressures,
- enthalpies_kj_per_kg: linspace(seg.from.enthalpy_kj_per_kg, seg.to.enthalpy_kj_per_kg, n),
- });
- } else {
- curveStates = statesFromPs({
- ...base,
- pressures_mpa: pressures,
- entropies_kj_per_kg_k: linspace(seg.from.entropy_kj_per_kg_k, seg.to.entropy_kj_per_kg_k, n),
- });
- }
- for (let i = 0; i < curveStates.length - 1; i++) {
- const pt = curveStates[i];
- if (i === 0) pt.label = seg.label_from;
- allPoints.push(pt);
- }
- }
- return allPoints;
+ ], model, fluid);
}
function renderSimpleRecupProfile(states, model, fluid) {
@@ -427,26 +399,9 @@ function renderRecompRecupProfiles(states, model, fluid) {
} catch { /* skip */ }
}
-function buildRecompCurvePoints(states, model, fluid) {
- const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = states;
+function buildCurveFromSegments(segments, model, fluid) {
const n = N_CURVE_POINTS;
const base = { model, fluid };
-
- // The recompression cycle has two paths that merge/split.
- // For the T-s / h-s diagrams, trace the main flow path:
- // 1→2→3→4→5→6→7→8→9→1 (skip 10, it's the recomp branch).
- const segments = [
- { from: s1, to: s2, method: 'ps', label_from: '1' },
- { from: s2, to: s3, method: 'ph', label_from: '2' },
- { from: s3, to: s4, method: 'ph', label_from: '3' },
- { from: s4, to: s5, method: 'ph', label_from: '4' },
- { from: s5, to: s6, method: 'ph', label_from: '5' },
- { from: s6, to: s7, method: 'ps', label_from: '6' },
- { from: s7, to: s8, method: 'ph', label_from: '7' },
- { from: s8, to: s9, method: 'ph', label_from: '8' },
- { from: s9, to: s1, method: 'ph', label_from: '9' },
- ];
-
const allPoints = [];
for (const seg of segments) {
const pressures = linspace(seg.from.pressure_mpa, seg.to.pressure_mpa, n);
@@ -473,29 +428,72 @@ function buildRecompCurvePoints(states, model, fluid) {
return allPoints;
}
+function buildRecompCurvePoints(states, model, fluid) {
+ const [s1, s2, s3, s4, s5, s6, s7, s8, s9, s10] = states;
+
+ // Main flow path: 1→2→3→4→5→6→7→8→9→1.
+ const mainSegments = [
+ { from: s1, to: s2, method: 'ps', label_from: '1' },
+ { from: s2, to: s3, method: 'ph', label_from: '2' },
+ { from: s3, to: s4, method: 'ph', label_from: '3' },
+ { from: s4, to: s5, method: 'ph', label_from: '4' },
+ { from: s5, to: s6, method: 'ph', label_from: '5' },
+ { from: s6, to: s7, method: 'ps', label_from: '6' },
+ { from: s7, to: s8, method: 'ph', label_from: '7' },
+ { from: s8, to: s9, method: 'ph', label_from: '8' },
+ { from: s9, to: s1, method: 'ph', label_from: '9' },
+ ];
+
+ // Recompressor branch: 9→10→4.
+ const branchSegments = [
+ { from: s9, to: s10, method: 'ps' },
+ { from: s10, to: s4, method: 'ph', label_from: '10' },
+ ];
+
+ const mainPoints = buildCurveFromSegments(mainSegments, model, fluid);
+ const branchPoints = buildCurveFromSegments(branchSegments, model, fluid);
+
+ return { mainPoints, branchPoints };
+}
+
function renderRecompCharts(states, model, fluid) {
- let curvePoints;
+ let mainPoints, branchPoints;
try {
- curvePoints = buildRecompCurvePoints(states, model, fluid);
+ ({ mainPoints, branchPoints } = buildRecompCurvePoints(states, model, fluid));
} catch {
- // Fallback: straight lines between main-path state points.
+ // Fallback: straight lines between state points.
const mainPath = [0, 1, 2, 3, 4, 5, 6, 7, 8];
- curvePoints = mainPath.map(i => ({ ...states[i], label: String(i + 1) }));
+ mainPoints = mainPath.map(i => ({ ...states[i], label: String(i + 1) }));
+ branchPoints = [
+ { ...states[9], label: '10' },
+ { ...states[3] },
+ ];
}
- const tsPoints = toChartPoints(curvePoints, 'entropy_kj_per_kg_k', 'temperature_c');
- const hsPoints = toChartPoints(curvePoints, 'entropy_kj_per_kg_k', 'enthalpy_kj_per_kg');
- const pvPoints = curvePoints.map(p => ({ x: 1 / p.density_kg_per_m3, y: p.pressure_mpa, label: p.label }));
- const phPoints = toChartPoints(curvePoints, 'enthalpy_kj_per_kg', 'pressure_mpa');
+ function toBranches(points, xKey, yKey) {
+ return [toChartPoints(points, xKey, yKey)];
+ }
+ function toBranchesPv(points) {
+ return [points.map(p => ({ x: 1 / p.density_kg_per_m3, y: p.pressure_mpa, label: p.label }))];
+ }
+
+ const tsPoints = toChartPoints(mainPoints, 'entropy_kj_per_kg_k', 'temperature_c');
+ const tsBranches = toBranches(branchPoints, 'entropy_kj_per_kg_k', 'temperature_c');
+ const hsPoints = toChartPoints(mainPoints, 'entropy_kj_per_kg_k', 'enthalpy_kj_per_kg');
+ const hsBranches = toBranches(branchPoints, 'entropy_kj_per_kg_k', 'enthalpy_kj_per_kg');
+ const pvPoints = mainPoints.map(p => ({ x: 1 / p.density_kg_per_m3, y: p.pressure_mpa, label: p.label }));
+ const pvBranches = toBranchesPv(branchPoints);
+ const phPoints = toChartPoints(mainPoints, 'enthalpy_kj_per_kg', 'pressure_mpa');
+ const phBranches = toBranches(branchPoints, 'enthalpy_kj_per_kg', 'pressure_mpa');
if (!rtsChart) rtsChart = createCycleChart(document.getElementById('r-chart-ts'), { title: 'T–s', xLabel: 's (kJ/kg·K)', yLabel: 'T (°C)' });
- rtsChart.update(tsPoints);
+ rtsChart.update(tsPoints, { branches: tsBranches });
if (!rhsChart) rhsChart = createCycleChart(document.getElementById('r-chart-hs'), { title: 'h–s', xLabel: 's (kJ/kg·K)', yLabel: 'h (kJ/kg)' });
- rhsChart.update(hsPoints);
+ rhsChart.update(hsPoints, { branches: hsBranches });
if (!rpvChart) rpvChart = createCycleChart(document.getElementById('r-chart-pv'), { title: 'P–v', xLabel: 'v (m³/kg)', yLabel: 'P (MPa)' });
- rpvChart.update(pvPoints);
+ rpvChart.update(pvPoints, { branches: pvBranches });
if (!rphChart) rphChart = createCycleChart(document.getElementById('r-chart-ph'), { title: 'P–h', xLabel: 'h (kJ/kg)', yLabel: 'P (MPa)' });
- rphChart.update(phPoints);
+ rphChart.update(phPoints, { branches: phBranches });
}
function showRecompError(msg) {
@@ -532,13 +530,29 @@ let recompNeedsCalc = true;
function switchCycle(cycle) {
if (cycle === activeCycle) return;
+
+ const oldView = document.getElementById(
+ activeCycle === 'simple' ? 'simple-cycle' : 'recompression-cycle'
+ );
+ const newView = document.getElementById(
+ cycle === 'simple' ? 'simple-cycle' : 'recompression-cycle'
+ );
+
activeCycle = cycle;
document.querySelectorAll('.tab').forEach(t => {
t.classList.toggle('active', t.dataset.cycle === cycle);
});
- document.getElementById('simple-cycle').hidden = (cycle !== 'simple');
- document.getElementById('recompression-cycle').hidden = (cycle !== 'recompression');
+
+ oldView.classList.add('fading-out');
+ function swap() {
+ oldView.hidden = true;
+ oldView.classList.remove('fading-out');
+ newView.hidden = false;
+ }
+ oldView.addEventListener('transitionend', swap, { once: true });
+ // Fallback if transitionend doesn't fire (e.g., rapid clicks, skipped transition).
+ setTimeout(swap, 350);
// Calculate on first switch if needed.
if (cycle === 'simple' && simpleNeedsCalc) {
diff --git a/web/cycle-chart.js b/web/cycle-chart.js
index 8b6ccb2..b5ed941 100644
--- a/web/cycle-chart.js
+++ b/web/cycle-chart.js
@@ -3,19 +3,18 @@
*
* API:
* const chart = createCycleChart(container, { title, xLabel, yLabel })
- * chart.update(points) // points = [{ x, y, label }, ...]
- *
- * Points are drawn connected in order, with the last point connecting
- * back to the first (closing the cycle). Each point gets a label.
+ * chart.update(points, { branches })
+ * points = [{ x, y, label }, ...] — main cycle (closed loop)
+ * branches = [[{ x, y, label }, ...], ...] — optional open paths
*
* To swap rendering (e.g., to a charting library), replace this file
* and keep the same create/update interface.
*/
-const CHART_WIDTH = 360;
-const CHART_HEIGHT = 280;
+const CHART_WIDTH = 576;
+const CHART_HEIGHT = 336;
const PADDING = { top: 40, right: 30, bottom: 50, left: 65 };
-const POINT_RADIUS = 4;
+const POINT_RADIUS = 9;
const COLORS = {
line: '#2563eb',
fill: 'rgba(37, 99, 235, 0.06)',
@@ -32,8 +31,6 @@ export function createCycleChart(container, { title, xLabel, yLabel }) {
container.appendChild(canvas);
const ctx = canvas.getContext('2d');
- let currentPoints = null;
-
function niceRange(min, max) {
if (min === max) {
@@ -57,15 +54,16 @@ export function createCycleChart(container, { title, xLabel, yLabel }) {
return step * mag;
}
- function draw(points, w, h) {
+ function draw(points, branches, w, h) {
ctx.clearRect(0, 0, w, h);
const plotW = w - PADDING.left - PADDING.right;
const plotH = h - PADDING.top - PADDING.bottom;
- // Compute ranges from data.
- const xs = points.map(p => p.x);
- const ys = points.map(p => p.y);
+ // Compute ranges from all data (main + branches).
+ const allPoints = points.concat(...branches);
+ const xs = allPoints.map(p => p.x);
+ const ys = allPoints.map(p => p.y);
const [xMin, xMax] = niceRange(Math.min(...xs), Math.max(...xs));
const [yMin, yMax] = niceRange(Math.min(...ys), Math.max(...ys));
@@ -128,35 +126,42 @@ export function createCycleChart(container, { title, xLabel, yLabel }) {
ctx.lineWidth = 2;
ctx.stroke();
- // Points and labels (only for labeled state points).
- // Collect labeled points first to compute centroid for offset direction.
- const labeled = points.filter(p => p.label);
- const centX = labeled.reduce((s, p) => s + toCanvasX(p.x), 0) / labeled.length;
- const centY = labeled.reduce((s, p) => s + toCanvasY(p.y), 0) / labeled.length;
+ // Branch lines (open paths, dashed).
+ for (const branch of branches) {
+ if (branch.length < 2) continue;
+ ctx.beginPath();
+ ctx.moveTo(toCanvasX(branch[0].x), toCanvasY(branch[0].y));
+ for (let i = 1; i < branch.length; i++) {
+ ctx.lineTo(toCanvasX(branch[i].x), toCanvasY(branch[i].y));
+ }
+ ctx.strokeStyle = COLORS.line;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([6, 4]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ // State points: hollow circles with number inside.
+ const labeled = allPoints.filter(p => p.label);
for (const p of labeled) {
const cx = toCanvasX(p.x);
const cy = toCanvasY(p.y);
+ // White fill to clear the cycle line behind the circle.
ctx.beginPath();
ctx.arc(cx, cy, POINT_RADIUS, 0, Math.PI * 2);
- ctx.fillStyle = COLORS.point;
+ ctx.fillStyle = '#fff';
ctx.fill();
-
- // Push label away from the centroid so it doesn't overlap the cycle.
- let dx = cx - centX;
- let dy = cy - centY;
- const len = Math.sqrt(dx * dx + dy * dy) || 1;
- dx = dx / len * 14;
- dy = dy / len * 14;
+ ctx.strokeStyle = COLORS.point;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
ctx.fillStyle = COLORS.label;
- ctx.font = 'bold 11px -apple-system, sans-serif';
+ ctx.font = 'bold 9px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
- ctx.fillText(p.label, cx + dx, cy + dy);
+ ctx.fillText(p.label, cx, cy);
}
- ctx.textBaseline = 'alphabetic';
// Title.
ctx.fillStyle = COLORS.title;
@@ -183,15 +188,27 @@ export function createCycleChart(container, { title, xLabel, yLabel }) {
return v.toFixed(3);
}
- function update(points) {
- currentPoints = points;
- canvas.style.width = CHART_WIDTH + 'px';
- canvas.style.height = CHART_HEIGHT + 'px';
- canvas.width = CHART_WIDTH * dpr;
- canvas.height = CHART_HEIGHT * dpr;
+ let lastPoints = null, lastBranches = [];
+
+ function render() {
+ if (!lastPoints) return;
+ const w = Math.min(CHART_WIDTH, container.clientWidth || CHART_WIDTH);
+ const h = CHART_HEIGHT;
+ canvas.style.width = w + 'px';
+ canvas.style.height = h + 'px';
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- draw(points, CHART_WIDTH, CHART_HEIGHT);
+ draw(lastPoints, lastBranches, w, h);
}
+ function update(points, { branches = [] } = {}) {
+ lastPoints = points;
+ lastBranches = branches;
+ render();
+ }
+
+ new ResizeObserver(render).observe(container);
+
return { update };
}
diff --git a/web/index.html b/web/index.html
index 17fec79..7463939 100644
--- a/web/index.html
+++ b/web/index.html
@@ -131,20 +131,22 @@
Performance
State Points
-
-
-
- | # |
- Location |
- T (°C) |
- P (MPa) |
- ρ (kg/m³) |
- h (kJ/kg) |
- s (kJ/kg·K) |
-
-
-
-
+
@@ -227,7 +229,7 @@
Inputs