198 lines
8.2 KiB
JavaScript
198 lines
8.2 KiB
JavaScript
/*
|
|
* Animação de intro pós-login — porta fiel da composição de
|
|
* pid-intro-escuro.html (motor "OM" de animação por cena/tempo autorado,
|
|
* ver CLAUDE.md "Animação de intro do login" para a extração original).
|
|
* Durações das cenas ACELERADAS em relação ao original (pedido explícito do
|
|
* usuário): Build e Face de 2,6s/2s pra 1s cada; Wordmark mantida em 2,8s
|
|
* (é a parte "onde aparece o nome do portal", pedida pra não mudar); Portal
|
|
* (a parte em que ele some) de ~1s. As fórmulas de easing/animate/blink
|
|
* abaixo são a tradução 1:1 das do original (animations-v3.jsx), só os
|
|
* deslocamentos de tempo dentro de cada cena foram reproporcionados pra
|
|
* caber na duração nova; o "fly" final foi recalibrado pro canto superior
|
|
* esquerdo REAL da janela (onde a logo colapsada da sidebar mora,
|
|
* `.sidebar__logo--icon`, 40px) em vez das coordenadas de um frame de vídeo
|
|
* 1920x1080 do arquivo original, que não correspondem a nada nesta página.
|
|
*/
|
|
|
|
const PID_INTRO_CUES = { Build: 0, Face: 1, Wordmark: 2, Portal: 4.8 };
|
|
const PID_INTRO_TOTAL = 5.7;
|
|
const PID_INTRO_FADE_MS = 500;
|
|
|
|
const PID_INTRO_EASING = {
|
|
easeOutCubic: (t) => (--t) * t * t + 1,
|
|
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
|
easeOutBack: (t) => {
|
|
const c1 = 1.70158;
|
|
const c3 = c1 + 1;
|
|
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
|
},
|
|
};
|
|
|
|
function pidIntroClamp(v, min, max) {
|
|
return Math.max(min, Math.min(max, v));
|
|
}
|
|
|
|
function pidIntroAnimate({ from = 0, to = 1, start = 0, end = 1, ease = PID_INTRO_EASING.easeInOutQuad }) {
|
|
return (t) => {
|
|
if (t <= start) return from;
|
|
if (t >= end) return to;
|
|
const local = (t - start) / (end - start);
|
|
return from + (to - from) * ease(local);
|
|
};
|
|
}
|
|
|
|
function pidIntroBlinkAt(t0, T) {
|
|
const d = T - t0;
|
|
if (d < 0 || d > 0.22) return 1;
|
|
return Math.abs(Math.cos((d / 0.22) * Math.PI));
|
|
}
|
|
|
|
function pidIntroCornerTarget(iconSize, finalSize, margin) {
|
|
const cx = window.innerWidth / 2;
|
|
const cy = window.innerHeight / 2;
|
|
return {
|
|
x: margin + finalSize / 2 - cx,
|
|
y: margin + finalSize / 2 - cy,
|
|
};
|
|
}
|
|
|
|
function pidPlayLoginIntro(onDone) {
|
|
const overlay = document.getElementById("login-intro");
|
|
const icon = document.getElementById("login-intro-icon");
|
|
const stage = document.getElementById("login-intro-stage");
|
|
const wordmark = document.getElementById("login-intro-wordmark");
|
|
if (!overlay || !icon || !stage || !wordmark) {
|
|
onDone();
|
|
return;
|
|
}
|
|
|
|
const body = icon.querySelector(".login-intro__body");
|
|
const tab = icon.querySelector(".login-intro__tab");
|
|
const screen = icon.querySelector(".login-intro__screen");
|
|
const eyeL = icon.querySelector(".login-intro__eye--l");
|
|
const eyeR = icon.querySelector(".login-intro__eye--r");
|
|
const smile = icon.querySelector(".login-intro__smile");
|
|
const letters = Array.from(wordmark.querySelectorAll(".login-intro__letter"));
|
|
const rule = wordmark.querySelector(".login-intro__rule");
|
|
const tagline = wordmark.querySelector(".login-intro__tagline");
|
|
|
|
const { easeOutCubic, easeInOutQuad, easeOutBack } = PID_INTRO_EASING;
|
|
const CUES = PID_INTRO_CUES;
|
|
|
|
const bodyDrawFn = pidIntroAnimate({ start: CUES.Build + 0.05, end: CUES.Build + 0.45, ease: easeInOutQuad });
|
|
const bodyFillFn = pidIntroAnimate({ start: CUES.Build + 0.3, end: CUES.Build + 0.55, ease: easeOutCubic });
|
|
const tabDropFn = pidIntroAnimate({ from: -18, to: 0, start: CUES.Build + 0.4, end: CUES.Build + 0.65, ease: easeOutBack });
|
|
const tabFadeFn = pidIntroAnimate({ start: CUES.Build + 0.4, end: CUES.Build + 0.55, ease: easeOutCubic });
|
|
const screenFn = pidIntroAnimate({ start: CUES.Build + 0.6, end: CUES.Build + 0.95, ease: easeOutCubic });
|
|
|
|
const eyeLFn = pidIntroAnimate({ start: CUES.Face + 0.05, end: CUES.Face + 0.35, ease: easeOutBack });
|
|
const eyeRFn = pidIntroAnimate({ start: CUES.Face + 0.15, end: CUES.Face + 0.45, ease: easeOutBack });
|
|
const smileFn = pidIntroAnimate({ start: CUES.Face + 0.35, end: CUES.Face + 0.65, ease: easeInOutQuad });
|
|
|
|
const shiftFn = pidIntroAnimate({ start: CUES.Wordmark - 0.35, end: CUES.Wordmark + 0.7, ease: easeOutCubic });
|
|
// Cena Portal comprimida (ver comentário de PID_INTRO_TOTAL acima): a
|
|
// wordmark some quase na hora, o ícone voa rápido e o stage inteiro
|
|
// desaparece logo depois, sem pausa parada no meio.
|
|
const flyFn = pidIntroAnimate({ start: CUES.Portal + 0.05, end: CUES.Portal + 0.55, ease: easeOutCubic });
|
|
const wordmarkOutFn = pidIntroAnimate({ from: 1, to: 0, start: CUES.Portal, end: CUES.Portal + 0.3, ease: easeOutCubic });
|
|
const stageFadeFn = pidIntroAnimate({ from: 1, to: 0, start: CUES.Portal + 0.5, end: CUES.Portal + 0.9, ease: easeOutCubic });
|
|
|
|
const ruleFn = pidIntroAnimate({ start: CUES.Wordmark + 0.85, end: CUES.Wordmark + 1.6, ease: easeInOutQuad });
|
|
const tagFn = pidIntroAnimate({ start: CUES.Wordmark + 1.15, end: CUES.Wordmark + 1.9, ease: easeOutCubic });
|
|
|
|
const letterFns = letters.map((_, i) => {
|
|
const s = CUES.Wordmark + 0.25 + i * 0.13;
|
|
return {
|
|
o: pidIntroAnimate({ start: s, end: s + 0.5, ease: easeOutCubic }),
|
|
y: pidIntroAnimate({ from: 26, to: 0, start: s, end: s + 0.6, ease: easeOutCubic }),
|
|
};
|
|
});
|
|
|
|
const SHIFT_X = -100;
|
|
const ICON_SIZE = 160;
|
|
const FINAL_ICON_SIZE = 40;
|
|
const FINAL_SCALE = FINAL_ICON_SIZE / ICON_SIZE;
|
|
const CORNER_MARGIN = 40;
|
|
|
|
// O card de login se dissolve enquanto o overlay (já na cor final,
|
|
// #121017) surge por cima — só depois dessa transição (mesma duração,
|
|
// PID_INTRO_FADE_MS) é que o ícone começa a se desenhar; sem isso, o
|
|
// overlay aparecia de repente por cima do card ainda visível.
|
|
const loginCard = document.querySelector(".login-card");
|
|
if (loginCard) loginCard.classList.add("is-leaving");
|
|
overlay.hidden = false;
|
|
void overlay.offsetWidth;
|
|
overlay.classList.add("is-visible");
|
|
|
|
let start;
|
|
|
|
function frame(now) {
|
|
const T = Math.min((now - start) / 1000, PID_INTRO_TOTAL);
|
|
|
|
const bodyDraw = bodyDrawFn(T);
|
|
body.style.strokeDashoffset = String(100 * (1 - bodyDraw));
|
|
body.style.strokeOpacity = bodyDraw > 0.001 ? "1" : "0";
|
|
body.style.fillOpacity = String(bodyFillFn(T));
|
|
|
|
const tabDrop = tabDropFn(T);
|
|
tab.setAttribute("transform", `translate(0 ${tabDrop})`);
|
|
tab.style.opacity = String(tabFadeFn(T));
|
|
|
|
const screenP = screenFn(T);
|
|
const screenScaleY = pidIntroClamp(screenP * 1.2, 0, 1);
|
|
screen.setAttribute("transform", `translate(32 41) scale(${screenP} ${screenScaleY}) translate(-32 -41)`);
|
|
screen.style.opacity = String(pidIntroClamp(screenP * 2, 0, 1));
|
|
|
|
const blink = Math.min(
|
|
pidIntroBlinkAt(CUES.Face + 0.8, T),
|
|
pidIntroBlinkAt(CUES.Wordmark + 1.1, T)
|
|
);
|
|
const sL = eyeLFn(T);
|
|
const sR = eyeRFn(T);
|
|
eyeL.setAttribute("transform", `translate(24 38) scale(${sL} ${sL * blink})`);
|
|
eyeL.style.opacity = String(pidIntroClamp(sL, 0, 1));
|
|
eyeR.setAttribute("transform", `translate(40 38) scale(${sR} ${sR * blink})`);
|
|
eyeR.style.opacity = String(pidIntroClamp(sR, 0, 1));
|
|
|
|
const smileP = smileFn(T);
|
|
smile.style.strokeDashoffset = String(100 * (1 - smileP));
|
|
smile.style.strokeOpacity = smileP > 0.001 ? "1" : "0";
|
|
|
|
const shift = shiftFn(T);
|
|
const fly = flyFn(T);
|
|
const lockupX = SHIFT_X * shift;
|
|
const lockupScale = 1 - 0.2 * shift;
|
|
const target = pidIntroCornerTarget(ICON_SIZE, FINAL_ICON_SIZE, CORNER_MARGIN);
|
|
const logoX = lockupX + (target.x - lockupX) * fly;
|
|
const logoY = (target.y - 0) * fly;
|
|
const logoScale = lockupScale + (FINAL_SCALE - lockupScale) * fly;
|
|
icon.style.transform = `translate(${logoX}px, ${logoY}px) scale(${logoScale})`;
|
|
|
|
wordmark.style.opacity = String(pidIntroClamp(shift * 1.4, 0, 1) * wordmarkOutFn(T));
|
|
|
|
letters.forEach((el, i) => {
|
|
el.style.opacity = String(letterFns[i].o(T));
|
|
el.style.transform = `translateY(${letterFns[i].y(T)}px)`;
|
|
});
|
|
|
|
rule.style.transform = `scaleX(${ruleFn(T)})`;
|
|
|
|
const tag = tagFn(T);
|
|
tagline.style.opacity = String(tag);
|
|
tagline.style.transform = `translateY(${(1 - tag) * 12}px)`;
|
|
|
|
stage.style.opacity = String(stageFadeFn(T));
|
|
|
|
if (T < PID_INTRO_TOTAL) {
|
|
requestAnimationFrame(frame);
|
|
} else {
|
|
onDone();
|
|
}
|
|
}
|
|
|
|
setTimeout(() => {
|
|
start = performance.now();
|
|
requestAnimationFrame(frame);
|
|
}, PID_INTRO_FADE_MS);
|
|
}
|