/** * Pump Cycle Animation — Canvas 2D renderer + playback controller. * Adapted from DR MURPHY v2 pump-renderer.js for Gradio gr.HTML embedding. * * This file is loaded by scenario_gui.py via demo.load(js=...) at page load. * The drawPump function and animation controller are defined on globalThis * so they can be invoked from gr.HTML onerror handlers. * * Layout (left to right): * [TANK] --ICV--> [CHAMBER with PISTON] --DCV--> [DISCHARGE] * Below: Energy balance bar chart (WORK / ICV FLOW / DCV FLOW) */ function() { var _anims = {}; // ----------------------------------------------------------------------- // Color helpers // ----------------------------------------------------------------------- function parseHex(hex) { var h = hex.replace('#', ''); return [ parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16), ]; } function toHex(r, g, b) { function clamp(v) { return Math.max(0, Math.min(255, Math.round(v))); } return '#' + clamp(r).toString(16).padStart(2, '0') + clamp(g).toString(16).padStart(2, '0') + clamp(b).toString(16).padStart(2, '0'); } function lerpColor(c1, c2, t) { var a = parseHex(c1), b = parseHex(c2); return toHex( a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t ); } function tempToColor(Tc_K) { var t = Math.max(0, Math.min(1, (Tc_K - 20) / 180)); if (t < 0.44) return lerpColor('#2196F3', '#FF9800', t / 0.44); return lerpColor('#FF9800', '#F44336', (t - 0.44) / 0.56); } function enthalpyToColor(hc) { var t = Math.max(-1, Math.min(1, hc / 500)); if (t <= 0) return lerpColor('#2196F3', '#b0b0b0', t + 1); return lerpColor('#b0b0b0', '#F44336', t); } // ----------------------------------------------------------------------- // Drawing helpers // ----------------------------------------------------------------------- function roundedRect(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); } function drawArrow(ctx, x1, y1, x2, y2, thickness, color) { var headLen = Math.max(8, thickness * 3); var dx = x2 - x1, dy = y2 - y1; var angle = Math.atan2(dy, dx); ctx.save(); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = thickness; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2 - headLen * Math.cos(angle), y2 - headLen * Math.sin(angle)); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6)); ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6)); ctx.closePath(); ctx.fill(); ctx.restore(); } // ----------------------------------------------------------------------- // Main draw function // ----------------------------------------------------------------------- function drawPump(ctx, width, height, state, pumpGeom) { var angle_deg = state.angle_deg; var pc = state.pc; var Tc_K = state.Tc_K; var yp = state.yp; var yp_prev = state.yp_prev; var ICV_open_frac = state.ICV_open_frac; var DCV_open_frac = state.DCV_open_frac; var ICV_leak_kgpm = state.ICV_leak_kgpm; var DCV_leak_kgpm = state.DCV_leak_kgpm; var hc = state.hc; // 0. Clear ctx.clearRect(0, 0, width, height); ctx.fillStyle = '#f0f0ec'; ctx.fillRect(0, 0, width, height); // 1. Layout constants var margin = 24; var pumpAreaH = height * 0.70; var chamberW = width * 0.42; var chamberH = pumpAreaH * 0.55; var chamberX = (width - chamberW) / 2; var chamberY = (pumpAreaH - chamberH) / 2 + 4; var boxW = 80, boxH = chamberH * 0.5; var tankX = margin, tankY = chamberY + (chamberH - boxH) / 2; var dischX = width - margin - boxW, dischY = tankY; var valveW = 14, valveH = 30, valveMaxTravel = 18; var pistonH = 14; // 2. Pipes var pipeY = chamberY + chamberH / 2 - 4, pipeH = 8; ctx.fillStyle = '#c0c0b8'; ctx.fillRect(tankX + boxW, pipeY, chamberX - (tankX + boxW), pipeH); ctx.fillRect(chamberX + chamberW, pipeY, dischX - (chamberX + chamberW), pipeH); // 3. Tank ctx.fillStyle = '#2196F3'; roundedRect(ctx, tankX, tankY, boxW, boxH, 4); ctx.fill(); ctx.strokeStyle = '#1565C0'; ctx.lineWidth = 2; roundedRect(ctx, tankX, tankY, boxW, boxH, 4); ctx.stroke(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 13px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('TANK', tankX + boxW / 2, tankY + boxH / 2); // 4. Discharge ctx.fillStyle = '#e8e8e4'; roundedRect(ctx, dischX, dischY, boxW, boxH, 4); ctx.fill(); ctx.strokeStyle = '#808078'; ctx.lineWidth = 2; roundedRect(ctx, dischX, dischY, boxW, boxH, 4); ctx.stroke(); ctx.fillStyle = '#505050'; ctx.font = 'bold 11px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('DISCHARGE', dischX + boxW / 2, dischY + boxH / 2); // 5. Chamber var pistonFrac = Math.max(0, Math.min(1, yp)); var pistonY = chamberY + pistonFrac * (chamberH - pistonH); var fluidH = pistonY - chamberY; if (fluidH > 0) { ctx.fillStyle = tempToColor(Tc_K); ctx.fillRect(chamberX, chamberY, chamberW, fluidH); } var belowPistonY = pistonY + pistonH; var belowH = chamberY + chamberH - belowPistonY; if (belowH > 0) { ctx.fillStyle = '#d8d8d2'; ctx.fillRect(chamberX, belowPistonY, chamberW, belowH); } ctx.fillStyle = '#505050'; ctx.fillRect(chamberX, pistonY, chamberW, pistonH); var rodW = 8, rodX = chamberX + chamberW / 2 - rodW / 2; ctx.fillRect(rodX, pistonY + pistonH, rodW, chamberY + chamberH - (pistonY + pistonH)); ctx.fillStyle = '#3a3a3a'; ctx.fillRect(chamberX, pistonY + 2, chamberW, 2); ctx.fillRect(chamberX, pistonY + pistonH - 4, chamberW, 2); ctx.strokeStyle = '#404040'; ctx.lineWidth = 3; ctx.strokeRect(chamberX, chamberY, chamberW, chamberH); // 6. ICV (left) var icvOF = Math.max(0, Math.min(1, ICV_open_frac)); var icvX = chamberX - valveW + icvOF * (-valveMaxTravel); var icvY = chamberY + chamberH / 2 - valveH / 2; ctx.fillStyle = '#606060'; ctx.fillRect(chamberX - 4, icvY - 2, 4, valveH + 4); ctx.fillStyle = '#1a4fd6'; ctx.fillRect(icvX, icvY, valveW, valveH); ctx.strokeStyle = '#0d36a0'; ctx.lineWidth = 1; ctx.strokeRect(icvX, icvY, valveW, valveH); ctx.fillStyle = '#1a4fd6'; ctx.font = 'bold 10px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText('ICV', chamberX - valveW / 2 - valveMaxTravel / 2, icvY - 6); // 7. DCV (right) var dcvOF = Math.max(0, Math.min(1, DCV_open_frac)); var dcvX = chamberX + chamberW + dcvOF * valveMaxTravel; var dcvY = chamberY + chamberH / 2 - valveH / 2; ctx.fillStyle = '#606060'; ctx.fillRect(chamberX + chamberW, dcvY - 2, 4, valveH + 4); ctx.fillStyle = '#1a7a4a'; ctx.fillRect(dcvX, dcvY, valveW, valveH); ctx.strokeStyle = '#0e5230'; ctx.lineWidth = 1; ctx.strokeRect(dcvX, dcvY, valveW, valveH); ctx.fillStyle = '#1a7a4a'; ctx.font = 'bold 10px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText('DCV', chamberX + chamberW + valveW / 2 + valveMaxTravel / 2, dcvY - 6); // 8. Flow arrows var maxFlow = 5.0; var flowColor = enthalpyToColor(hc || 0); if (ICV_leak_kgpm > 0.01) { var th = Math.max(1, Math.min(5, (ICV_leak_kgpm / maxFlow) * 5)); drawArrow(ctx, tankX + boxW + 6, pipeY + pipeH / 2, chamberX - 6, pipeY + pipeH / 2, th, flowColor); } if (DCV_leak_kgpm > 0.01) { var th2 = Math.max(1, Math.min(5, (DCV_leak_kgpm / maxFlow) * 5)); drawArrow(ctx, chamberX + chamberW + 6, pipeY + pipeH / 2, dischX - 6, pipeY + pipeH / 2, th2, flowColor); } // 9. Pressure text var textY = Math.max(chamberY + 18, pistonY - 16); ctx.font = 'bold 16px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillText(pc.toFixed(0) + ' bar', chamberX + chamberW / 2 + 1, textY + 1); ctx.fillStyle = '#ffffff'; ctx.fillText(pc.toFixed(0) + ' bar', chamberX + chamberW / 2, textY); // 10. Angle indicator (top-right) ctx.fillStyle = '#1a1a1a'; ctx.font = '14px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.fillText(angle_deg.toFixed(1) + '\u00B0', width - margin, margin / 2); // 11. Temperature badge (top-left) ctx.fillStyle = tempToColor(Tc_K); ctx.font = '13px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(Tc_K.toFixed(1) + ' K', margin, margin / 2); // 12. Geometry annotation if (pumpGeom) { ctx.fillStyle = '#888880'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('\u2300' + pumpGeom.bore_mm.toFixed(1) + ' x ' + pumpGeom.stroke_mm.toFixed(0) + ' mm', width - margin, pumpAreaH - 4); } // 13. Stroke phase indicator (top-center) var isRetract = (angle_deg % 360) < 180; var phaseText = isRetract ? 'RETRACT (intake)' : 'EXTEND (compress/discharge)'; var phaseColor = isRetract ? '#1a4fd6' : '#c01a1a'; ctx.save(); ctx.font = 'bold 13px "JetBrains Mono", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = phaseColor; ctx.fillText(phaseText, width / 2, margin / 2); ctx.restore(); // 14. Valve force indicators var forceArrowLen = 22, springLen = 12; // ICV var fCX_icv = chamberX - valveW / 2 - valveMaxTravel / 2; var fY_icv = icvY - 20; drawArrow(ctx, fCX_icv - 2, fY_icv, fCX_icv - 2 + springLen, fY_icv, 2, '#b85c00'); if (ICV_leak_kgpm > 0.005) { var pLen = Math.min(forceArrowLen, Math.max(3, (ICV_leak_kgpm / maxFlow) * forceArrowLen)); drawArrow(ctx, fCX_icv + 2, fY_icv, fCX_icv + 2 - pLen, fY_icv, 2, '#1a4fd6'); } // DCV var fCX_dcv = chamberX + chamberW + valveW / 2 + valveMaxTravel / 2; var fY_dcv = dcvY - 20; drawArrow(ctx, fCX_dcv + 2, fY_dcv, fCX_dcv + 2 - springLen, fY_dcv, 2, '#b85c00'); if (DCV_leak_kgpm > 0.005) { var pLen2 = Math.min(forceArrowLen, Math.max(3, (DCV_leak_kgpm / maxFlow) * forceArrowLen)); drawArrow(ctx, fCX_dcv - 2, fY_dcv, fCX_dcv - 2 + pLen2, fY_dcv, 2, '#1a7a4a'); } // 15. Energy balance bar chart var barAreaTop = pumpAreaH + 8; var barAreaLeft = margin + 10; var barAreaWidth = width - 2 * margin - 20; var barHeight = 22, barGap = 10; var barLabelW = 90, barValueW = 80; var barMaxW = barAreaWidth - barLabelW - barValueW; // Separator ctx.save(); ctx.strokeStyle = '#a0a09a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(margin, pumpAreaH + 2); ctx.lineTo(width - margin, pumpAreaH + 2); ctx.stroke(); ctx.restore(); ctx.save(); ctx.fillStyle = '#505050'; ctx.font = 'bold 10px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('ENERGY BALANCE', barAreaLeft, barAreaTop); ctx.restore(); var barsStartY = barAreaTop + 18; var deltaYp = yp - (yp_prev !== undefined ? yp_prev : yp); var workProxy = Math.abs(deltaYp * pc); var isCompression = deltaYp < 0; var workScale = 100, flowScale = maxFlow; // Bar 1: WORK var y1 = barsStartY; var barW1 = Math.min(barMaxW, Math.max(2, (workProxy / workScale) * barMaxW)); var barColor1 = isCompression ? '#c01a1a' : '#1a7a4a'; ctx.save(); ctx.fillStyle = '#505050'; ctx.font = '11px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('WORK', barAreaLeft, y1 + barHeight / 2); ctx.fillStyle = '#d8d8d4'; ctx.fillRect(barAreaLeft + barLabelW, y1, barMaxW, barHeight); ctx.fillStyle = barColor1; ctx.fillRect(barAreaLeft + barLabelW, y1, barW1, barHeight); ctx.strokeStyle = '#a0a09a'; ctx.lineWidth = 1; ctx.strokeRect(barAreaLeft + barLabelW, y1, barMaxW, barHeight); ctx.fillStyle = '#1a1a1a'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.fillText(workProxy.toFixed(1) + (isCompression ? ' COMP' : ' EXP'), barAreaLeft + barLabelW + barMaxW + 6, y1 + barHeight / 2); ctx.restore(); // Bar 2: ICV FLOW var y2 = barsStartY + barHeight + barGap; var barW2 = Math.min(barMaxW, Math.max(0, (ICV_leak_kgpm / flowScale) * barMaxW)); ctx.save(); ctx.fillStyle = '#505050'; ctx.font = '11px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('ICV FLOW', barAreaLeft, y2 + barHeight / 2); ctx.fillStyle = '#d8d8d4'; ctx.fillRect(barAreaLeft + barLabelW, y2, barMaxW, barHeight); ctx.fillStyle = '#1a4fd6'; ctx.fillRect(barAreaLeft + barLabelW, y2, barW2, barHeight); ctx.strokeStyle = '#a0a09a'; ctx.lineWidth = 1; ctx.strokeRect(barAreaLeft + barLabelW, y2, barMaxW, barHeight); ctx.fillStyle = '#1a1a1a'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.fillText(ICV_leak_kgpm.toFixed(2) + ' kg/min', barAreaLeft + barLabelW + barMaxW + 6, y2 + barHeight / 2); ctx.restore(); // Bar 3: DCV FLOW var y3 = barsStartY + 2 * (barHeight + barGap); var barW3 = Math.min(barMaxW, Math.max(0, (DCV_leak_kgpm / flowScale) * barMaxW)); ctx.save(); ctx.fillStyle = '#505050'; ctx.font = '11px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('DCV FLOW', barAreaLeft, y3 + barHeight / 2); ctx.fillStyle = '#d8d8d4'; ctx.fillRect(barAreaLeft + barLabelW, y3, barMaxW, barHeight); ctx.fillStyle = '#1a7a4a'; ctx.fillRect(barAreaLeft + barLabelW, y3, barW3, barHeight); ctx.strokeStyle = '#a0a09a'; ctx.lineWidth = 1; ctx.strokeRect(barAreaLeft + barLabelW, y3, barMaxW, barHeight); ctx.fillStyle = '#1a1a1a'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.fillText(DCV_leak_kgpm.toFixed(2) + ' kg/min', barAreaLeft + barLabelW + barMaxW + 6, y3 + barHeight / 2); ctx.restore(); // 16. Enthalpy legend ctx.save(); ctx.fillStyle = '#888880'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('hc: ' + (hc || 0).toFixed(1) + ' kJ/kg', margin + 90, margin / 2 + 2); ctx.restore(); } // ----------------------------------------------------------------------- // Animation controller // ----------------------------------------------------------------------- function _drawFrame(st, frameIdx) { var f = st.frames; var i = Math.min(frameIdx, st.totalFrames - 1); var prevI = Math.max(0, i - 1); drawPump(st.ctx, st.canvas.width, st.canvas.height, { angle_deg: f.angle_deg[i] || 0, pc: f.pc[i] || 0, Tc_K: f.Tc_K[i] || 20, yp: f.yp[i] || 0, yp_prev: f.yp[prevI] || 0, ICV_open_frac: f.ICV_open_frac[i] || 0, DCV_open_frac: f.DCV_open_frac[i] || 0, ICV_leak_kgpm: f.ICV_leak_kgpm[i] || 0, DCV_leak_kgpm: f.DCV_leak_kgpm[i] || 0, hc: (f.hc && f.hc[i]) || 0, }, st.pumpGeom); // Update angle readout var angleEl = document.getElementById('pump-angle-' + st.uid); if (angleEl) angleEl.textContent = (f.angle_deg[i] || 0).toFixed(1) + '\u00B0'; // Update scrubber position var scrubEl = document.getElementById('pump-scrub-' + st.uid); if (scrubEl) scrubEl.value = i; } function _startPlayback(st) { var framesPerTick = Math.max(1, Math.round(st.totalFrames / 360 * st.speed)); function tick() { if (!st.isPlaying) return; // Check canvas still in DOM (Gradio may have replaced it) if (!document.getElementById('pump-anim-' + st.uid)) { st.isPlaying = false; return; } st.currentFrame = (st.currentFrame + framesPerTick) % st.totalFrames; _drawFrame(st, st.currentFrame); st.animId = requestAnimationFrame(tick); } st.animId = requestAnimationFrame(tick); } globalThis.initPumpAnimation = function(uid) { var container = document.getElementById('pump-anim-' + uid); if (!container) return; // Cancel any previous animation if (_anims._current && _anims._current.animId) { cancelAnimationFrame(_anims._current.animId); _anims._current.isPlaying = false; } var canvas = container.querySelector('canvas'); var dataDiv = document.getElementById('pump-data-' + uid); if (!canvas || !dataDiv) return; var data = JSON.parse(dataDiv.textContent); var ctx = canvas.getContext('2d'); var st = { uid: uid, ctx: ctx, canvas: canvas, frames: data.frames, pumpGeom: data.pumpGeom, totalFrames: data.frames.angle_deg.length, currentFrame: 0, isPlaying: false, speed: 1, animId: null, }; _anims[uid] = st; _anims._current = st; // Draw first frame _drawFrame(st, 0); }; globalThis.togglePumpAnim = function(uid) { var st = _anims[uid]; if (!st) return; st.isPlaying = !st.isPlaying; var btn = document.getElementById('pump-play-' + uid); if (btn) btn.textContent = st.isPlaying ? '\u23F8' : '\u25B6'; if (st.isPlaying) { _startPlayback(st); } else { if (st.animId) cancelAnimationFrame(st.animId); st.animId = null; } }; globalThis.setPumpAnimSpeed = function(uid, speed) { var st = _anims[uid]; if (st) st.speed = parseFloat(speed); }; globalThis.scrubPumpAnim = function(uid, frame) { var st = _anims[uid]; if (!st) return; st.currentFrame = parseInt(frame); // Pause on manual scrub if (st.isPlaying) { st.isPlaying = false; if (st.animId) cancelAnimationFrame(st.animId); st.animId = null; var btn = document.getElementById('pump-play-' + st.uid); if (btn) btn.textContent = '\u25B6'; } _drawFrame(st, st.currentFrame); }; }