From 36648472339bc006821b441e5560c6a5c11764a1 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sun, 3 May 2026 20:50:42 +0200 Subject: [PATCH] fix(ui): clear leftover inline font-size when buttons disconnect; add explode animation for sub-threshold runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standby fix: after status "ready" had run, fitReadyText left an inline pixel font-size sized for "Bereit" on the status element. Re-entering the standby branch only swapped the class and text, so the long "Standby: Drücke beide Buttons einmal" rendered at that oversized font and overflowed the lane container. Reset font-size, height, maxHeight and overflow so CSS clamp takes over again. Also bundled: explodeFromSnapshot() animation triggered on the finished→ready transition when the run was below minTimeForLeaderboard (loaded from /api/get-settings) — runs too short for the leaderboard now visually "burst" instead of flying down into a list they wouldn't enter. Co-Authored-By: Claude Opus 4.7 (1M context) --- data/index.html | 168 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 15 deletions(-) diff --git a/data/index.html b/data/index.html index 0147899..618c1cc 100644 --- a/data/index.html +++ b/data/index.html @@ -472,6 +472,23 @@ let flyAnimationActive = false; let leaderboardUpdatePending = false; + // Schwelle aus /api/get-settings (Sekunden). Läufe unterhalb dieser + // Zeit landen NICHT im Leaderboard → dafür gibt's die Explode-Animation. + let minTimeForLeaderboardSec = 5; + + function loadAnimationSettings() { + fetch("/api/get-settings") + .then((r) => r.json()) + .then((s) => { + if (typeof s.minTimeForLeaderboard === "number") { + minTimeForLeaderboardSec = s.minTimeForLeaderboard; + } + }) + .catch(() => { + /* Default behält */ + }); + } + async function loadLeaderboard() { try { const response = await fetch("/api/leaderboard"); @@ -695,6 +712,94 @@ }, slideMs); } + // Explode-Animation: Zeit war zu kurz für's Leaderboard. + // Großer Ghost an der Quellposition skaliert auf, rotiert, fadet rot; + // dazu fliegen Partikel-Kopien sternförmig nach außen und zerfallen. + function explodeFromSnapshot(srcSnap) { + if (!srcSnap) return; + + const cx = srcSnap.rect.left + srcSnap.rect.width / 2; + const cy = srcSnap.rect.top + srcSnap.rect.height / 2; + + // Haupt-Ghost (die "platzende" Zeit selbst) + const main = document.createElement("div"); + main.textContent = srcSnap.text; + Object.assign(main.style, { + position: "fixed", + left: srcSnap.rect.left + "px", + top: srcSnap.rect.top + "px", + width: srcSnap.rect.width + "px", + height: srcSnap.rect.height + "px", + margin: "0", + padding: "0", + zIndex: "9999", + pointerEvents: "none", + color: "#ff4444", + fontFamily: srcSnap.fontFamily, + fontWeight: srcSnap.fontWeight, + fontSize: srcSnap.fontSize, + lineHeight: "1", + display: "flex", + alignItems: "center", + justifyContent: "center", + textShadow: "0 0 20px #ff4444, 0 0 40px rgba(255, 100, 100, 0.7)", + transformOrigin: "center", + transition: + "transform 600ms cubic-bezier(0.2, 0.7, 0.3, 1)," + + "opacity 600ms ease-out," + + "color 200ms ease-out", + }); + document.body.appendChild(main); + main.getBoundingClientRect(); // reflow + + main.style.transform = "scale(2.4) rotate(-8deg)"; + main.style.opacity = "0"; + setTimeout(() => main.remove(), 650); + + // Partikel: kleine Kopien fliegen sternförmig nach außen + const particleCount = 12; + const baseDist = Math.max(srcSnap.rect.width, srcSnap.rect.height) * 0.9; + for (let i = 0; i < particleCount; i++) { + const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.4; + const dist = baseDist * (0.8 + Math.random() * 0.6); + const dx = Math.cos(angle) * dist; + const dy = Math.sin(angle) * dist; + const rot = (Math.random() - 0.5) * 720; + const size = 14 + Math.random() * 24; + + const p = document.createElement("div"); + p.textContent = ["✦", "✶", "★", "•", "◆"][i % 5]; + Object.assign(p.style, { + position: "fixed", + left: cx + "px", + top: cy + "px", + margin: "0", + padding: "0", + zIndex: "9998", + pointerEvents: "none", + color: i % 2 === 0 ? "#ff4444" : "#ffaa33", + fontFamily: "Arial, sans-serif", + fontWeight: "900", + fontSize: size + "px", + lineHeight: "1", + textShadow: "0 0 8px currentColor", + transform: "translate(-50%, -50%) scale(0.4)", + opacity: "1", + transition: + "transform 700ms cubic-bezier(0.2, 0.7, 0.3, 1)," + + "opacity 700ms ease-out", + }); + document.body.appendChild(p); + p.getBoundingClientRect(); // reflow + + p.style.transform = + `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) ` + + `scale(1) rotate(${rot}deg)`; + p.style.opacity = "0"; + setTimeout(() => p.remove(), 750); + } + } + // Bestimmt das Ziel-Element (erster Leaderboard-Eintrag) für eine Lane. function findFlyDest(lane) { const containerId = @@ -767,6 +872,13 @@ s1.className = "status standby large-status"; s1.textContent = "Standby: Drücke beide Buttons einmal"; time1Element.style.display = "none"; + // Inline-Styles aus vorherigen Zuständen (ready→fitReadyText, + // armed/finished→maxHeight) räumen, sonst rendert der lange + // Standby-Text mit der für „Bereit" gefitteten Riesen-Pixelgröße. + s1.style.fontSize = ""; + s1.style.height = ""; + s1.style.maxHeight = ""; + s1.style.overflow = ""; // Position über time-display, aber innerhalb des Containers if (s1.classList.contains("large-status")) { const lane1Rect = lane1Element.getBoundingClientRect(); @@ -871,6 +983,13 @@ s2.className = "status standby large-status"; s2.textContent = "Standby: Drücke beide Buttons einmal"; time2Element.style.display = "none"; + // Inline-Styles aus vorherigen Zuständen (ready→fitReadyText, + // armed/finished→maxHeight) räumen, sonst rendert der lange + // Standby-Text mit der für „Bereit" gefitteten Riesen-Pixelgröße. + s2.style.fontSize = ""; + s2.style.height = ""; + s2.style.maxHeight = ""; + s2.style.overflow = ""; // Position über time-display, aber innerhalb des Containers if (s2.classList.contains("large-status")) { const lane2Rect = lane2Element.getBoundingClientRect(); @@ -1002,12 +1121,16 @@ fetch("/api/data") .then((response) => response.json()) .then((data) => { - timer1 = data.time1; - timer2 = data.time2; - - // Alte Status-Werte sichern, BEVOR sie überschrieben werden + // Alte Status- und Timer-Werte sichern, BEVOR sie + // überschrieben werden — werden für die Animations- + // Entscheidung am Status-Übergang gebraucht. const oldStatus1 = status1; const oldStatus2 = status2; + const oldTimer1 = timer1; + const oldTimer2 = timer2; + + timer1 = data.time1; + timer2 = data.time2; // Status nur übernehmen, wenn der Wert gültig ist. // Bei unvollständiger ESP-Response (Last) bleibt der @@ -1023,22 +1146,36 @@ // Übergang finished -> ready erkennen. // Snapshot der großen Zeit JETZT einfrieren, bevor // kickDisplayScheduler/updateDisplay sie versteckt. - const fly1 = - oldStatus1 === "finished" && status1 === "ready" - ? captureSourceSnapshot(document.getElementById("time1")) - : null; - const fly2 = - oldStatus2 === "finished" && status2 === "ready" - ? captureSourceSnapshot(document.getElementById("time2")) - : null; + // Außerdem entscheiden: Fly-Down (Lap im Leaderboard) oder + // Explode (Zeit war unter minTimeForLeaderboardSec). + const transition1 = oldStatus1 === "finished" && status1 === "ready"; + const transition2 = oldStatus2 === "finished" && status2 === "ready"; + + const snap1 = transition1 + ? captureSourceSnapshot(document.getElementById("time1")) + : null; + const snap2 = transition2 + ? captureSourceSnapshot(document.getElementById("time2")) + : null; + + const explode1 = + transition1 && oldTimer1 > 0 && oldTimer1 < minTimeForLeaderboardSec; + const explode2 = + transition2 && oldTimer2 > 0 && oldTimer2 < minTimeForLeaderboardSec; kickDisplayScheduler(); // Animation auf nächsten Frame, wenn updateDisplay durch ist - if (fly1 || fly2) { + if (snap1 || snap2) { requestAnimationFrame(() => { - if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1)); - if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2)); + if (snap1) { + if (explode1) explodeFromSnapshot(snap1); + else flyDownFromSnapshot(snap1, findFlyDest(1)); + } + if (snap2) { + if (explode2) explodeFromSnapshot(snap2); + else flyDownFromSnapshot(snap2, findFlyDest(2)); + } }); } }) @@ -1135,6 +1272,7 @@ syncFromBackend(); loadLaneConfig(); loadLeaderboard(); + loadAnimationSettings(); // Leaderboard alle 5 Sekunden aktualisieren setInterval(loadLeaderboard, 5000);