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

- - - - - - - - - - - - - -
#LocationT (°C)P (MPa)ρ (kg/m³)h (kJ/kg)s (kJ/kg·K)
+
+ + + + + + + + + + + + + +
#LocationT (°C)P (MPa)ρ (kg/m³)h (kJ/kg)s (kJ/kg·K)
+
@@ -227,7 +229,7 @@

Inputs

LT Recuperator