86 lines
3.5 KiB
JavaScript
86 lines
3.5 KiB
JavaScript
// ── Job title slot machine: click the role pill ───────────────
|
|
(function () {
|
|
const pill = document.getElementById('heroRole');
|
|
if (!pill) return;
|
|
|
|
const WORDS = [
|
|
['Software', 'Backend', 'Data', 'Computer', 'Frontend', 'Full-Stack', 'Tech', 'Server', 'It Project', 'Cloud'],
|
|
['Developer', 'Scientist', 'Engineer', 'Enthusiast', 'Wizard', 'Programmer', 'Manager'],
|
|
];
|
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
const slots = [...pill.querySelectorAll('.slot')];
|
|
|
|
// Width of a word in this mono font: 1ch + letter-spacing per character.
|
|
// Slots hug their current word at rest and expand to their widest word
|
|
// while spinning (the CSS width transition animates both moves).
|
|
const widthFor = (word) => `calc(${word.length}ch + ${word.length * 0.08}em)`;
|
|
slots.forEach((slot) => {
|
|
slot.style.width = widthFor(slot.textContent.trim());
|
|
});
|
|
|
|
let busy = false;
|
|
pill.addEventListener('click', () => {
|
|
if (window.achv) window.achv.mark('slot');
|
|
if (busy) return;
|
|
busy = true;
|
|
|
|
const spins = slots.map((slot, i) => {
|
|
const list = WORDS[i];
|
|
const reel = slot.querySelector('.reel');
|
|
const from = Math.max(list.indexOf(reel.textContent.trim()), 0);
|
|
// Random pick that's never the word already showing
|
|
let target = Math.floor(Math.random() * (list.length - 1));
|
|
if (target >= from) target++;
|
|
|
|
if (reduceMotion.matches) {
|
|
reel.innerHTML = `<span>${list[target]}</span>`;
|
|
slot.style.width = widthFor(list[target]);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Expand to fit the reel's widest word for the duration of the spin
|
|
const longest = list.reduce((a, b) => (b.length > a.length ? b : a));
|
|
slot.style.width = widthFor(longest);
|
|
|
|
// Walk the reel in list order: a couple of full revolutions, then
|
|
// land on the target (the second reel spins one revolution longer)
|
|
const steps = ((target - from + list.length) % list.length) + list.length * (2 + i);
|
|
|
|
// Build the strip top-down ending at the current word, so the column
|
|
// slides downward and words enter from above. One extra word sits
|
|
// above the target to back the overshoot.
|
|
reel.innerHTML = '';
|
|
for (let k = 0; k <= steps + 1; k++) {
|
|
const w = document.createElement('span');
|
|
w.textContent = list[(from + steps + 1 - k) % list.length];
|
|
reel.appendChild(w);
|
|
}
|
|
|
|
const h = reel.firstChild.getBoundingClientRect().height;
|
|
const land = -h; // row where the target word sits
|
|
const over = land + h * 0.35; // spin runs slightly past the stop
|
|
const anim = reel.animate(
|
|
[
|
|
{ transform: `translateY(${-(steps + 1) * h}px)` },
|
|
{ transform: `translateY(${over}px)` },
|
|
],
|
|
{ duration: 900 + i * 500, easing: 'cubic-bezier(0.15, 0.85, 0.25, 1)', fill: 'forwards' }
|
|
);
|
|
return anim.finished.then(() => {
|
|
const settle = reel.animate(
|
|
[{ transform: `translateY(${over}px)` }, { transform: `translateY(${land}px)` }],
|
|
{ duration: 200, easing: 'ease', fill: 'forwards' }
|
|
);
|
|
return settle.finished.then(() => {
|
|
reel.innerHTML = `<span>${list[target]}</span>`;
|
|
settle.cancel();
|
|
anim.cancel(); // drop the forward-filled transforms after the swap
|
|
slot.style.width = widthFor(list[target]); // shrink back to fit
|
|
});
|
|
});
|
|
});
|
|
|
|
Promise.all(spins).finally(() => { busy = false; });
|
|
});
|
|
})();
|