Compare commits

...

4 Commits

Author SHA1 Message Date
Carsten Graf
3664847233 fix(ui): clear leftover inline font-size when buttons disconnect; add explode animation for sub-threshold runs
Some checks failed
/ build (push) Failing after 9m56s
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) <noreply@anthropic.com>
2026-05-03 20:51:30 +02:00
Carsten Graf
f57442c906 fix(leaderboard): fly-down landed at (0,0) when polling re-rendered list
Wenn das 5s-Polling während der Fly-Animation feuerte, hat
fillLeaderboardContainer den destEl-Eintrag aus dem DOM entfernt.
getBoundingClientRect() lieferte dann {0,0,0,0} und der Ghost flog
sichtbar in die linke obere Ecke.

Fix:
- flyAnimationActive-Flag suspendiert updateLeaderboardDisplay
  während der Animation; ausstehendes Update wird im cleanup
  nachgeholt
- Defensive isConnected/Rect-Checks vor jedem Schritt;
  bei Orphan wird sauber abgebrochen statt nach (0,0) zu fliegen
- Cleanup in eine Funktion ausgelagert für mehrere Exit-Pfade

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:30:50 +02:00
Carsten Graf
5beced0041 Merge feat/lb-fly-animation: fly-down animation on lap reset
Some checks failed
/ build (push) Has been cancelled
2026-05-03 17:24:05 +02:00
Carsten Graf
fd18d0cd22 feat(leaderboard): fly-down animation on lap reset
All checks were successful
/ build (push) Successful in 4m6s
Beim Übergang finished -> ready (Auto-Reset) fliegt die große
Lauf-Zeit aus #time1/#time2 nach unten in die Leaderboard-Liste.
Die bestehenden Einträge werden dabei nach unten geschoben, um
Platz zu machen.

Auto-Trigger beim Leaderboard-Polling entfernt; Animation läuft
jetzt ausschließlich am Status-Übergang über einen Snapshot, der
vor kickDisplayScheduler() eingefroren wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:23:39 +02:00

View File

