Compare commits

...

1 Commits

Author SHA1 Message Date
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

@@ -529,6 +529,143 @@
);
}
// -------- 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;
const dstRect = destEl.getBoundingClientRect();
if (dstRect.width === 0 || dstRect.height === 0) return;
// ---- 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;
setTimeout(() => {
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: aktuelle Position des .time-Spans im Eintrag
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(() => {
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);
}
// 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");
@@ -827,6 +964,11 @@
.then((data) => {
timer1 = data.time1;
timer2 = data.time2;
// Alte Status-Werte sichern, BEVOR sie überschrieben werden
const oldStatus1 = status1;
const oldStatus2 = status2;
// 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 +979,28 @@
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.
const fly1 =
oldStatus1 === "finished" && status1 === "ready"
? captureSourceSnapshot(document.getElementById("time1"))
: null;
const fly2 =
oldStatus2 === "finished" && status2 === "ready"
? captureSourceSnapshot(document.getElementById("time2"))
: null;
kickDisplayScheduler();
// Animation auf nächsten Frame, wenn updateDisplay durch ist
if (fly1 || fly2) {
requestAnimationFrame(() => {
if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1));
if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2));
});
}
})
.catch((error) =>
console.error("Fehler beim Laden deiner Daten:", error)