/* Homepage interactions ported from option 2: floating venture icons, giant
words marquee, scroll-linked type, fireworks journey, themed 3D venture
objects (three.js r128) and the scaled portfolio dashboard. */
(() => {
'use strict';
const $ = (s, c = document) => c.querySelector(s);
const $$ = (s, c = document) => Array.from(c.querySelectorAll(s));
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
/* ---------- hero icons: one per venture ---------- */
const svg = (p) => '';
const ICONS = [
{ n: 'AI Software', side: 'l', x: '8%', y: '24%', r: '6deg', d: '0s', p: '' },
{ n: 'Marketing & AI Search', side: 'l', x: '15%', y: '50%', r: '-7deg', d: '-2s', p: '' },
{ n: 'Spirits', side: 'l', x: '6%', y: '73%', r: '8deg', d: '-4s', p: '' },
{ n: 'Education Technology', side: 'r', x: '8%', y: '22%', r: '-6deg', d: '-1s', p: '' },
{ n: 'Cybersecurity', side: 'r', x: '15%', y: '50%', r: '7deg', d: '-3s', p: '' },
{ n: 'E-Commerce', side: 'r', x: '6%', y: '73%', r: '-8deg', d: '-5s', p: '' },
];
const tileHTML = (i) => '
' + svg(i.p) + '
' + i.n.replace('&', '&') + '';
const fl = $('#floaters'), ir = $('#iconRow');
if (fl && ir) {
ICONS.forEach((i) => {
const t = document.createElement('div');
t.className = 't ' + i.side;
t.style.cssText = '--x:' + i.x + ';--y:' + i.y + ';--r:' + i.r + ';--d:' + i.d;
t.innerHTML = tileHTML(i);
fl.appendChild(t);
const t2 = document.createElement('div');
t2.className = 't';
t2.innerHTML = tileHTML(i);
ir.appendChild(t2);
});
}
/* emblem follows the cursor a little */
const emblemImg = $('#emblem img');
addEventListener('pointermove', (e) => {
if (!emblemImg || reduce || e.pointerType !== 'mouse') return;
const mx = (e.clientX / innerWidth) * 2 - 1, my = (e.clientY / innerHeight) * 2 - 1;
emblemImg.style.setProperty('--ry', (mx * 16).toFixed(1) + 'deg');
emblemImg.style.setProperty('--rx', (-my * 11).toFixed(1) + 'deg');
}, { passive: true });
/* neon words switch on when they scroll into view */
const awords = $('.awords');
if (awords) new IntersectionObserver((es, o) => es.forEach((e) => { if (e.isIntersecting) { awords.classList.add('is-in'); o.disconnect(); } }), { threshold: .3 }).observe(awords);
/* ---------- portfolio dashboard: scale the fixed-size cluster to its column ---------- */
const fit = $('.cluster-fit'), cl = fit && $('.cluster', fit);
const sizeCluster = () => {
if (!fit || !cl) return;
if (innerWidth <= 1100) { fit.style.height = ''; cl.style.removeProperty('--s'); return; }
const s = Math.min(1, fit.clientWidth / 760);
cl.style.setProperty('--s', s.toFixed(4));
fit.style.height = Math.round(740 * s) + 'px';
};
sizeCluster();
addEventListener('resize', sizeCluster);
/* ---------- scroll-linked playbook word ---------- */
const pbSec = $('#playbook'), pbWord = $('#pbword');
let ticking = false;
function onScroll() {
if (pbSec && pbWord) {
const rp = pbSec.getBoundingClientRect();
const kp = clamp((innerHeight - rp.top) / (innerHeight + rp.height), 0, 1);
pbWord.style.transform = 'translate3d(' + (reduce ? 0 : lerp(-6, -38, kp)).toFixed(1) + '%,-50%,0)';
}
}
addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(() => { ticking = false; onScroll(); }); } }, { passive: true });
addEventListener('resize', onScroll);
onScroll();
const book = $('#book');
if (book) book.addEventListener('click', () => book.classList.toggle('tap'));
/* ---------- giant words: moves on its own ---------- */
const wTrack = $('#wTrack'), wSpans = wTrack ? $$('span', wTrack) : [], wSec = $('#words');
let wx = 0, wVis = false;
if (wSec) new IntersectionObserver((es) => { wVis = es[0].isIntersecting; }).observe(wSec);
function marquee(dt) {
const half = wTrack.scrollWidth / 2;
if (!half) return;
wx -= (reduce ? 0 : 58) * dt;
if (wx <= -half) wx += half;
wTrack.style.transform = 'translate3d(' + wx.toFixed(1) + 'px,0,0)';
const vw = innerWidth;
wSpans.forEach((s) => {
const r = s.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - vw / 2) / (vw * .55);
s.style.opacity = (.25 + .75 * clamp(1 - d, 0, 1)).toFixed(3);
});
}
/* ---------- journey: fireworks, counters land in Dylan's stats row ---------- */
const jst = $('#jstage'), fw = $('#fw');
const FWOK = !!(jst && fw);
const fx = FWOK ? fw.getContext('2d') : null;
const pinEls = $$('.pn'), jx = $$('.jx'), jfinal = $('#jfinal'), jreplay = $('#jreplay');
const cEx = $('#cEx'), cInd = $('#cInd'), cYr = $('#cYr');
const PINX = [.12, .31, .5, .69, .88], PINF = [.22, .34, .46, .58, .72], LAUNCH = [200, 950, 1700, 2450, 3300];
const COLORS_DARK = ['#ffffff', '#9fe6ff', '#1fb7ff', '#3d8bff', '#d6ecff'];
const COLORS_LIGHT = ['#1a7fff', '#0b5fd0', '#1fb7ff', '#5b6cff', '#0870c4'];
const isLight = () => document.documentElement.getAttribute('data-theme') === 'light';
let FW = { w: 0, h: 0, floor: 0 }, PH = [0, 0, 0, 0, 0], rockets = [], parts = [], extra = [];
let fwState = 'idle', fwStart = 0, fwVis = false, cleared = true, fwLaunched = [false, false, false, false, false], fwPlayed = false;
const countUp = (el, to, suf) => {
if (reduce) { el.textContent = to + (suf || ''); return; }
const t0 = performance.now(), d = 1200;
(function tick(n) {
const k = clamp((n - t0) / d, 0, 1);
el.textContent = Math.round(to * (1 - Math.pow(1 - k, 3))) + (k >= 1 ? (suf || '') : '');
if (k < 1) requestAnimationFrame(tick);
})(t0);
};
const pop = (el) => { el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); };
function sizeFW() {
if (!FWOK) return;
const r = jst.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 2);
fw.width = Math.round(r.width * dpr); fw.height = Math.round(r.height * dpr);
fx.setTransform(dpr, 0, 0, dpr, 0, 0);
FW.w = r.width; FW.h = r.height; FW.floor = FW.h * .83;
pinEls.forEach((p, i) => { PH[i] = Math.round(FW.h * PINF[i] * .88); p.style.left = (PINX[i] * 100) + '%'; p.style.setProperty('--ph', PH[i] + 'px'); });
}
function burst(x, y, n, spread) {
const COLORS = isLight() ? COLORS_LIGHT : COLORS_DARK;
const base = COLORS[(Math.random() * COLORS.length) | 0], sp = spread || 1;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2, v = (50 + Math.random() * 190) * sp;
parts.push({ x, y, px: x, py: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0, max: .85 + Math.random() * .7, c: Math.random() < .65 ? base : COLORS[(Math.random() * COLORS.length) | 0], w: Math.random() < .2 ? 2.2 : 1.3 });
}
}
function launch(i) { pinEls[i].classList.add('rise'); rockets.push({ i, x: PINX[i] * FW.w, y: FW.floor, ty: FW.floor - PH[i], t: 0, dur: .55, trail: [] }); }
function arrive(i) {
const R = { x: PINX[i] * FW.w, y: FW.floor - PH[i] };
pinEls[i].classList.add('done');
if (i < 4) {
jx[i] && jx[i].classList.add('lit');
if (cEx) { cEx.textContent = i + 1; pop(cEx); }
burst(R.x, R.y, 72, 1);
} else {
burst(R.x, R.y, 120, 1.3);
extra = [{ t: .12, x: .3, y: .34 }, { t: .3, x: .68, y: .28 }, { t: .5, x: .5, y: .2 }, { t: .7, x: .15, y: .3 }, { t: .9, x: .85, y: .4 }];
jfinal.classList.add('show');
if (cInd) { countUp(cInd, 6); pop(cInd); }
if (cYr) { countUp(cYr, 8, '+'); pop(cYr); }
fwState = 'done'; fwPlayed = true; jreplay.classList.add('show');
}
}
function resetFW() {
fwState = 'idle'; rockets = []; parts = []; extra = [];
pinEls.forEach((p) => p.classList.remove('rise', 'done')); jx.forEach((x) => x.classList.remove('lit'));
[cEx, cInd, cYr].forEach((el) => el && (el.textContent = '0'));
jfinal.classList.remove('show'); jreplay.classList.remove('show');
fx.clearRect(0, 0, FW.w, FW.h);
}
function startFW() {
if (fwState !== 'idle') return;
sizeFW();
if (reduce) {
pinEls.forEach((p) => p.classList.add('rise', 'done')); jx.forEach((x) => x.classList.add('lit'));
cEx.textContent = '4'; cInd.textContent = '6'; cYr.textContent = '8+'; jfinal.classList.add('show');
fwState = 'done'; fwPlayed = true; return;
}
[cEx, cInd, cYr].forEach((el) => el && (el.textContent = '0'));
fwState = 'play'; fwStart = performance.now(); fwLaunched = [false, false, false, false, false];
}
if (FWOK) {
new IntersectionObserver((es) => {
const e = es[0]; fwVis = e.isIntersecting;
if (e.intersectionRatio >= .45 && fwState === 'idle' && !fwPlayed) startFW();
}, { threshold: [0, .45] }).observe(jst);
jreplay.addEventListener('click', () => { resetFW(); fwPlayed = false; startFW(); });
addEventListener('resize', sizeFW);
sizeFW();
}
function fwFrame(now, dt) {
if (fwState === 'idle' && !parts.length && !rockets.length) { if (!cleared) { fx.clearRect(0, 0, FW.w, FW.h); cleared = true; } return; }
cleared = false;
if (fwState === 'play') { const el = now - fwStart; LAUNCH.forEach((t, i) => { if (!fwLaunched[i] && el >= t) { fwLaunched[i] = true; launch(i); } }); }
if (extra.length) { extra.forEach((e) => { e.t -= dt; }); extra = extra.filter((e) => { if (e.t <= 0) { burst(e.x * FW.w, e.y * FW.h, 70, 1.1); return false; } return true; }); }
const light = isLight();
fx.clearRect(0, 0, FW.w, FW.h); fx.globalCompositeOperation = light ? 'source-over' : 'lighter'; fx.lineCap = 'round';
rockets = rockets.filter((r) => {
r.t += dt; const k = Math.min(1, r.t / r.dur), e = 1 - Math.pow(1 - k, 3); r.y = lerp(FW.floor, r.ty, e);
r.trail.push({ x: r.x + (Math.random() - .5) * 1.5, y: r.y }); if (r.trail.length > 14) r.trail.shift();
for (let j = 1; j < r.trail.length; j++) { const a = j / r.trail.length; fx.strokeStyle = (light ? 'rgba(26,127,255,' : 'rgba(159,230,255,') + (a * .8) + ')'; fx.lineWidth = 2.4 * a; fx.beginPath(); fx.moveTo(r.trail[j - 1].x, r.trail[j - 1].y); fx.lineTo(r.trail[j].x, r.trail[j].y); fx.stroke(); }
fx.fillStyle = light ? '#0b5fd0' : '#fff'; fx.beginPath(); fx.arc(r.x, r.y, 2.6, 0, 6.283); fx.fill();
if (k >= 1) { arrive(r.i); return false; } return true;
});
parts = parts.filter((p) => {
p.life += dt; if (p.life >= p.max) return false;
p.vx *= Math.exp(-1.3 * dt); p.vy = p.vy * Math.exp(-1.3 * dt) + 130 * dt;
p.px = p.x; p.py = p.y; p.x += p.vx * dt; p.y += p.vy * dt;
fx.globalAlpha = 1 - Math.pow(p.life / p.max, 2); fx.strokeStyle = p.c; fx.lineWidth = p.w;
fx.beginPath(); fx.moveTo(p.px, p.py); fx.lineTo(p.x, p.y); fx.stroke();
return true;
});
fx.globalAlpha = 1; fx.globalCompositeOperation = 'source-over';
}
/* ---------- main loop ---------- */
let last = performance.now();
(function loop(now) {
requestAnimationFrame(loop);
const dt = Math.min(.05, (now - last) / 1000); last = now;
if (wVis && wTrack) marquee(dt);
if (FWOK && (fwVis || fwState === 'play' || parts.length || rockets.length)) fwFrame(now, dt);
})(last);
/* ---------- ventures: tabs + a 3D object that matches each business ---------- */
const V = [
{ n: 'MARKETING & AI SEARCH', d: 'Leading teams through a constantly changing search landscape.', c: '#1fb7ff', m: 'magnifier' },
{ n: 'AI SOFTWARE', d: 'Building around new opportunities created by AI.', c: '#7a7bff', m: 'chip' },
{ n: 'SPIRITS', d: 'Taking a consumer brand from idea to market.', c: '#3fa9ff', m: 'bottle' },
{ n: 'EDUCATION TECHNOLOGY', d: 'Creating technology designed for real classrooms.', c: '#27d9ff', m: 'cap' },
{ n: 'CYBERSECURITY', d: 'Solving security challenges for growing businesses.', c: '#8a6bff', m: 'lock' },
{ n: 'E-COMMERCE', d: 'Turning product ideas into businesses built around real customers.', c: '#1a8bff', m: 'bag' },
];
const vtabs = $('#vtabs'), vpanel = $('#vpanel');
if (!vtabs || !vpanel) return;
const vt = $('#vt'), vname = $('#vname'), vdesc = $('#vdesc'), vnum = $('#vnum');
let vi = 0, vSwitch = () => {};
V.forEach((v, i) => {
const b = document.createElement('button');
b.type = 'button'; b.setAttribute('role', 'tab'); b.textContent = v.n;
b.addEventListener('click', () => setV(i));
vtabs.appendChild(b);
});
const vbtns = $$('button', vtabs);
function setV(i, first) {
vi = (i + V.length) % V.length;
vbtns.forEach((b, n) => b.setAttribute('aria-selected', String(n === vi)));
const apply = () => { vname.textContent = V[vi].n; vdesc.textContent = V[vi].d; vnum.textContent = '0' + (vi + 1) + ' / 06'; vpanel.style.setProperty('--ac', V[vi].c); vt.classList.remove('out'); };
if (first || reduce) apply(); else { vt.classList.add('out'); setTimeout(apply, 200); }
vSwitch(vi);
const sel = vbtns[vi];
if (!first && sel && vtabs.scrollWidth > vtabs.clientWidth) vtabs.scrollTo({ left: sel.offsetLeft - vtabs.clientWidth / 2 + sel.clientWidth / 2, behavior: 'smooth' });
}
$('#vprev').addEventListener('click', () => setV(vi - 1));
$('#vnext').addEventListener('click', () => setV(vi + 1));
vpanel.setAttribute('tabindex', '0');
vpanel.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') setV(vi + 1); if (e.key === 'ArrowLeft') setV(vi - 1); });
let sx0 = null;
vpanel.addEventListener('touchstart', (e) => { sx0 = e.touches[0].clientX; }, { passive: true });
vpanel.addEventListener('touchend', (e) => { if (sx0 === null) return; const dx = e.changedTouches[0].clientX - sx0; if (Math.abs(dx) > 50) setV(vi + (dx < 0 ? 1 : -1)); sx0 = null; });
setV(0, true);
if (typeof THREE === 'undefined') return;
(function () {
const canvas = $('#vcanvas');
let r;
try { r = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); } catch (e) { return; }
r.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
r.outputEncoding = THREE.sRGBEncoding; r.toneMapping = THREE.ACESFilmicToneMapping; r.toneMappingExposure = 1.2;
// Studio reflections painted on a canvas, as in option 2.
const ec = document.createElement('canvas'); ec.width = 1024; ec.height = 512;
const e = ec.getContext('2d');
const g0 = e.createLinearGradient(0, 0, 0, 512); g0.addColorStop(0, '#1b2f5c'); g0.addColorStop(.5, '#05070f'); g0.addColorStop(1, '#000');
e.fillStyle = g0; e.fillRect(0, 0, 1024, 512);
const box = (x, y, w, h, c, b) => { e.save(); e.shadowColor = c; e.shadowBlur = b; e.fillStyle = c; e.fillRect(x, y, w, h); e.restore(); };
box(120, 50, 300, 36, '#ffffff', 40); box(560, 40, 260, 30, '#d6e8ff', 40); box(250, 150, 26, 240, '#ffffff', 30); box(610, 130, 34, 260, '#2aa8ff', 34); box(860, 140, 30, 240, '#1a7fff', 34); box(40, 160, 26, 220, '#1fb7ff', 30); box(440, 200, 18, 200, '#bfe6ff', 26);
const tex = new THREE.CanvasTexture(ec); tex.mapping = THREE.EquirectangularReflectionMapping; tex.encoding = THREE.sRGBEncoding;
const env = new THREE.PMREMGenerator(r).fromEquirectangular(tex).texture;
const scene = new THREE.Scene(), cam = new THREE.PerspectiveCamera(32, 1, .1, 100);
cam.position.set(0, 0, 9);
const metal = new THREE.MeshStandardMaterial({ color: 0x0b2c6e, metalness: .9, roughness: .22, envMap: env, envMapIntensity: 1.5, emissive: 0x1a7fff, emissiveIntensity: .15 });
const chrome = new THREE.MeshStandardMaterial({ color: 0xdfe8f5, metalness: 1, roughness: .12, envMap: env, envMapIntensity: 1.6 });
const glass = new THREE.MeshPhysicalMaterial({ color: 0x9fdcff, metalness: .1, roughness: .05, transmission: .6, transparent: true, opacity: .55, envMap: env, envMapIntensity: 1.4 });
const glow = new THREE.MeshBasicMaterial({ color: 0x9fe6ff });
const holder = new THREE.Group();
scene.add(holder);
const key = new THREE.PointLight(0x1fb7ff, 60, 30); key.position.set(-4, 3, 6); scene.add(key);
scene.add(new THREE.AmbientLight(0x2a4a99, .8));
const k2 = new THREE.DirectionalLight(0xffffff, 1.1); k2.position.set(3, 4, 5); scene.add(k2);
const M = (geo, mat) => new THREE.Mesh(geo, mat);
const BUILD = {
// Marketing & AI Search: a magnifying glass over rising bars
magnifier() {
const g = new THREE.Group();
const ring = M(new THREE.TorusGeometry(1.05, .17, 24, 64), metal);
const lens = M(new THREE.CircleGeometry(1.0, 48), glass);
const handle = M(new THREE.CylinderGeometry(.16, .2, 1.6, 24), chrome);
handle.position.set(1.15, -1.15, 0); handle.rotation.z = Math.PI / 4;
const lensGroup = new THREE.Group(); lensGroup.add(ring, lens, handle); lensGroup.position.set(.25, .35, .35);
[[-.75, .45], [-.25, .8], [.25, 1.15]].forEach(([x, h], i) => {
const bar = M(new THREE.BoxGeometry(.32, h, .32), i === 2 ? glow : metal);
bar.position.set(x - .1, -1.05 + h / 2, -.6); g.add(bar);
});
g.add(lensGroup);
return g;
},
// AI Software: a processor chip with pins and a glowing core
chip() {
const g = new THREE.Group();
g.add(M(new THREE.BoxGeometry(2.2, 2.2, .32), metal));
const core = M(new THREE.BoxGeometry(1.1, 1.1, .1), glow); core.position.z = .2; g.add(core);
const pinGeo = new THREE.BoxGeometry(.14, .42, .1);
for (let s = 0; s < 4; s++) for (let i = 0; i < 5; i++) {
const p = M(pinGeo, chrome); const off = -0.8 + i * .4;
if (s === 0) p.position.set(off, 1.3, 0);
if (s === 1) p.position.set(off, -1.3, 0);
if (s === 2) { p.position.set(1.3, off, 0); p.rotation.z = Math.PI / 2; }
if (s === 3) { p.position.set(-1.3, off, 0); p.rotation.z = Math.PI / 2; }
g.add(p);
}
g.rotation.x = -.35;
return g;
},
// Spirits: a bottle (lathe profile) with a chrome cap
bottle() {
const g = new THREE.Group();
const pts = [[0, -1.7], [.78, -1.7], [.86, -1.55], [.86, .35], [.7, .7], [.32, 1.05], [.27, 1.55], [0, 1.55]].map(([x, y]) => new THREE.Vector2(x, y));
g.add(M(new THREE.LatheGeometry(pts, 48), glass));
const liquid = M(new THREE.CylinderGeometry(.78, .78, 1.6, 48), metal); liquid.position.y = -.85; g.add(liquid);
const label = M(new THREE.CylinderGeometry(.875, .875, .55, 48, 1, true), glow); label.position.y = -.35; g.add(label);
const cap = M(new THREE.CylinderGeometry(.3, .3, .4, 32), chrome); cap.position.y = 1.72; g.add(cap);
g.scale.setScalar(.85);
return g;
},
// Education Technology: a graduation cap
cap() {
const g = new THREE.Group();
const board = M(new THREE.BoxGeometry(2.6, .12, 2.6), metal); board.rotation.y = Math.PI / 4; board.position.y = .45; g.add(board);
g.add(M(new THREE.CylinderGeometry(.95, 1.05, .9, 40, 1, true), chrome));
const bottom = M(new THREE.CircleGeometry(.95, 40), metal); bottom.rotation.x = Math.PI / 2; bottom.position.y = -.45; g.add(bottom);
const button = M(new THREE.SphereGeometry(.12, 16, 16), glow); button.position.y = .55; g.add(button);
const cord = M(new THREE.CylinderGeometry(.03, .03, 1.3, 8), glow); cord.position.set(.85, -.1, .85); g.add(cord);
const tassel = M(new THREE.ConeGeometry(.12, .4, 16), glow); tassel.position.set(.85, -.85, .85); g.add(tassel);
g.rotation.x = .35;
return g;
},
// Cybersecurity: a padlock
lock() {
const g = new THREE.Group();
const body = M(new THREE.BoxGeometry(2, 1.6, .7), metal); body.position.y = -.5; g.add(body);
const shackle = M(new THREE.TorusGeometry(.62, .16, 20, 48, Math.PI), chrome); shackle.position.y = .3; g.add(shackle);
[-.62, .62].forEach((x) => { const leg = M(new THREE.CylinderGeometry(.16, .16, .5, 20), chrome); leg.position.set(x, .05, 0); g.add(leg); });
const hole = M(new THREE.CylinderGeometry(.17, .17, .1, 24), glow); hole.rotation.x = Math.PI / 2; hole.position.set(0, -.35, .36); g.add(hole);
const slot = M(new THREE.BoxGeometry(.12, .4, .1), glow); slot.position.set(0, -.62, .36); g.add(slot);
return g;
},
// E-commerce: a shopping bag with handles
bag() {
const g = new THREE.Group();
g.add(M(new THREE.BoxGeometry(2, 2.2, .9), metal));
const band = M(new THREE.BoxGeometry(2.02, .28, .92), glow); band.position.y = .6; g.add(band);
[-.42, .42].forEach((z) => { const h = M(new THREE.TorusGeometry(.5, .09, 16, 40, Math.PI), chrome); h.position.set(0, 1.1, z * .9); g.add(h); });
const tag = M(new THREE.BoxGeometry(.5, .32, .05), chrome); tag.position.set(.55, -.45, .48); tag.rotation.z = -.25; g.add(tag);
g.rotation.x = .1; g.scale.setScalar(.9);
return g;
},
};
let model = null, born = 0;
const dispose = (o) => o.traverse((c) => { if (c.geometry) c.geometry.dispose(); });
vSwitch = (i) => {
if (model) { holder.remove(model); dispose(model); }
model = BUILD[V[i].m]();
holder.add(model);
born = performance.now();
key.color.set(V[i].c); metal.emissive.set(V[i].c); glow.color.set(V[i].c).lerp(new THREE.Color(0xffffff), .45);
};
vSwitch(vi);
let vis = true;
new IntersectionObserver((es) => { vis = es[0].isIntersecting; }).observe(canvas);
const resize = () => { const w = canvas.clientWidth || 1, h = canvas.clientHeight || 1; r.setSize(w, h, false); cam.aspect = w / h; cam.updateProjectionMatrix(); };
addEventListener('resize', resize); resize();
let mx = 0, my = 0;
vpanel.addEventListener('pointermove', (ev) => { const b = vpanel.getBoundingClientRect(); mx = ((ev.clientX - b.left) / b.width - .5) * 2; my = ((ev.clientY - b.top) / b.height - .5) * 2; });
let lt = performance.now();
(function loop(now) {
requestAnimationFrame(loop);
if (!vis) return;
const dt = Math.min(.05, (now - lt) / 1000); lt = now;
const k = reduce ? 1 : clamp((now - born) / 650, 0, 1), sc = reduce ? 1 : (.3 + .7 * (1 - Math.pow(1 - k, 3)));
holder.scale.setScalar(sc);
if (!reduce) { holder.rotation.y += dt * (.45 + (1 - k) * 6); }
holder.rotation.x += ((my * .35) - holder.rotation.x) * .06;
holder.position.x += ((mx * .5) - holder.position.x) * .06;
r.render(scene, cam);
})(lt);
})();
})();