mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
462ce257a8
Split monolithic 261KB index.html into maintainable modules: - assets/css/jarvis.css (65KB, 1103 lines) — all styles - assets/js/jarvis-effects.js (23KB) — particle canvas, sparklines, panel float, face tracking, glitch - assets/js/jarvis-overlays.js (17KB) — sleep mode, network map - assets/js/jarvis-app.js (60KB) — globals, init, login, API, panels, chat, voice, alerts, weather, news, planner - assets/js/jarvis-protocols.js (69KB) — arc reactor, intel/comms/guardian/mission/directives/clearance/sites/vision, history search, suggestions, mobile index.html is now a 25KB thin HTML shell with link/script tags. Load order preserved; all cross-file dependencies resolve at runtime after window.load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
591 lines
23 KiB
JavaScript
591 lines
23 KiB
JavaScript
// ── 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();
|
||
})();
|