@@ -465,11 +465,39 @@
}
// 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() {
try {
const response = await fetch("/api/leaderboard");
leaderboardData = await response.json();
updateLeaderboardDisplay();
if (flyAnimationActive) {
leaderboardUpdatePending = true;
} else {
updateLeaderboardDisplay();
}
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
@@ -529,6 +557,260 @@
);
}
// -------- Fly-down Animation --------
// Wird ausgelöst beim Status-Übergang finished -> ready (kurz vor dem
// Auto-Reset des Backends). Damit bleibt die große Zeit oben sichtbar
// bis der Backend resettet, und fliegt dann erst nach unten.
// Snapshot der Quelle einfrieren, BEVOR sie versteckt wird.
function captureSourceSnapshot(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const cs = window.getComputedStyle(el);
return {
rect,
fontSize: cs.fontSize,
fontFamily: cs.fontFamily,
fontWeight: cs.fontWeight,
color: cs.color,
text: el.textContent,
};
}
function flyDownFromSnapshot(srcSnap, destEl) {
if (!srcSnap || !destEl) return;
if (!destEl.isConnected) return;
const dstRect = destEl.getBoundingClientRect();
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" ----
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
// setzen die Geschwister visuell an die Position, die sie VOR
// dem neuen Eintrag hatten (eine Slot-Höhe nach oben). Dann
// gleiten sie animiert in ihre natürliche Position herunter.
const container = destEl.parentElement;
const siblings = container
? Array.from(
container.querySelectorAll(".leaderboard-entry")
).filter((e) => e !== destEl)
: [];
let shiftPx = dstRect.height;
if (container) {
const cs = window.getComputedStyle(container);
const gap =
parseFloat(cs.rowGap) || parseFloat(cs.gap) || 0;
shiftPx += gap;
}
// Dest sofort verstecken (Layout-Slot bleibt erhalten)
destEl.style.visibility = "hidden";
// Geschwister hochsetzen (instant, ohne Transition)
siblings.forEach((s) => {
s.style.transition = "none";
s.style.transform = `translateY(-${shiftPx}px)`;
});
// Reflow, damit der "instant"-Setup wirkt
if (siblings.length > 0) siblings[0].getBoundingClientRect();
// Slide nach unten zur natürlichen Position
const slideMs = 280;
siblings.forEach((s) => {
s.style.transition = `transform ${slideMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
s.style.transform = "translateY(0)";
});
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
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(() => {
// 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");
ghost.textContent = srcSnap.text;
ghost.style.position = "fixed";
ghost.style.left = srcSnap.rect.left + "px";
ghost.style.top = srcSnap.rect.top + "px";
ghost.style.width = srcSnap.rect.width + "px";
ghost.style.height = srcSnap.rect.height + "px";
ghost.style.margin = "0";
ghost.style.padding = "0";
ghost.style.zIndex = "9999";
ghost.style.pointerEvents = "none";
ghost.style.color = srcSnap.color;
ghost.style.fontFamily = srcSnap.fontFamily;
ghost.style.fontWeight = srcSnap.fontWeight;
ghost.style.fontSize = srcSnap.fontSize;
ghost.style.lineHeight = "1";
ghost.style.display = "flex";
ghost.style.alignItems = "center";
ghost.style.justifyContent = "center";
ghost.style.textShadow = "0 0 12px rgba(0, 255, 136, 0.8)";
ghost.style.borderRadius = "8px";
ghost.style.transition =
`left ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`top ${flyMs}ms cubic-bezier(0.55, 0.05, 0.3, 1.1),` +
`width ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`height ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`font-size ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`color ${flyMs}ms ease-out,` +
`text-shadow ${flyMs}ms ease-out`;
document.body.appendChild(ghost);
// Reflow erzwingen, damit die Anfangsposition wirkt
ghost.getBoundingClientRect();
// Endwerte aus erneuter Messung (preRect war Vorab-Check)
const destTimeSpan = destEl.querySelector(".time") || destEl;
const destTimeRect = destTimeSpan.getBoundingClientRect();
const destStyle = window.getComputedStyle(destTimeSpan);
ghost.style.left = destTimeRect.left + "px";
ghost.style.top = destTimeRect.top + "px";
ghost.style.width = destTimeRect.width + "px";
ghost.style.height = destTimeRect.height + "px";
ghost.style.fontSize = destStyle.fontSize;
ghost.style.color = destStyle.color;
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
setTimeout(() => cleanup(ghost), flyMs + 20);
}, 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 =
leaderboardData && leaderboardData.mode === "different"
? "leaderboard-container-" + lane
: "leaderboard-container-1";
const container = document.getElementById(containerId);
if (!container) return null;
return container.querySelector(".leaderboard-entry");
}
function updateLeaderboardDisplay() {
const box1 = document.getElementById("best-times-1");
const box2 = document.getElementById("best-times-2");
@@ -590,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();
@@ -694,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();
@@ -825,8 +1121,17 @@
fetch("/api/data")
.then((response) => response.json())
.then((data) => {
// 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
// bisherige Status erhalten statt "Status unbekannt".
@@ -837,7 +1142,42 @@
learningMode = data.learningMode;
learningButton = data.learningButton || "";
lastSync = Date.now();
// Übergang finished -> ready erkennen.
// Snapshot der großen Zeit JETZT einfrieren, bevor
// kickDisplayScheduler/updateDisplay sie versteckt.
// 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 (snap1 || snap2) {
requestAnimationFrame(() => {
if (snap1) {
if (explode1) explodeFromSnapshot(snap1);
else flyDownFromSnapshot(snap1, findFlyDest(1));
}
if (snap2) {
if (explode2) explodeFromSnapshot(snap2);
else flyDownFromSnapshot(snap2, findFlyDest(2));
}
});
}
})
.catch((error) =>
console.error("Fehler beim Laden deiner Daten:", error)
@@ -932,6 +1272,7 @@
syncFromBackend();
loadLaneConfig();
loadLeaderboard();
loadAnimationSettings();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);