portal_publico/static/js/login-intro.js

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