Files
site-portfolio/public/js/modules/ui/hero-word-slot.js
T

104 lines
3.6 KiB
JavaScript

import { clampNum } from "../shared/math.js";
import { prefersReducedMotion } from "../shared/media.js";
export function initHeroWordSlot() {
const wordEl = document.querySelector("[data-hero-rotator-word]");
if (!wordEl) return;
const words = ["web", "mobile", "desktop"];
if (!words.length) return;
if (prefersReducedMotion()) {
wordEl.textContent = words[0];
return;
}
const alphabet = "abcdefghijklmnopqrstuvwxyz";
const slotStepMs = 45;
const letterStopDelayMs = 80;
const pauseBetweenWordsMs = 1700;
let currentWordIndex = 0;
let runToken = 0;
const padWord = (word, len) => word.padEnd(len, " ");
const randomLetter = () => alphabet[Math.floor(Math.random() * alphabet.length)];
function readThemeColorBase() {
const root = getComputedStyle(document.documentElement);
const h = clampNum(root.getPropertyValue("--theme-h"), 0, 360, 210);
const s = clampNum(root.getPropertyValue("--theme-s"), 30, 100, 90);
const l = clampNum(root.getPropertyValue("--theme-l"), 20, 70, 40);
return { h, s, l };
}
function randomScrambleColor(base) {
const hVar = (base.h + (Math.random() * 84 - 42) + 360) % 360;
const sVar = Math.round(clampNum(base.s + (Math.random() * 26 - 13), 35, 100, base.s));
const lVar = Math.round(clampNum(base.l + (Math.random() * 24 - 12), 22, 76, base.l));
return `hsl(${Math.round(hVar)} ${sVar}% ${lVar}%)`;
}
function renderChars(chars, scramble = false, base = null) {
if (!scramble) {
wordEl.textContent = chars.join("");
return;
}
const colorBase = base || readThemeColorBase();
const html = chars
.map((ch) => {
if (ch === " ") return `<span class="hero-rotator__char">&nbsp;</span>`;
return `<span class="hero-rotator__char" style="color:${randomScrambleColor(colorBase)}">${ch}</span>`;
})
.join("");
wordEl.innerHTML = html;
}
function animateToWord(nextWord) {
runToken += 1;
const token = runToken;
const fromWord = words[currentWordIndex];
const maxLen = Math.max(fromWord.length, nextWord.length);
const targetChars = padWord(nextWord, maxLen).split("");
const liveChars = padWord(fromWord, maxLen).split("");
const finalCharTime = (maxLen - 1) * letterStopDelayMs + 380;
const spacingResetTime = Math.max(finalCharTime - 240, 0);
const start = performance.now();
wordEl.style.setProperty("--hero-slot-spacing", "0.085em");
const themeColorBase = readThemeColorBase();
function tick(now) {
if (token !== runToken) return;
const elapsed = now - start;
if (elapsed >= spacingResetTime) wordEl.style.setProperty("--hero-slot-spacing", "0");
for (let i = 0; i < maxLen; i++) {
const settleAt = i * letterStopDelayMs;
if (elapsed < settleAt) continue;
const settleProgress = elapsed - settleAt;
if (settleProgress >= 340) liveChars[i] = targetChars[i];
else if (targetChars[i] === " ") liveChars[i] = " ";
else liveChars[i] = randomLetter();
}
renderChars(liveChars, true, themeColorBase);
if (elapsed >= finalCharTime) {
renderChars(targetChars);
wordEl.style.setProperty("--hero-slot-spacing", "0");
currentWordIndex = words.indexOf(nextWord);
window.setTimeout(nextCycle, pauseBetweenWordsMs);
return;
}
window.setTimeout(() => requestAnimationFrame(tick), slotStepMs);
}
requestAnimationFrame(tick);
}
function nextCycle() {
const nextIndex = (currentWordIndex + 1) % words.length;
animateToWord(words[nextIndex]);
}
wordEl.textContent = words[0];
window.setTimeout(nextCycle, pauseBetweenWordsMs);
}