// ── 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; }); }); })(); // ── Scroll hint: appears after idling at the top of the page ── (function () { const hint = document.getElementById('scrollHint'); if (!hint) return; const timer = setTimeout(() => { if (window.scrollY <= 24) hint.classList.add('visible'); }, 3000); // Any scroll dismisses it for the rest of the visit window.addEventListener('scroll', () => { clearTimeout(timer); hint.classList.remove('visible'); }, { passive: true, once: true }); })(); // ── 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'], ['Developer', 'Scientist', 'Engineer', 'Enthusiast', 'Wizard'], ]; 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 (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 = `${list[target]}`; 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 = `${list[target]}`; 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; }); }); })(); // ── Pronoun pill: non-binary flag sweep on click ────────────── (function () { const pill = document.querySelector('.hero-pronouns'); if (!pill) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); pill.addEventListener('click', () => { if (reduceMotion.matches) return; if (pill.classList.contains('waving')) return; pill.classList.add('waving'); }); pill.addEventListener('animationend', () => { pill.classList.remove('waving'); }); })(); // ── Delete website (footer): flag in localStorage, serve the 404 ─ (function () { const button = document.getElementById('deleteSite'); const dialog = document.getElementById('deleteDialog'); const confirm = document.getElementById('deleteConfirm'); const cancel = document.getElementById('deleteCancel'); if (!button || !dialog) return; button.addEventListener('click', () => dialog.showModal()); cancel.addEventListener('click', () => dialog.close()); confirm.addEventListener('click', () => { try { localStorage.setItem('siteDeleted', '1'); } catch (e) {} location.replace('404.html'); }); // Click on the backdrop closes too dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); })(); // ── Icon circles spin on click, same move as the avatar ─────── (function () { const icons = document.querySelectorAll('.skill-card-icon, .project-icon, .edu-icon, .nav-logo'); icons.forEach((icon) => { icon.addEventListener('click', () => { if (icon.classList.contains('spinning')) return; icon.classList.add('spinning'); }); icon.addEventListener('animationend', () => { icon.classList.remove('spinning'); }); }); })(); // ── D20 on "Dungeons & Dragons": a 3D die built from matrix3d ─ (function () { const anchor = document.querySelector('.dnd-roll'); if (!anchor) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); // Icosahedron in CSS coordinates (x right, y down, z toward viewer) const R = 44; // circumradius, px const PHI = (1 + Math.sqrt(5)) / 2; const RAW = [ [-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0], [0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI], [PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1], ]; const FACES = [ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], ]; // The standard d20 layout (per Alea Kybos' dice configuration catalog), // embedded onto the face list above: 20 bordered by 2/8/14, opposite // faces sum to 21, evens on 20's hemisphere and odds on 1's const NUMBERS = [20, 2, 12, 10, 8, 18, 14, 16, 15, 17, 11, 9, 19, 1, 13, 6, 4, 3, 7, 5]; // Theme pastels the die can be cast from, one picked per roll const PASTELS = [ [255, 216, 77], [140, 188, 255], [255, 159, 202], [169, 231, 108], [185, 156, 255], [255, 157, 87], ]; const scale = R / Math.hypot(1, PHI); const V = RAW.map(([x, y, z]) => [x * scale, -y * scale, z * scale]); const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; const cross = (a, b) => [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ]; const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]); return [a[0] / l, a[1] / l, a[2] / l]; }; const mat3d = ([X, Y, Z], t) => `matrix3d(${X[0]},${X[1]},${X[2]},0,${Y[0]},${Y[1]},${Y[2]},0,` + `${Z[0]},${Z[1]},${Z[2]},0,${t[0]},${t[1]},${t[2]},1)`; let scene = null; let die = null; const bases = []; const faceEls = []; function build() { scene = document.createElement('span'); scene.className = 'die-scene'; die = document.createElement('span'); die.className = 'die'; scene.appendChild(die); const light = norm([-0.35, -0.5, 0.78]); FACES.forEach((f, idx) => { let [A, B, C] = f.map((i) => V[i]); const G = [ (A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3, (A[2] + B[2] + C[2]) / 3, ]; let n = norm(cross(sub(B, A), sub(C, A))); if (dot(n, G) < 0) { // ensure the normal points outward [B, C] = [C, B]; n = [-n[0], -n[1], -n[2]]; } const X = norm(sub(B, A)); // face-local right const Y = norm(cross(n, X)); // face-local down (X × Y = n, no mirroring) bases.push([X, Y, n]); const D = Math.hypot(...sub(B, A)); // edge length const face = document.createElement('span'); face.className = 'die-face'; face.textContent = NUMBERS[idx]; // Real dice underline the ambiguous digits if (NUMBERS[idx] === 6 || NUMBERS[idx] === 9) face.classList.add('die-face--mark'); const pts = [A, B, C].map((P) => { const p = sub(P, G); return `${dot(p, X) + D / 2}px ${dot(p, Y) + D / 2}px`; }); face.style.width = face.style.height = D + 'px'; face.style.margin = -D / 2 + 'px'; face.style.fontSize = D * 0.3 + 'px'; face.style.clipPath = `polygon(${pts.join(', ')})`; // Static lighting factor, applied to the roll's color when it's cast faceEls.push({ el: face, shade: 0.6 + 0.4 * Math.max(0, dot(n, light)) }); face.style.transform = mat3d([X, Y, n], G); die.appendChild(face); }); anchor.appendChild(scene); } // Rotation that lands face i flat toward the viewer, number upright function landing(i) { const [X, Y, Z] = bases[i]; return `matrix3d(${X[0]},${Y[0]},${Z[0]},0,${X[1]},${Y[1]},${Z[1]},0,` + `${X[2]},${Y[2]},${Z[2]},0,0,0,0,1)`; } let busy = false; anchor.addEventListener('click', () => { if (busy) return; busy = true; if (!scene) build(); scene.style.display = ''; scene.style.opacity = '1'; const result = Math.floor(Math.random() * 20) + 1; const final = landing(NUMBERS.indexOf(result)); // Cast the die in one of the theme pastels for this roll const [pr, pg, pb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; faceEls.forEach(({ el, shade }) => { el.style.background = `rgb(${Math.round(pr * shade)}, ${Math.round(pg * shade)}, ${Math.round(pb * shade)})`; }); // Nat 20: DOM sprinkles burst from the die — unlike the canvas ones, // these render above the cards, so the effect survives any backdrop const burst = () => { const r = scene.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; for (let i = 0; i < 24; i++) { const bit = document.createElement('span'); bit.className = 'burst-bit'; const [br, bg, bb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; bit.style.background = `rgb(${br}, ${bg}, ${bb})`; bit.style.left = cx + 'px'; bit.style.top = cy + 'px'; document.body.appendChild(bit); const a = Math.random() * Math.PI * 2; const d = 60 + Math.random() * 90; bit.animate( [ { transform: `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`, opacity: 1, }, { transform: `translate(calc(-50% + ${(Math.cos(a) * d).toFixed(1)}px), ` + `calc(-50% + ${(Math.sin(a) * d).toFixed(1)}px)) ` + `rotate(${((Math.random() - 0.5) * 720).toFixed(0)}deg)`, opacity: 0, }, ], { duration: 700 + Math.random() * 300, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' } ).finished.then(() => bit.remove()); } }; const finish = () => { if (result === 20 && !reduceMotion.matches) burst(); setTimeout(() => { scene.style.opacity = '0'; setTimeout(() => { scene.style.display = 'none'; busy = false; }, 320); }, 1100); }; if (reduceMotion.matches) { die.style.transform = final; finish(); return; } // One continuous decelerating roll from a fully random direction: the // die tumbles end-over-end along its travel (axis perpendicular to the // motion, in-plane) with a slower barrel roll around the travel axis. // Both extra rotations unwind to exactly zero on top of the landing // matrix, so it settles on the result with no correcting jerk. const ang = Math.random() * Math.PI * 2; const dx = Math.cos(ang); const dy = Math.sin(ang); const roll = 810 + Math.floor(Math.random() * 3) * 72; const wobble = 240 + Math.random() * 240; const tumbleAxis = `${dy.toFixed(4)},${(-dx).toFixed(4)},0`; const travelAxis = `${dx.toFixed(4)},${dy.toFixed(4)},0`; const anim = die.animate( [ { transform: `translate(${(-dx * 150).toFixed(1)}px, ${(-dy * 150).toFixed(1)}px) ` + `rotate3d(${tumbleAxis},${roll}deg) rotate3d(${travelAxis},${-wobble}deg) ${final}`, }, { transform: `translate(0px, 0px) rotate3d(${tumbleAxis},0deg) rotate3d(${travelAxis},0deg) ${final}`, }, ], { duration: 1400, easing: 'cubic-bezier(0.16, 0.6, 0.28, 1)', fill: 'forwards' } ); anim.finished.then(() => { die.style.transform = final; anim.cancel(); finish(); }); }); })(); // ── Name anagram: clicks cycle through rearrangements ───────── (function () { const h1 = document.querySelector('.hero-name'); if (!h1) return; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const NAME = 'Nikolaj Gade'; const CYCLE = [NAME, 'a joking deal', NAME, 'naked oil jag']; // The visual letters rearrange, but assistive tech always gets the name h1.setAttribute('aria-label', NAME); // Split into persistent per-letter spans; the exact glyphs get reused, // so the capitals travel (see "a jokiNG deal") const letters = []; h1.textContent = ''; for (const ch of NAME) { if (ch === ' ') { h1.appendChild(document.createTextNode(' ')); continue; } const s = document.createElement('span'); s.className = 'ltr'; s.textContent = ch; h1.appendChild(s); letters.push({ el: s, ch: ch.toLowerCase() }); } function arrange(phrase) { // Pick a distinct source glyph for every letter of the target const pool = [...letters]; const seq = []; for (const ch of phrase) { if (ch === ' ') { seq.push(null); continue; } const i = pool.findIndex((l) => l.ch === ch.toLowerCase()); seq.push(pool.splice(i, 1)[0].el); } // FLIP: measure, reorder the DOM, measure again, then animate each // glyph from its old position into its new one const first = new Map(letters.map((l) => [l.el, l.el.getBoundingClientRect()])); h1.textContent = ''; seq.forEach((el) => h1.appendChild(el || document.createTextNode(' '))); if (reduceMotion.matches) return Promise.resolve(); return Promise.all( letters.map((l, k) => { const a = first.get(l.el); const b = l.el.getBoundingClientRect(); return l.el.animate( [ { transform: `translate(${(a.left - b.left).toFixed(1)}px, ${(a.top - b.top).toFixed(1)}px)` }, { transform: 'translate(0, 0)' }, ], { duration: 650, delay: k * 18, fill: 'backwards', easing: 'cubic-bezier(0.3, 0.9, 0.3, 1)', } ).finished; }) ); } let pos = 0; let busy = false; h1.addEventListener('click', () => { if (busy) return; busy = true; pos = (pos + 1) % CYCLE.length; arrange(CYCLE[pos]).finally(() => { busy = false; }); }); })();