// ── PARTICLE CANVAS ─────────────────────────────────────────────────── (function initParticles() { const canvas = document.getElementById('particleCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const N = 65; const CONNECT_DIST = 130; let W, H, particles = []; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } function spawn() { particles = []; for (let i = 0; i < N; i++) { particles.push({ x: Math.random() * W, y: Math.random() * H, vx: (Math.random() - 0.5) * 0.25, vy: (Math.random() - 0.5) * 0.25, r: Math.random() * 1.2 + 0.4, a: Math.random() * 0.35 + 0.08, }); } } function draw() { ctx.clearRect(0, 0, W, H); for (let i = 0; i < N; i++) { const p = particles[i]; for (let j = i + 1; j < N; j++) { const q = particles[j]; const dx = p.x - q.x, dy = p.y - q.y; const d = Math.sqrt(dx * dx + dy * dy); if (d < CONNECT_DIST) { ctx.strokeStyle = `rgba(0,180,255,${0.09 * (1 - d / CONNECT_DIST)})`; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); } } ctx.fillStyle = `rgba(0,200,255,${p.a})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); p.x += p.vx; p.y += p.vy; if (p.x < 0) p.x = W; if (p.x > W) p.x = 0; if (p.y < 0) p.y = H; if (p.y > H) p.y = 0; } requestAnimationFrame(draw); } resize(); spawn(); draw(); window.addEventListener('resize', () => { resize(); spawn(); }); })(); // ── PANEL FLOAT STAGGER — different phase per panel ─────────────────── document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.panel').forEach((p, i) => { p.style.animationDelay = `-${(i * 1.37).toFixed(2)}s`; }); }); // ── MOUSE PARALLAX — panels tilt toward cursor ──────────────────────── (function initParallax() { const MAX_TILT = 3.5; // degrees let mouseX = 0, mouseY = 0, raf = null; window.addEventListener('mousemove', e => { mouseX = e.clientX / window.innerWidth - 0.5; // -0.5 to 0.5 mouseY = e.clientY / window.innerHeight - 0.5; if (!raf) raf = requestAnimationFrame(applyTilt); }); function applyTilt() { raf = null; const rx = mouseY * MAX_TILT; const ry = -mouseX * MAX_TILT; document.querySelectorAll('.panel').forEach(p => { p.style.setProperty('--prx', rx.toFixed(2) + 'deg'); p.style.setProperty('--pry', ry.toFixed(2) + 'deg'); }); // Column-level parallax (skip in focus mode) if (!document.getElementById('mainLayout')?.classList.contains('focus-mode')) { const lp = document.getElementById('leftPanel'); const rp = document.getElementById('rightPanel'); const cp = document.getElementById('centerPanel'); if (lp) lp.style.transform = `translateX(${mouseX * 5}px) translateY(${mouseY * 3}px)`; if (rp) rp.style.transform = `translateX(${-mouseX * 5}px) translateY(${mouseY * 3}px)`; if (cp) cp.style.transform = `translateX(${mouseX * 2}px)`; } } })(); // ── SPARKLINES ──────────────────────────────────────────────────────── const _sparkData = {cpu: [], mem: [], disk: []}; const SPARK_MAX = 25; function pushSparkData(key, val) { _sparkData[key].push(val); if (_sparkData[key].length > SPARK_MAX) _sparkData[key].shift(); } function drawSparkline(canvasId, data, color) { const canvas = document.getElementById(canvasId); if (!canvas || !data.length) return; const wrap = canvas.parentElement; canvas.width = wrap.clientWidth || 240; canvas.height = 32; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); const W = canvas.width, H = canvas.height; const min = 0, max = 100; const step = W / (SPARK_MAX - 1); // Fill area under line const grad = ctx.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, color.replace(')', ',0.35)').replace('rgb','rgba')); grad.addColorStop(1, color.replace(')', ',0)').replace('rgb','rgba')); ctx.beginPath(); data.forEach((v, i) => { const x = i * step; const y = H - ((v - min) / (max - min)) * H; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.lineTo((data.length - 1) * step, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); // Line ctx.beginPath(); data.forEach((v, i) => { const x = i * step; const y = H - ((v - min) / (max - min)) * H; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke(); // Current value dot if (data.length > 1) { const last = data[data.length - 1]; const x = (data.length - 1) * step; const y = H - ((last - min) / (max - min)) * H; ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke(); } } // ── PANEL DATA FLASH ────────────────────────────────────────────────── function flashPanel(panelEl) { if (!panelEl) return; panelEl.classList.remove('data-flash'); void panelEl.offsetWidth; // reflow to restart animation panelEl.classList.add('data-flash'); setTimeout(() => panelEl.classList.remove('data-flash'), 600); } // ── ALERT PULSE ─────────────────────────────────────────────────────── function setAlertState(hasAlerts) { const ov = document.getElementById('alertOverlay'); if (ov) ov.style.display = hasAlerts ? 'block' : 'none'; const vg = document.getElementById('vignetteOverlay'); if (vg) vg.classList.toggle('alert-vignette', hasAlerts); } function setSystemHealth(level) { // level: 'ok' | 'warning' | 'critical' const reactor = document.getElementById('arcReactor'); if (!reactor) return; reactor.classList.remove('health-warning', 'health-critical'); if (level === 'warning') reactor.classList.add('health-warning'); if (level === 'critical') reactor.classList.add('health-critical'); // Also update topbar logo dot const dot = document.querySelector('.tb-logo-dot'); if (dot) { dot.style.background = level === 'critical' ? 'var(--red)' : level === 'warning' ? '#f5a623' : 'var(--cyan)'; dot.style.boxShadow = level === 'critical' ? '0 0 8px var(--red)' : level === 'warning' ? '0 0 8px #f5a623' : '0 0 8px var(--cyan)'; } } // ── FACE TRACKING — reactor follows face position ───────────────────── let _faceTargetX = 0, _faceTargetY = 0; // normalized -0.5 to 0.5 let _faceCurrX = 0, _faceCurrY = 0; let _faceVisible = false; let _faceTrackRaf = null; const FACE_MAX_X = 48; // max px left/right travel const FACE_MAX_Y = 10; // max px up/down travel const FACE_LERP = 0.07; // smoothing (lower = slower/smoother) function _faceTrackLoop() { _faceTrackRaf = requestAnimationFrame(_faceTrackLoop); // Lerp toward target (or back to 0 when no face) const targetX = _faceVisible ? _faceTargetX : 0; const targetY = _faceVisible ? _faceTargetY : 0; _faceCurrX += (targetX - _faceCurrX) * FACE_LERP; _faceCurrY += (targetY - _faceCurrY) * FACE_LERP; const tx = _faceCurrX * FACE_MAX_X; const ty = _faceCurrY * FACE_MAX_Y; const logo = document.querySelector('.tb-logo'); if (logo) logo.style.transform = `translateX(${tx.toFixed(2)}px) translateY(${ty.toFixed(2)}px)`; } function updateFaceTarget(box, videoW, videoH) { // Face center, normalized — flip X because front camera is mirrored const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; _faceTargetX = 0.5 - (cx / videoW); // flipped: move right when face is left _faceTargetY = (cy / videoH) - 0.5; // positive = face low = logo drops slightly _faceVisible = true; // Position the scan overlay over the detected face in the viewport. // The video feed is 320×240 but hidden; map to viewport coords. const scaleX = window.innerWidth / videoW; const scaleY = window.innerHeight / videoH; const faceVx = box.x * scaleX; const faceVy = box.y * scaleY; const faceVw = box.width * scaleX; const faceVh = box.height * scaleY; const ov = document.getElementById('faceScanOverlay'); if (ov) { ov.style.display = 'block'; // Center overlay on face, flipped X to match mirror const ovX = window.innerWidth - faceVx - faceVw / 2 - 30; const ovY = faceVy + faceVh / 2 - 30; ov.style.left = Math.max(0, Math.min(window.innerWidth - 60, ovX)) + 'px'; ov.style.top = Math.max(0, Math.min(window.innerHeight - 80, ovY)) + 'px'; } } function clearFaceTarget() { _faceVisible = false; const ov = document.getElementById('faceScanOverlay'); if (ov) ov.style.display = 'none'; } function startFaceTracking() { const logo = document.querySelector('.tb-logo'); if (logo) logo.classList.add('face-tracking'); if (!_faceTrackRaf) _faceTrackLoop(); } function stopFaceTracking() { clearFaceTarget(); const logo = document.querySelector('.tb-logo'); if (logo) { logo.classList.remove('face-tracking'); logo.style.transform = ''; } // Let the loop coast to zero naturally rather than snapping — cancel after settling setTimeout(() => { if (!_faceVisible && _faceTrackRaf) { cancelAnimationFrame(_faceTrackRaf); _faceTrackRaf = null; } }, 1500); } // ── GLITCH EFFECT ───────────────────────────────────────────────────── (function initGlitch() { function triggerGlitch() { const el = document.querySelector('.tb-logo-text'); if (!el) return; el.classList.add('glitching'); setTimeout(() => el.classList.remove('glitching'), 280); setTimeout(triggerGlitch, 35000 + Math.random() * 25000); } setTimeout(triggerGlitch, 20000); })(); // ① HUD CORNER RINGS ────────────────────────────────────────────────── (function initHudCorners() { const canvas = document.getElementById('hudCornersCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); let W, H, t = 0; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; } function drawCorner(cx, cy, a0, a1) { const R = 62, R2 = R + 16; // Edge lines ctx.strokeStyle = 'rgba(0,212,255,0.35)'; ctx.lineWidth = 1; const edgeLen = 28; // two short lines along the screen edges from the corner point const midA = (a0 + a1) / 2; const ax = Math.cos(a0), ay = Math.sin(a0); const bx = Math.cos(a1), by = Math.sin(a1); ctx.beginPath(); ctx.moveTo(cx + ax*(R+6), cy + ay*(R+6)); ctx.lineTo(cx + ax*(R+6+edgeLen), cy + ay*(R+6+edgeLen)); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx + bx*(R+6), cy + by*(R+6)); ctx.lineTo(cx + bx*(R+6+edgeLen), cy + by*(R+6+edgeLen)); ctx.stroke(); // Primary arc ctx.beginPath(); ctx.arc(cx, cy, R, a0, a1); ctx.strokeStyle = 'rgba(0,212,255,0.5)'; ctx.lineWidth = 1.2; ctx.stroke(); // Outer arc ctx.beginPath(); ctx.arc(cx, cy, R2, a0 + 0.12, a1 - 0.12); ctx.strokeStyle = 'rgba(0,212,255,0.18)'; ctx.lineWidth = 0.6; ctx.stroke(); // Tick marks const ticks = 14; for (let i = 0; i <= ticks; i++) { const a = a0 + (a1 - a0) * (i / ticks); const big = i % 7 === 0; const len = big ? 9 : (i % 2 === 0 ? 5 : 3); ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * (R - 2), cy + Math.sin(a) * (R - 2)); ctx.lineTo(cx + Math.cos(a) * (R - 2 - len), cy + Math.sin(a) * (R - 2 - len)); ctx.strokeStyle = big ? 'rgba(0,212,255,0.8)' : 'rgba(0,212,255,0.3)'; ctx.lineWidth = big ? 1 : 0.5; ctx.stroke(); } // Animated scanning dot const dotA = a0 + ((a1 - a0) * ((t * 0.35) % 1)); ctx.beginPath(); ctx.arc(cx + Math.cos(dotA) * R, cy + Math.sin(dotA) * R, 2.5, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,212,255,1)'; ctx.shadowColor = 'rgba(0,212,255,0.9)'; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowBlur = 0; // Small numeric labels ctx.font = '7px Share Tech Mono,monospace'; ctx.fillStyle = 'rgba(0,212,255,0.55)'; ctx.fillText(Math.round((Math.abs(a0) / (Math.PI*2)) * 360) + '°', cx + Math.cos(a0) * (R + 20), cy + Math.sin(a0) * (R + 20)); } function draw() { ctx.clearRect(0, 0, W, H); t += 0.01; drawCorner(0, 0, 0, Math.PI*0.5); drawCorner(W, 0, Math.PI*0.5, Math.PI); drawCorner(0, H, Math.PI*1.5, Math.PI*2); drawCorner(W, H, Math.PI, Math.PI*1.5); requestAnimationFrame(draw); } resize(); draw(); window.addEventListener('resize', resize); })(); // ② DATA STREAM COLUMNS ─────────────────────────────────────────────── (function initDataStream() { const canvas = document.getElementById('dataStreamCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); let W, H; const CHARS = '0123456789ABCDEF◈◉▸▹⟩⟨⬡░▒'; const COL_COUNT = 22; let cols = []; function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; cols = []; for (let i = 0; i < COL_COUNT; i++) { const x = (i / COL_COUNT) * W + Math.random() * (W / COL_COUNT) * 0.6; cols.push({ x, y: Math.random() * H, speed: Math.random() * 0.7 + 0.25, len: Math.floor(Math.random() * 14 + 5), chars: [], alpha: Math.random() * 0.035 + 0.015, tick: 0 }); } } function draw() { ctx.clearRect(0, 0, W, H); ctx.font = '11px Share Tech Mono,monospace'; for (const c of cols) { c.y += c.speed; if (c.y - c.len * 14 > H) { c.y = -c.len * 14; c.alpha = Math.random() * 0.035 + 0.015; } if (++c.tick > 7) { c.tick = 0; c.chars[0] = CHARS[Math.floor(Math.random() * CHARS.length)]; } for (let i = 0; i < c.len; i++) { if (!c.chars[i]) c.chars[i] = CHARS[Math.floor(Math.random() * CHARS.length)]; const cy = c.y - i * 14; if (cy < -14 || cy > H + 14) continue; const a = c.alpha * (1 - i / c.len); ctx.fillStyle = i === 0 ? `rgba(180,240,255,${Math.min(a*4,0.15)})` : `rgba(0,185,225,${a})`; ctx.fillText(c.chars[i], c.x, cy); } } requestAnimationFrame(draw); } resize(); draw(); window.addEventListener('resize', resize); })(); // ③ NETWORK TOPOLOGY ────────────────────────────────────────────────── let _topoNodes = [], _topoT = 0, _topoRunning = false; function renderTopology(devices) { const canvas = document.getElementById('topoCanvas'); if (!canvas) return; const W = canvas.parentElement?.clientWidth || 260; canvas.width = W; canvas.height = 118; _topoNodes = devices.slice(0, 18).map((d, i, arr) => { const angle = (i / arr.length) * Math.PI * 2 - Math.PI / 2; const rx = W * 0.36, ry = 36; return { x: W/2 + Math.cos(angle) * rx * (0.6 + (i%3)*0.18), y: 52 + Math.sin(angle) * ry * (0.65 + (i%2)*0.25), label: (d.name || d.ip || '?').split('.')[0].substring(0, 9), on: !!(d.alive || d.status === 'online'), agent: d.source === 'agent', phase: Math.random() * Math.PI * 2, }; }); if (!_topoRunning) { _topoRunning = true; _drawTopo(); } } function _drawTopo() { requestAnimationFrame(_drawTopo); const canvas = document.getElementById('topoCanvas'); if (!canvas || !_topoNodes.length) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; _topoT += 0.018; ctx.clearRect(0, 0, W, H); const floatY = n => Math.sin(_topoT * 0.9 + n.phase) * 2.5; // Connections for (let i = 0; i < _topoNodes.length; i++) { for (let j = i+1; j < _topoNodes.length; j++) { const a = _topoNodes[i], b = _topoNodes[j]; if (!a.on || !b.on) continue; const ax = a.x, ay = a.y + floatY(a), bx = b.x, by = b.y + floatY(b); const dist = Math.hypot(bx-ax, by-ay); if (dist > W * 0.55) continue; const lg = ctx.createLinearGradient(ax, ay, bx, by); const col = a.agent ? '0,255,136' : '0,212,255'; lg.addColorStop(0, `rgba(${col},0.25)`); lg.addColorStop(0.5, `rgba(${col},0.1)`); lg.addColorStop(1, `rgba(${col},0.25)`); ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.strokeStyle = lg; ctx.lineWidth = 0.8; ctx.stroke(); // travelling pulse const p = (_topoT * 0.4 + a.phase) % 1; ctx.beginPath(); ctx.arc(ax+(bx-ax)*p, ay+(by-ay)*p, 1.8, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,212,255,0.85)'; ctx.fill(); } } // Bubble nodes for (const n of _topoNodes) { const pulse = 0.5 + Math.sin(_topoT * 1.4 + n.phase) * 0.3; const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80'; const r = n.agent ? 10 : 7; const nx = n.x, ny = n.y + floatY(n); if (n.on) { // Ambient bloom const bloom = ctx.createRadialGradient(nx, ny, r*0.4, nx, ny, r*3); bloom.addColorStop(0, `rgba(${col},${(pulse*0.18).toFixed(3)})`); bloom.addColorStop(1, `rgba(${col},0)`); ctx.beginPath(); ctx.arc(nx, ny, r*3, 0, Math.PI*2); ctx.fillStyle = bloom; ctx.fill(); } // Frosted glass fill const fg = ctx.createRadialGradient(nx, ny - r*0.3, 0, nx, ny, r); const fa = n.on ? 0.18 + pulse*0.1 : 0.07; fg.addColorStop(0, `rgba(${col},${(fa*1.8).toFixed(3)})`); fg.addColorStop(0.65, `rgba(${col},${fa.toFixed(3)})`); fg.addColorStop(1, `rgba(${col},${(fa*0.2).toFixed(3)})`); ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.fillStyle = fg; ctx.fill(); // Border ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.strokeStyle = `rgba(${col},${n.on ? (0.5 + pulse*0.32).toFixed(3) : '0.2'})`; ctx.lineWidth = 1; ctx.stroke(); // Label below bubble ctx.font = '6px Share Tech Mono,monospace'; ctx.textAlign = 'center'; ctx.fillStyle = n.on ? `rgba(${col},0.82)` : 'rgba(255,100,100,0.5)'; ctx.fillText(n.label, nx, ny + r + 7); ctx.textAlign = 'left'; } } // ④ EKG HEARTBEAT ──────────────────────────────────────────────────── (function initEKG() { const canvas = document.getElementById('ekgCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); let W, H, data = [], phase = 0; function resize() { const wrap = document.getElementById('ekgWrap'); W = canvas.width = wrap ? Math.max(100, wrap.clientWidth) : 180; H = canvas.height = 22; data = new Array(W).fill(0); } function ekgVal(p) { const c = p % 1; if (c < 0.28) return 0; if (c < 0.38) return Math.sin((c-0.28)/0.10*Math.PI) * 0.22; // P bump if (c < 0.43) return 0; // PR flat if (c < 0.45) return -(c-0.43)/0.02 * 0.18; // Q dip if (c < 0.465){ const f=(c-0.45)/0.015; return f<0.5?f*2*0.95:(2-f*2)*0.95; } // R spike if (c < 0.49) return -(c-0.465)/0.025 * 0.22; // S dip if (c < 0.52) return -(0.22-(c-0.49)/0.03*0.22); // back to baseline if (c < 0.70) return Math.sin((c-0.52)/0.18*Math.PI) * 0.28; // T wave return 0; } function draw() { phase += 0.0038; data.shift(); data.push(ekgVal(phase)); ctx.clearRect(0, 0, W, H); // Glow layer ctx.beginPath(); data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); }); ctx.strokeStyle='rgba(0,220,100,0.2)'; ctx.lineWidth=4; ctx.stroke(); // Line ctx.beginPath(); data.forEach((v,i) => { const y=H*0.5-v*H*0.44; i===0?ctx.moveTo(i,y):ctx.lineTo(i,y); }); ctx.strokeStyle='rgba(0,255,120,0.85)'; ctx.lineWidth=1.2; ctx.stroke(); requestAnimationFrame(draw); } resize(); draw(); window.addEventListener('resize', resize); })(); // ⑤ AUDIO WAVEFORM RING ─────────────────────────────────────────────── (function initAudioRing() { const canvas = document.getElementById('audioRingCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); const CX = 30, CY = 30, BARS = 28, INNER = 20; let t = 0; function draw() { t += 0.055; ctx.clearRect(0, 0, 60, 60); const listening = window.isListening; const speaking = window.isSpeaking; if (!listening && !speaking) { requestAnimationFrame(draw); return; } for (let i = 0; i < BARS; i++) { const angle = (i / BARS) * Math.PI * 2; let amp; if (speaking) { amp = 0.35 + Math.abs(Math.sin(t*4.1+i*0.9))*0.5 + Math.abs(Math.sin(t*9+i*1.7))*0.15; } else { amp = 0.08 + Math.abs(Math.sin(t*1.1+i*0.6))*0.18; } const barLen = 3 + amp * 11; ctx.beginPath(); ctx.moveTo(CX+Math.cos(angle)*INNER, CY+Math.sin(angle)*INNER); ctx.lineTo(CX+Math.cos(angle)*(INNER+barLen), CY+Math.sin(angle)*(INNER+barLen)); const alpha = speaking ? 0.55+amp*0.45 : 0.25+amp*0.35; ctx.strokeStyle = speaking ? `rgba(0,255,175,${alpha})` : `rgba(0,212,255,${alpha})`; ctx.lineWidth = 1.8; ctx.stroke(); } requestAnimationFrame(draw); } draw(); })(); // ⑥ STATIC NOISE BURSTS ─────────────────────────────────────────────── (function initStaticBursts() { function burst() { const panels = document.querySelectorAll('#app .panel'); if (panels.length) { const p = panels[Math.floor(Math.random() * panels.length)]; const n = document.createElement('div'); n.className = 'panel-noise-layer'; p.appendChild(n); setTimeout(() => n.remove(), 320); } setTimeout(burst, 75000 + Math.random() * 55000); } setTimeout(burst, 40000 + Math.random() * 30000); })(); // ⑦ AMBIENT COLOR CYCLE ─────────────────────────────────────────────── (function initAmbientColor() { let t = 0; const root = document.documentElement; function tick() { t += 0.00025; const g = Math.round(210 + Math.sin(t) * 22); const b = Math.round(248 + Math.cos(t * 1.15) * 28); root.style.setProperty('--cyan', `rgb(0,${g},${b})`); root.style.setProperty('--cyan2', `rgb(0,${Math.round(g*.82)},${Math.round(b*.87)})`); requestAnimationFrame(tick); } tick(); })();