🐐
This commit is contained in:
@@ -284,3 +284,447 @@
|
||||
}).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 = `<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; });
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
// ── 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; });
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user