287 lines
8.8 KiB
JavaScript
287 lines
8.8 KiB
JavaScript
// ── Sprinkle background ────────────────────────────
|
||
(function () {
|
||
const canvas = document.getElementById('bgCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const COLORS = ['#ffd84d', '#8cbcff', '#ff9fca', '#a9e76c', '#b99cff', '#ff9d57'];
|
||
const INK = '#171717';
|
||
const COUNT = 1000; // number of sprinkles
|
||
const MIN_LEN = 12; // px
|
||
const MAX_LEN = 18; // px
|
||
const MIN_W = 4;
|
||
const MAX_W = 6;
|
||
|
||
let W, H, sprinkles;
|
||
|
||
function rand(a, b) { return a + Math.random() * (b - a); }
|
||
|
||
function resize() {
|
||
W = canvas.width = window.innerWidth;
|
||
H = canvas.height = window.innerHeight;
|
||
if (!sprinkles) init();
|
||
// Redistribute any sprinkles that landed outside new bounds
|
||
sprinkles.forEach(s => {
|
||
if (s.x > W) s.x = rand(0, W);
|
||
if (s.y > H) s.y = rand(0, H);
|
||
});
|
||
// Resizing clears the canvas; with no animation loop running, repaint it
|
||
if (reduceMotion.matches) {
|
||
cancelAnimationFrame(rafId);
|
||
rafId = requestAnimationFrame(draw);
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
sprinkles = [];
|
||
for (let i = 0; i < COUNT; i++) {
|
||
sprinkles.push(makeSprinkle());
|
||
}
|
||
}
|
||
|
||
function makeSprinkle() {
|
||
return {
|
||
x: rand(0, W),
|
||
y: rand(0, H),
|
||
len: rand(MIN_LEN, MAX_LEN),
|
||
width: rand(MIN_W, MAX_W),
|
||
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||
angle: rand(0, Math.PI * 2),
|
||
// very slow drift
|
||
vx: rand(-0.1, 0.1),
|
||
vy: rand(-0.1, 0.1),
|
||
va: rand(-0.005, 0.005), // angular velocity
|
||
alpha: 1,
|
||
};
|
||
}
|
||
|
||
function draw() {
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
sprinkles.forEach(s => {
|
||
ctx.save();
|
||
ctx.translate(s.x, s.y);
|
||
ctx.rotate(s.angle);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -s.len / 2);
|
||
ctx.lineTo(0, s.len / 2);
|
||
ctx.lineCap = 'round';
|
||
ctx.globalAlpha = s.alpha;
|
||
// Ink outline under the colored fill (sticker look): same path, two strokes
|
||
ctx.strokeStyle = INK;
|
||
ctx.lineWidth = s.width + 3;
|
||
ctx.stroke();
|
||
ctx.strokeStyle = s.color;
|
||
ctx.lineWidth = s.width;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Reduced motion: sprinkles stay where they are
|
||
if (reduceMotion.matches) return;
|
||
|
||
// Drift
|
||
s.x += s.vx;
|
||
s.y += s.vy;
|
||
if (s.vx > 0.1 || s.vx < -0.1) {
|
||
s.vx *= 0.98;
|
||
}
|
||
if (s.vy > 0.1 || s.vy < -0.1) {
|
||
s.vy *= 0.98;
|
||
}
|
||
s.angle += s.va;
|
||
|
||
// Repel from cursor
|
||
const dx = s.x - mouseX;
|
||
const dy = s.y - mouseY;
|
||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||
const repelRadius = 80;
|
||
if (dist < repelRadius && dist > 0) {
|
||
const force = (repelRadius - dist) / repelRadius;
|
||
s.vx += (dx / dist) * force * 1.0;
|
||
s.vy += (dy / dist) * force * 1.0;
|
||
}
|
||
|
||
// Wrap: when a sprinkle drifts off the screen, reappear on the other side
|
||
if (s.y < -MAX_LEN) s.y = H + MAX_LEN;
|
||
if (s.y > H + MAX_LEN) s.y = -MAX_LEN
|
||
if (s.x < -MAX_LEN) s.x = W + MAX_LEN;
|
||
if (s.x > W + MAX_LEN) s.x = -MAX_LEN;
|
||
});
|
||
|
||
if (!reduceMotion.matches) rafId = requestAnimationFrame(draw);
|
||
}
|
||
|
||
// With reduced motion the sprinkles render once as a static backdrop
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
let rafId;
|
||
reduceMotion.addEventListener('change', () => {
|
||
cancelAnimationFrame(rafId);
|
||
rafId = requestAnimationFrame(draw);
|
||
});
|
||
|
||
window.addEventListener('resize', resize, { passive: true });
|
||
resize();
|
||
let mouseX = -9999, mouseY = -9999;
|
||
window.addEventListener('mousemove', (e) => {
|
||
mouseX = e.clientX;
|
||
mouseY = e.clientY;
|
||
}, { passive: true });
|
||
rafId = requestAnimationFrame(draw);
|
||
})();
|
||
|
||
|
||
// ── Nav: scroll state & active link ───────────────────────────
|
||
(function () {
|
||
const header = document.getElementById('header');
|
||
const links = document.querySelectorAll('.nav-link');
|
||
const sections = document.querySelectorAll('section[id]');
|
||
const burger = document.getElementById('burger');
|
||
|
||
window.addEventListener('scroll', () => {
|
||
header.classList.toggle('scrolled', window.scrollY > 24 || burger.classList.contains('open'));
|
||
|
||
let current = '';
|
||
sections.forEach(s => {
|
||
if (window.scrollY >= s.offsetTop - 100) current = s.id;
|
||
});
|
||
links.forEach(l => {
|
||
l.classList.toggle('active', l.getAttribute('href') === '#' + current);
|
||
});
|
||
}, { passive: true });
|
||
})();
|
||
|
||
|
||
// ── Mobile burger ──────────────────────────────────────────────
|
||
(function () {
|
||
const burger = document.getElementById('burger');
|
||
const navLinks = document.getElementById('navLinks');
|
||
const header = document.getElementById('header');
|
||
burger.addEventListener('click', () => {
|
||
burger.classList.toggle('open');
|
||
navLinks.classList.toggle('open');
|
||
header.classList.toggle('scrolled', burger.classList.contains('open') || window.scrollY > 24);
|
||
});
|
||
navLinks.querySelectorAll('a').forEach(a => {
|
||
a.addEventListener('click', () => {
|
||
burger.classList.remove('open');
|
||
navLinks.classList.remove('open');
|
||
header.classList.toggle('scrolled', window.scrollY > 24);
|
||
});
|
||
});
|
||
})();
|
||
|
||
|
||
// ── Avatar spin on click ──────────────────────────────────────
|
||
(function () {
|
||
const avatar = document.querySelector('.avatar-ring');
|
||
|
||
avatar.addEventListener('click', () => {
|
||
if (avatar.classList.contains('spinning')) return;
|
||
avatar.classList.add('spinning');
|
||
});
|
||
avatar.addEventListener('animationend', () => {
|
||
avatar.classList.remove('spinning');
|
||
});
|
||
})();
|
||
|
||
|
||
// ── Tag pill tooltips: tap toggle (touch devices only) ────────
|
||
(function () {
|
||
// Hover-capable devices already get the tooltips via :hover
|
||
if (window.matchMedia('(hover: hover)').matches) return;
|
||
|
||
const pills = [...document.querySelectorAll('[data-tip]')];
|
||
|
||
function closeAll() {
|
||
pills.forEach(p => {
|
||
p.classList.remove('tip-open');
|
||
p.setAttribute('aria-expanded', 'false');
|
||
});
|
||
}
|
||
|
||
pills.forEach(pill => {
|
||
pill.setAttribute('tabindex', '0');
|
||
pill.setAttribute('role', 'button');
|
||
pill.setAttribute('aria-expanded', 'false');
|
||
// Pseudo-element tooltips are invisible to screen readers
|
||
pill.setAttribute('aria-description', pill.dataset.tip);
|
||
|
||
pill.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const wasOpen = pill.classList.contains('tip-open');
|
||
closeAll();
|
||
if (!wasOpen) {
|
||
pill.classList.add('tip-open');
|
||
pill.setAttribute('aria-expanded', 'true');
|
||
}
|
||
});
|
||
pill.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
pill.click();
|
||
}
|
||
});
|
||
});
|
||
|
||
document.addEventListener('click', closeAll);
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closeAll();
|
||
});
|
||
})();
|
||
|
||
|
||
|
||
// ── Pepsi gallery: shuffled on load, swipe cycles through on click ─
|
||
(function () {
|
||
const COUNT = 15;
|
||
const img = document.querySelector('.pepsi-placeholder img');
|
||
if (!img) return;
|
||
const box = img.parentElement;
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
const src = (n) => `Pepsi/Pepsi-${n}.jpg`;
|
||
|
||
// Fisher–Yates shuffle of [1..COUNT]; clicks cycle through this order
|
||
const order = Array.from({ length: COUNT }, (_, i) => i + 1);
|
||
for (let i = order.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[order[i], order[j]] = [order[j], order[i]];
|
||
}
|
||
let pos = 0;
|
||
img.src = src(order[pos]);
|
||
|
||
let busy = false;
|
||
box.addEventListener('click', () => {
|
||
if (busy) return;
|
||
pos = (pos + 1) % order.length;
|
||
const next = order[pos];
|
||
|
||
if (reduceMotion.matches) {
|
||
img.src = src(next);
|
||
return;
|
||
}
|
||
|
||
busy = true;
|
||
const incoming = new Image();
|
||
incoming.src = src(next);
|
||
incoming.alt = img.alt;
|
||
incoming.draggable = false;
|
||
incoming.className = 'pepsi-incoming';
|
||
// Decode before animating so the swipe never reveals a half-loaded frame
|
||
incoming.decode().then(() => {
|
||
box.appendChild(incoming);
|
||
const opts = { duration: 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' };
|
||
img.animate(
|
||
[{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }],
|
||
opts
|
||
);
|
||
incoming.animate(
|
||
[{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }],
|
||
opts
|
||
).finished.then(() => {
|
||
img.src = incoming.src; // already decoded, swaps instantly
|
||
incoming.remove();
|
||
busy = false;
|
||
});
|
||
}).catch(() => { busy = false; });
|
||
});
|
||
})();
|