Compare commits
2 Commits
esp32thing
...
esp32thing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3664847233 | ||
|
|
f57442c906 |
230
data/index.html
230
data/index.html
@@ -465,11 +465,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Leaderboard Funktionen
|
// Leaderboard Funktionen
|
||||||
|
// Während der Fly-Down-Animation darf das Leaderboard NICHT neu
|
||||||
|
// gerendert werden — sonst wird destEl aus dem DOM entfernt und
|
||||||
|
// getBoundingClientRect() liefert {0,0,0,0}; der Ghost fliegt dann
|
||||||
|
// sichtbar in die linke obere Ecke.
|
||||||
|
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() {
|
async function loadLeaderboard() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/leaderboard");
|
const response = await fetch("/api/leaderboard");
|
||||||
leaderboardData = await response.json();
|
leaderboardData = await response.json();
|
||||||
updateLeaderboardDisplay();
|
if (flyAnimationActive) {
|
||||||
|
leaderboardUpdatePending = true;
|
||||||
|
} else {
|
||||||
|
updateLeaderboardDisplay();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Fehler beim Laden des Leaderboards:", error);
|
console.error("Fehler beim Laden des Leaderboards:", error);
|
||||||
}
|
}
|
||||||
@@ -552,9 +580,14 @@
|
|||||||
|
|
||||||
function flyDownFromSnapshot(srcSnap, destEl) {
|
function flyDownFromSnapshot(srcSnap, destEl) {
|
||||||
if (!srcSnap || !destEl) return;
|
if (!srcSnap || !destEl) return;
|
||||||
|
if (!destEl.isConnected) return;
|
||||||
const dstRect = destEl.getBoundingClientRect();
|
const dstRect = destEl.getBoundingClientRect();
|
||||||
if (dstRect.width === 0 || dstRect.height === 0) return;
|
if (dstRect.width === 0 || dstRect.height === 0) return;
|
||||||
|
|
||||||
|
// Polling-getriebenes Re-Rendering des Leaderboards während der
|
||||||
|
// Animation unterdrücken (sonst wird destEl orphaned).
|
||||||
|
flyAnimationActive = true;
|
||||||
|
|
||||||
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
|
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
|
||||||
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
|
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
|
||||||
// setzen die Geschwister visuell an die Position, die sie VOR
|
// setzen die Geschwister visuell an die Position, die sie VOR
|
||||||
@@ -595,7 +628,39 @@
|
|||||||
|
|
||||||
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
|
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
|
||||||
const flyMs = 800;
|
const flyMs = 800;
|
||||||
|
|
||||||
|
// Sauberes Aufräumen — wird in jedem Exit-Pfad aufgerufen
|
||||||
|
const cleanup = (ghost) => {
|
||||||
|
if (ghost) ghost.remove();
|
||||||
|
if (destEl.isConnected) destEl.style.visibility = "";
|
||||||
|
siblings.forEach((s) => {
|
||||||
|
if (!s.isConnected) return;
|
||||||
|
s.style.transition = "";
|
||||||
|
s.style.transform = "";
|
||||||
|
});
|
||||||
|
flyAnimationActive = false;
|
||||||
|
// Während Animation aufgelaufenes Update jetzt nachholen
|
||||||
|
if (leaderboardUpdatePending) {
|
||||||
|
leaderboardUpdatePending = false;
|
||||||
|
updateLeaderboardDisplay();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// Wenn destEl in der Zwischenzeit aus dem DOM verschwand
|
||||||
|
// (z. B. Modus-Wechsel), Animation abbrechen statt nach (0,0)
|
||||||
|
// zu fliegen.
|
||||||
|
if (!destEl.isConnected) {
|
||||||
|
cleanup(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const destTimeSpanCheck = destEl.querySelector(".time") || destEl;
|
||||||
|
const preRect = destTimeSpanCheck.getBoundingClientRect();
|
||||||
|
if (preRect.width === 0 || preRect.height === 0) {
|
||||||
|
cleanup(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ghost = document.createElement("div");
|
const ghost = document.createElement("div");
|
||||||
ghost.textContent = srcSnap.text;
|
ghost.textContent = srcSnap.text;
|
||||||
ghost.style.position = "fixed";
|
ghost.style.position = "fixed";
|
||||||
@@ -630,7 +695,7 @@
|
|||||||
// Reflow erzwingen, damit die Anfangsposition wirkt
|
// Reflow erzwingen, damit die Anfangsposition wirkt
|
||||||
ghost.getBoundingClientRect();
|
ghost.getBoundingClientRect();
|
||||||
|
|
||||||
// Endwerte: aktuelle Position des .time-Spans im Eintrag
|
// Endwerte aus erneuter Messung (preRect war Vorab-Check)
|
||||||
const destTimeSpan = destEl.querySelector(".time") || destEl;
|
const destTimeSpan = destEl.querySelector(".time") || destEl;
|
||||||
const destTimeRect = destTimeSpan.getBoundingClientRect();
|
const destTimeRect = destTimeSpan.getBoundingClientRect();
|
||||||
const destStyle = window.getComputedStyle(destTimeSpan);
|
const destStyle = window.getComputedStyle(destTimeSpan);
|
||||||
@@ -643,18 +708,98 @@
|
|||||||
ghost.style.color = destStyle.color;
|
ghost.style.color = destStyle.color;
|
||||||
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
|
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => cleanup(ghost), flyMs + 20);
|
||||||
ghost.remove();
|
|
||||||
destEl.style.visibility = "";
|
|
||||||
// Sibling-Transforms aufräumen (sie sind eh schon bei 0)
|
|
||||||
siblings.forEach((s) => {
|
|
||||||
s.style.transition = "";
|
|
||||||
s.style.transform = "";
|
|
||||||
});
|
|
||||||
}, flyMs + 20);
|
|
||||||
}, slideMs);
|
}, 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.
|
// Bestimmt das Ziel-Element (erster Leaderboard-Eintrag) für eine Lane.
|
||||||
function findFlyDest(lane) {
|
function findFlyDest(lane) {
|
||||||
const containerId =
|
const containerId =
|
||||||
@@ -727,6 +872,13 @@
|
|||||||
s1.className = "status standby large-status";
|
s1.className = "status standby large-status";
|
||||||
s1.textContent = "Standby: Drücke beide Buttons einmal";
|
s1.textContent = "Standby: Drücke beide Buttons einmal";
|
||||||
time1Element.style.display = "none";
|
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
|
// Position über time-display, aber innerhalb des Containers
|
||||||
if (s1.classList.contains("large-status")) {
|
if (s1.classList.contains("large-status")) {
|
||||||
const lane1Rect = lane1Element.getBoundingClientRect();
|
const lane1Rect = lane1Element.getBoundingClientRect();
|
||||||
@@ -831,6 +983,13 @@
|
|||||||
s2.className = "status standby large-status";
|
s2.className = "status standby large-status";
|
||||||
s2.textContent = "Standby: Drücke beide Buttons einmal";
|
s2.textContent = "Standby: Drücke beide Buttons einmal";
|
||||||
time2Element.style.display = "none";
|
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
|
// Position über time-display, aber innerhalb des Containers
|
||||||
if (s2.classList.contains("large-status")) {
|
if (s2.classList.contains("large-status")) {
|
||||||
const lane2Rect = lane2Element.getBoundingClientRect();
|
const lane2Rect = lane2Element.getBoundingClientRect();
|
||||||
@@ -962,12 +1121,16 @@
|
|||||||
fetch("/api/data")
|
fetch("/api/data")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
timer1 = data.time1;
|
// Alte Status- und Timer-Werte sichern, BEVOR sie
|
||||||
timer2 = data.time2;
|
// überschrieben werden — werden für die Animations-
|
||||||
|
// Entscheidung am Status-Übergang gebraucht.
|
||||||
// Alte Status-Werte sichern, BEVOR sie überschrieben werden
|
|
||||||
const oldStatus1 = status1;
|
const oldStatus1 = status1;
|
||||||
const oldStatus2 = status2;
|
const oldStatus2 = status2;
|
||||||
|
const oldTimer1 = timer1;
|
||||||
|
const oldTimer2 = timer2;
|
||||||
|
|
||||||
|
timer1 = data.time1;
|
||||||
|
timer2 = data.time2;
|
||||||
|
|
||||||
// Status nur übernehmen, wenn der Wert gültig ist.
|
// Status nur übernehmen, wenn der Wert gültig ist.
|
||||||
// Bei unvollständiger ESP-Response (Last) bleibt der
|
// Bei unvollständiger ESP-Response (Last) bleibt der
|
||||||
@@ -983,22 +1146,36 @@
|
|||||||
// Übergang finished -> ready erkennen.
|
// Übergang finished -> ready erkennen.
|
||||||
// Snapshot der großen Zeit JETZT einfrieren, bevor
|
// Snapshot der großen Zeit JETZT einfrieren, bevor
|
||||||
// kickDisplayScheduler/updateDisplay sie versteckt.
|
// kickDisplayScheduler/updateDisplay sie versteckt.
|
||||||
const fly1 =
|
// Außerdem entscheiden: Fly-Down (Lap im Leaderboard) oder
|
||||||
oldStatus1 === "finished" && status1 === "ready"
|
// Explode (Zeit war unter minTimeForLeaderboardSec).
|
||||||
? captureSourceSnapshot(document.getElementById("time1"))
|
const transition1 = oldStatus1 === "finished" && status1 === "ready";
|
||||||
: null;
|
const transition2 = oldStatus2 === "finished" && status2 === "ready";
|
||||||
const fly2 =
|
|
||||||
oldStatus2 === "finished" && status2 === "ready"
|
const snap1 = transition1
|
||||||
? captureSourceSnapshot(document.getElementById("time2"))
|
? captureSourceSnapshot(document.getElementById("time1"))
|
||||||
: null;
|
: null;
|
||||||
|
const snap2 = transition2
|
||||||
|
? captureSourceSnapshot(document.getElementById("time2"))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const explode1 =
|
||||||
|
transition1 && oldTimer1 > 0 && oldTimer1 < minTimeForLeaderboardSec;
|
||||||
|
const explode2 =
|
||||||
|
transition2 && oldTimer2 > 0 && oldTimer2 < minTimeForLeaderboardSec;
|
||||||
|
|
||||||
kickDisplayScheduler();
|
kickDisplayScheduler();
|
||||||
|
|
||||||
// Animation auf nächsten Frame, wenn updateDisplay durch ist
|
// Animation auf nächsten Frame, wenn updateDisplay durch ist
|
||||||
if (fly1 || fly2) {
|
if (snap1 || snap2) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1));
|
if (snap1) {
|
||||||
if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2));
|
if (explode1) explodeFromSnapshot(snap1);
|
||||||
|
else flyDownFromSnapshot(snap1, findFlyDest(1));
|
||||||
|
}
|
||||||
|
if (snap2) {
|
||||||
|
if (explode2) explodeFromSnapshot(snap2);
|
||||||
|
else flyDownFromSnapshot(snap2, findFlyDest(2));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1095,6 +1272,7 @@
|
|||||||
syncFromBackend();
|
syncFromBackend();
|
||||||
loadLaneConfig();
|
loadLaneConfig();
|
||||||
loadLeaderboard();
|
loadLeaderboard();
|
||||||
|
loadAnimationSettings();
|
||||||
|
|
||||||
// Leaderboard alle 5 Sekunden aktualisieren
|
// Leaderboard alle 5 Sekunden aktualisieren
|
||||||
setInterval(loadLeaderboard, 5000);
|
setInterval(loadLeaderboard, 5000);
|
||||||
|
|||||||
Reference in New Issue
Block a user