Files
website-cv/script.js
T
2026-08-08 16:55:26 +02:00

287 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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`;
// FisherYates 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; });
});
})();