From bc4a11dcb7fe943c060592180c417f946c5d18ac Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Fri, 26 Jun 2026 22:27:05 +0200 Subject: [PATCH] Show floating grain particles in water when grain_mass > 0 35 particles distributed across full pot height, clipped to dynamic water area via water-clip-rect updated alongside water fill level. Particle opacity transitions on grain_mass changes per step. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DGKe8JzTM2Gr1fdx9wYXTi --- web/app.js | 21 +++++++++++++++++++++ web/index.html | 42 ++++++++++++++++++++++++++++++++++++++++++ web/style.css | 13 +++++++++++++ 3 files changed, 76 insertions(+) diff --git a/web/app.js b/web/app.js index e2be24d..da0cb11 100644 --- a/web/app.js +++ b/web/app.js @@ -320,6 +320,14 @@ function currentStepWaterMass() { return sudWaterMass; } +function currentStepGrainMass() { + if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) { + const step = sudSchedule[sudStepIndex]; + if ('grain_mass' in step) return step.grain_mass; + } + return sudGrainMass; +} + function updatePotVisualization() { // Thermometer mercury column (0°C = y=174, 100°C = y=28, tube length = 146) const tubeBottom = 174; @@ -338,9 +346,14 @@ function updatePotVisualization() { const potInnerBottom = 172; const potInnerH = potInnerBottom - potInnerTop; // 142 + const particlesEl = document.getElementById('grain-particles'); + const waterClipRect = document.getElementById('water-clip-rect'); + if (sudEmpty || sudVolumen <= 0) { waterFillEl.setAttribute('height', '0'); waterWaveEl.setAttribute('d', ''); + if (waterClipRect) waterClipRect.setAttribute('height', '0'); + if (particlesEl) particlesEl.style.opacity = '0'; return; } @@ -350,6 +363,14 @@ function updatePotVisualization() { waterFillEl.setAttribute('y', waterTop.toFixed(1)); waterFillEl.setAttribute('height', waterH.toFixed(1)); waterWaveEl.setAttribute('d', wavePathD(63, 177, waterTop, 3, 28)); + + if (waterClipRect) { + waterClipRect.setAttribute('y', waterTop.toFixed(1)); + waterClipRect.setAttribute('height', waterH.toFixed(1)); + } + if (particlesEl) { + particlesEl.style.opacity = currentStepGrainMass() > 0 ? '1' : '0'; + } } // --- Status line - mirrors update_status_step_label(). --- diff --git a/web/index.html b/web/index.html index b87f72f..ebcbfcf 100644 --- a/web/index.html +++ b/web/index.html @@ -55,6 +55,9 @@ + + + @@ -93,6 +96,45 @@ fill="none" stroke="#4a9ed8" stroke-width="2" clip-path="url(#pot-clip)"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/style.css b/web/style.css index 0e20be6..845235b 100644 --- a/web/style.css +++ b/web/style.css @@ -153,6 +153,19 @@ header#connection-bar { display: block; } +/* Grain particle animation */ +@keyframes float-p { + 0%, 100% { transform: translate(0px, 0px); } + 25% { transform: translate(1.5px, -3px); } + 50% { transform: translate(-1px, 2px); } + 75% { transform: translate(2px, -1.5px); } +} +.grain-p { + fill: #c8a060; + animation: float-p var(--fp-dur, 3s) ease-in-out infinite var(--fp-del, 0s); +} +#grain-particles { transition: opacity 0.5s; } + /* Stirrer animation */ @keyframes stir-rotate { to { transform: rotate(360deg); }