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);