mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Network topology overlay: voice/chat triggered full-screen viz with directional flow particles
- Voice: say show network map / network topology / show connections to open - Voice: say close map / dismiss / close network to close - Same commands work in chat text input - Explode animation: overlay expands from top-left reactor position with clip-path wipe - Collapse animation: folds back to reactor on close - Visualization: live node graph with bezier curved edges, hub (JARVIS) at center - Inner ring: all registered agents (agents color-coded by type: proxmox=green, HA=gold, etc) - Outer ring: netscan-discovered devices - Rotating orbit rings on hub and agent nodes - Pulsing radial glow per node keyed to online status - Hub cross-hair targeting lines - Directional particle flow: - CYAN particles: data/heartbeats flowing FROM agents TO JARVIS hub - ORANGE particles: commands flowing FROM JARVIS hub TO agents - All particles travel curved bezier paths, fade at endpoints, glow with shadows - Mouse hover: node info card shows name/IP/status/type - Stats bar: total nodes, online count, agent count - Background: faint hex grid overlay for sci-fi depth
This commit is contained in:
+428
-1
@@ -754,6 +754,67 @@ body::after{
|
||||
animation:shimmer 1.5s infinite;border-radius:4px;height:12px;
|
||||
}
|
||||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||||
|
||||
/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */
|
||||
#netMapOverlay{
|
||||
position:fixed;top:48px;left:0;
|
||||
width:100vw;height:calc(100vh - 80px);
|
||||
z-index:200;
|
||||
display:none;flex-direction:column;
|
||||
background:rgba(0,4,16,0.97);
|
||||
border-right:1px solid rgba(0,212,255,0.35);
|
||||
border-bottom:1px solid rgba(0,212,255,0.35);
|
||||
transform-origin:0 0;
|
||||
backdrop-filter:blur(12px);
|
||||
overflow:hidden;
|
||||
}
|
||||
#netMapOverlay::after{
|
||||
content:'';position:absolute;inset:0;pointer-events:none;z-index:1;
|
||||
background:
|
||||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 20px 1px no-repeat,
|
||||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top left / 1px 20px no-repeat,
|
||||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 20px 1px no-repeat,
|
||||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) top right / 1px 20px no-repeat,
|
||||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 20px 1px no-repeat,
|
||||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom left / 1px 20px no-repeat,
|
||||
linear-gradient(to right,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 20px 1px no-repeat,
|
||||
linear-gradient(to bottom,rgba(0,212,255,0.7),rgba(0,212,255,0.7)) bottom right / 1px 20px no-repeat;
|
||||
}
|
||||
#netMapOverlay.nm-open{display:flex;animation:nmExplode 0.5s cubic-bezier(0.4,0,0.2,1) forwards}
|
||||
#netMapOverlay.nm-closing{animation:nmCollapse 0.32s cubic-bezier(0.4,0,0.2,1) forwards}
|
||||
@keyframes nmExplode{0%{transform:scale(0.04,0.06);opacity:0;clip-path:inset(0 100% 100% 0)}60%{opacity:1}100%{transform:scale(1,1);opacity:1;clip-path:inset(0 0% 0% 0)}}
|
||||
@keyframes nmCollapse{0%{transform:scale(1,1);opacity:1}100%{transform:scale(0.04,0.06);opacity:0}}
|
||||
#nmHeader{
|
||||
display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 18px;flex-shrink:0;
|
||||
border-bottom:1px solid rgba(0,212,255,0.18);
|
||||
background:rgba(0,8,28,0.6);
|
||||
z-index:2;position:relative;
|
||||
}
|
||||
#nmTitle{font-family:var(--font-display);font-size:0.65rem;letter-spacing:4px;color:var(--cyan);display:flex;align-items:center;gap:12px}
|
||||
#nmTitle .nm-pulse{width:7px;height:7px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);animation:corePulse 1.5s infinite}
|
||||
#nmStats{font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);display:flex;gap:20px}
|
||||
#nmStats span{color:var(--cyan)}
|
||||
#nmClose{background:none;border:1px solid rgba(0,212,255,0.3);color:var(--text-dim);font-family:var(--font-mono);font-size:0.58rem;padding:4px 12px;cursor:pointer;letter-spacing:2px;transition:all 0.2s}
|
||||
#nmClose:hover{border-color:var(--red);color:var(--red)}
|
||||
#nmCanvas{flex:1;display:block;z-index:2;position:relative}
|
||||
#nmLegend{
|
||||
display:flex;gap:18px;align-items:center;
|
||||
padding:6px 18px;flex-shrink:0;
|
||||
border-top:1px solid rgba(0,212,255,0.12);
|
||||
font-family:var(--font-mono);font-size:0.56rem;color:var(--text-dim);
|
||||
background:rgba(0,8,28,0.6);z-index:2;position:relative;
|
||||
}
|
||||
.nm-leg-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px;flex-shrink:0}
|
||||
#nmNodeInfo{
|
||||
position:absolute;pointer-events:none;z-index:10;
|
||||
background:rgba(0,8,30,0.95);border:1px solid rgba(0,212,255,0.4);
|
||||
padding:8px 12px;font-family:var(--font-mono);font-size:0.62rem;
|
||||
display:none;min-width:160px;
|
||||
box-shadow:0 0 20px rgba(0,212,255,0.15);
|
||||
}
|
||||
#nmNodeInfo .ni-title{color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:4px}
|
||||
#nmNodeInfo .ni-row{color:var(--text-dim);margin:2px 0}
|
||||
.text-cyan{color:var(--cyan)}
|
||||
.text-green{color:var(--green)}
|
||||
.text-orange{color:var(--orange)}
|
||||
@@ -1057,6 +1118,35 @@ body::after{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- NETWORK MAP OVERLAY -->
|
||||
<div id="netMapOverlay">
|
||||
<div id="nmHeader">
|
||||
<div id="nmTitle">
|
||||
<div class="nm-pulse"></div>
|
||||
◈ NETWORK TOPOLOGY — LIVE FEED
|
||||
</div>
|
||||
<div id="nmStats">
|
||||
NODES <span id="nm-node-count">—</span> |
|
||||
ONLINE <span id="nm-online-count">—</span> |
|
||||
AGENTS <span id="nm-agent-count">—</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:14px">
|
||||
<div style="font-family:var(--font-mono);font-size:0.55rem;color:var(--text-dim);letter-spacing:1px">SAY "CLOSE MAP" TO DISMISS</div>
|
||||
<button id="nmClose" onclick="closeNetMap()">✕ CLOSE</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="nmCanvas"></canvas>
|
||||
<div id="nmLegend">
|
||||
<span><span class="nm-leg-dot" style="background:#00d4ff;box-shadow:0 0 5px #00d4ff"></span>AGENT ONLINE</span>
|
||||
<span><span class="nm-leg-dot" style="background:#ff2244;box-shadow:0 0 5px #ff2244"></span>AGENT OFFLINE</span>
|
||||
<span><span class="nm-leg-dot" style="background:#00ff88;box-shadow:0 0 5px #00ff88"></span>PROXMOX</span>
|
||||
<span><span class="nm-leg-dot" style="background:#ffd700;box-shadow:0 0 5px #ffd700"></span>HA / AI</span>
|
||||
<span><span class="nm-leg-dot" style="background:rgba(0,180,200,0.4)"></span>DEVICE</span>
|
||||
<span style="margin-left:auto;opacity:0.5">CYAN FLOW = DATA IN · ORANGE FLOW = COMMANDS OUT</span>
|
||||
</div>
|
||||
<div id="nmNodeInfo"><div class="ni-title" id="ni-name">—</div><div class="ni-row" id="ni-ip">—</div><div class="ni-row" id="ni-status">—</div><div class="ni-row" id="ni-type">—</div></div>
|
||||
</div>
|
||||
|
||||
<div id="sitesModal" style="position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:none;align-items:flex-start;justify-content:center;padding:24px;overflow-y:auto">
|
||||
<div style="background:var(--panel-bg);border:1px solid var(--panel-border);width:100%;max-width:960px;font-family:var(--font-mono)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 24px;border-bottom:1px solid var(--panel-border)">
|
||||
@@ -1651,6 +1741,318 @@ function _drawTopo() {
|
||||
tick();
|
||||
})();
|
||||
|
||||
// ── NETWORK MAP OVERLAY ───────────────────────────────────────────────
|
||||
let _nmNodes = [], _nmEdges = [], _nmParticles = [], _nmRaf = null, _nmT = 0;
|
||||
let _nmHoverNode = null;
|
||||
const NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network\s*(map|topology|viz|visual|status|graph|diagram)|topology|node\s*map|connection\s*map)\b|\b(network\s*(map|topology|viz|visual|graph|diagram))\b/i;
|
||||
const NM_CLOSE_RE = /\b(close|hide|dismiss|exit|shut|collapse)\b.*\b(network|map|topology|overlay|viz)\b|\b(close\s*map|hide\s*map|dismiss\s*map)\b/i;
|
||||
|
||||
async function openNetMap() {
|
||||
const ov = document.getElementById('netMapOverlay');
|
||||
if (!ov) return;
|
||||
ov.classList.remove('nm-closing');
|
||||
ov.classList.add('nm-open');
|
||||
|
||||
// Fetch network data
|
||||
let devices = [];
|
||||
try { const n = await api('network'); devices = n.devices || []; } catch(_) {}
|
||||
|
||||
_buildNetGraph(devices);
|
||||
_startNetDraw();
|
||||
}
|
||||
|
||||
function closeNetMap() {
|
||||
const ov = document.getElementById('netMapOverlay');
|
||||
if (!ov) return;
|
||||
ov.classList.add('nm-closing');
|
||||
setTimeout(() => { ov.classList.remove('nm-open','nm-closing'); }, 350);
|
||||
if (_nmRaf) { cancelAnimationFrame(_nmRaf); _nmRaf = null; }
|
||||
}
|
||||
|
||||
function _nodeColor(n) {
|
||||
if (n.type === 'hub') return {r:0, g:212, b:255};
|
||||
if (n.type === 'proxmox') return {r:0, g:255, b:136};
|
||||
if (n.type === 'ha') return {r:255,g:215, b:0 };
|
||||
if (n.type === 'ai') return {r:255,g:215, b:0 };
|
||||
if (n.type === 'pbx') return {r:255,g:140, b:0 };
|
||||
if (!n.online) return {r:255,g:34, b:68 };
|
||||
if (n.agent) return {r:0, g:212, b:255};
|
||||
return {r:0, g:160, b:200};
|
||||
}
|
||||
|
||||
function _buildNetGraph(devices) {
|
||||
const W = document.getElementById('nmCanvas')?.clientWidth || window.innerWidth;
|
||||
const H = document.getElementById('nmCanvas')?.clientHeight || (window.innerHeight - 130);
|
||||
|
||||
const cx = W / 2, cy = H / 2;
|
||||
_nmNodes = []; _nmEdges = []; _nmParticles = [];
|
||||
|
||||
// Hub node: JARVIS
|
||||
_nmNodes.push({ id:'jarvis', label:'JARVIS', sub:'165.22.1.228 · DO', type:'hub',
|
||||
online:true, agent:true, x:cx, y:cy, r:22, pulse:0, vx:0, vy:0 });
|
||||
|
||||
// Categorise devices
|
||||
const agents = devices.filter(d => d.source === 'agent');
|
||||
const scanned = devices.filter(d => d.source !== 'agent');
|
||||
|
||||
// Assign node types from hostname/agent_type
|
||||
function classifyAgent(d) {
|
||||
const h = (d.name||'').toLowerCase();
|
||||
if (d.agent_type==='proxmox' || h.includes('pve') || h.includes('proxmox')) return 'proxmox';
|
||||
if (d.agent_type==='homeassistant' || h.includes('homeassist') || h.includes('_ha')) return 'ha';
|
||||
if (h.includes('ollama') || h.includes('ai')) return 'ai';
|
||||
if (h.includes('fusion') || h.includes('pbx')) return 'pbx';
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
// Place agents in inner ring, scanned in outer ring
|
||||
const innerR = Math.min(cx, cy) * 0.40;
|
||||
const outerR = Math.min(cx, cy) * 0.72;
|
||||
|
||||
agents.forEach((d, i) => {
|
||||
const a = (i / Math.max(agents.length, 1)) * Math.PI * 2 - Math.PI / 2;
|
||||
const jitter = (Math.random() - 0.5) * 30;
|
||||
_nmNodes.push({
|
||||
id: d.agent_id || d.ip || ('a'+i),
|
||||
label: (d.name || d.ip || '?').replace(/_[a-f0-9]{6,}$/, '').substring(0, 14),
|
||||
sub: d.ip || '',
|
||||
type: classifyAgent(d),
|
||||
online: !!(d.alive || d.status === 'online'),
|
||||
agent: true,
|
||||
x: cx + Math.cos(a) * (innerR + jitter),
|
||||
y: cy + Math.sin(a) * (innerR + jitter),
|
||||
r: 13, pulse: Math.random() * Math.PI * 2, vx: 0, vy: 0,
|
||||
});
|
||||
});
|
||||
|
||||
scanned.forEach((d, i) => {
|
||||
const a = (i / Math.max(scanned.length, 1)) * Math.PI * 2 - Math.PI / 4;
|
||||
const jitter = (Math.random() - 0.5) * 25;
|
||||
_nmNodes.push({
|
||||
id: d.ip || ('s'+i),
|
||||
label: (d.name || d.ip || '?').substring(0, 12),
|
||||
sub: d.ip || '',
|
||||
type: 'device',
|
||||
online: !!(d.alive || d.status === 'online'),
|
||||
agent: false,
|
||||
x: cx + Math.cos(a) * (outerR + jitter),
|
||||
y: cy + Math.sin(a) * (outerR + jitter),
|
||||
r: 7, pulse: Math.random() * Math.PI * 2, vx: 0, vy: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Build edges (all nodes → hub; proxmox nodes interconnect)
|
||||
const hub = _nmNodes[0];
|
||||
for (let i = 1; i < _nmNodes.length; i++) {
|
||||
const n = _nmNodes[i];
|
||||
if (!n.online && !hub.online) continue;
|
||||
_nmEdges.push({ from: i, to: 0, strength: n.agent ? 1 : 0.4 });
|
||||
}
|
||||
// Cross-link proxmox nodes
|
||||
const pveNodes = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.type==='proxmox');
|
||||
for (let a = 0; a < pveNodes.length; a++)
|
||||
for (let b = a+1; b < pveNodes.length; b++)
|
||||
_nmEdges.push({ from: pveNodes[a].i, to: pveNodes[b].i, strength: 0.6 });
|
||||
|
||||
// Spawn particles for each edge
|
||||
_nmEdges.forEach((e, ei) => {
|
||||
const count = e.strength > 0.8 ? 5 : (e.strength > 0.5 ? 3 : 2);
|
||||
for (let p = 0; p < count; p++) {
|
||||
// Cyan particles: node → hub (data in)
|
||||
_nmParticles.push({ edge: ei, t: Math.random(), dir: 'in',
|
||||
speed: 0.003 + Math.random() * 0.003, r: 2.2 + Math.random() });
|
||||
// Orange particles: hub → node (commands out) — fewer, slower
|
||||
if (e.strength > 0.6 && Math.random() > 0.4) {
|
||||
_nmParticles.push({ edge: ei, t: Math.random(), dir: 'out',
|
||||
speed: 0.0018 + Math.random() * 0.002, r: 1.8 + Math.random() * 0.8 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update stats bar
|
||||
const online = _nmNodes.filter(n=>n.online).length;
|
||||
const agentCount = _nmNodes.filter(n=>n.agent).length;
|
||||
const el = id => document.getElementById(id);
|
||||
if(el('nm-node-count')) el('nm-node-count').textContent = _nmNodes.length;
|
||||
if(el('nm-online-count')) el('nm-online-count').textContent = online;
|
||||
if(el('nm-agent-count')) el('nm-agent-count').textContent = agentCount;
|
||||
}
|
||||
|
||||
function _bezierPt(ax, ay, bx, by, t) {
|
||||
// Cubic bezier with perpendicular control points for curved lines
|
||||
const mx = (ax + bx) / 2, my = (ay + by) / 2;
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const cpx = mx - dy * 0.25, cpy = my + dx * 0.25;
|
||||
const inv = 1 - t;
|
||||
return {
|
||||
x: inv*inv*ax + 2*inv*t*cpx + t*t*bx,
|
||||
y: inv*inv*ay + 2*inv*t*cpy + t*t*by,
|
||||
};
|
||||
}
|
||||
|
||||
function _startNetDraw() {
|
||||
if (_nmRaf) cancelAnimationFrame(_nmRaf);
|
||||
const canvas = document.getElementById('nmCanvas');
|
||||
if (!canvas) return;
|
||||
// Resize canvas to its display size
|
||||
canvas.width = canvas.clientWidth || window.innerWidth;
|
||||
canvas.height = canvas.clientHeight || (window.innerHeight - 130);
|
||||
|
||||
function draw() {
|
||||
if (!document.getElementById('netMapOverlay')?.classList.contains('nm-open')) return;
|
||||
_nmRaf = requestAnimationFrame(draw);
|
||||
_nmT += 0.016;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Background hex-grid tint
|
||||
ctx.strokeStyle = 'rgba(0,180,255,0.04)';
|
||||
ctx.lineWidth = 0.5;
|
||||
const gs = 38;
|
||||
for (let x = 0; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
|
||||
for (let y = 0; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
|
||||
|
||||
// Draw edges
|
||||
_nmEdges.forEach(e => {
|
||||
const a = _nmNodes[e.from], b = _nmNodes[e.to];
|
||||
if (!a || !b) return;
|
||||
const steps = 60;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const p = _bezierPt(a.x, a.y, b.x, b.y, i / steps);
|
||||
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
|
||||
}
|
||||
const alive = a.online && b.online;
|
||||
ctx.strokeStyle = alive ? 'rgba(0,180,220,0.14)' : 'rgba(100,20,40,0.12)';
|
||||
ctx.lineWidth = e.strength * 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Draw particles
|
||||
_nmParticles.forEach(p => {
|
||||
p.t += p.speed;
|
||||
if (p.t > 1) p.t -= 1;
|
||||
const e = _nmEdges[p.edge];
|
||||
if (!e) return;
|
||||
const a = _nmNodes[e.from], b = _nmNodes[e.to];
|
||||
if (!a?.online || !b?.online) return;
|
||||
const t = p.dir === 'in' ? p.t : 1 - p.t;
|
||||
const pt = _bezierPt(a.x, a.y, b.x, b.y, t);
|
||||
|
||||
// Fade near endpoints
|
||||
const fade = Math.min(t * 6, (1 - t) * 6, 1);
|
||||
if (p.dir === 'in') {
|
||||
// Cyan: data flowing to hub
|
||||
ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(0,212,255,${(0.6 + Math.sin(_nmT*3+p.t*10)*0.3) * fade})`;
|
||||
ctx.shadowColor = 'rgba(0,212,255,0.6)'; ctx.shadowBlur = 6;
|
||||
ctx.fill(); ctx.shadowBlur = 0;
|
||||
} else {
|
||||
// Orange: commands from hub
|
||||
ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r * 0.85, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255,140,0,${(0.55 + Math.sin(_nmT*4+p.t*8)*0.25) * fade})`;
|
||||
ctx.shadowColor = 'rgba(255,120,0,0.5)'; ctx.shadowBlur = 5;
|
||||
ctx.fill(); ctx.shadowBlur = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Draw nodes
|
||||
_nmNodes.forEach((n, ni) => {
|
||||
const col = _nodeColor(n);
|
||||
const pulse = 0.55 + Math.sin(_nmT * 1.6 + n.pulse) * 0.3;
|
||||
const isHover = _nmHoverNode === ni;
|
||||
const baseR = n.r + (isHover ? 4 : 0);
|
||||
|
||||
if (n.online) {
|
||||
// Outer glow ring
|
||||
const glowR = baseR + 10 + Math.sin(_nmT + n.pulse) * 4;
|
||||
const g = ctx.createRadialGradient(n.x, n.y, baseR * 0.5, n.x, n.y, glowR);
|
||||
g.addColorStop(0, `rgba(${col.r},${col.g},${col.b},${pulse * 0.3})`);
|
||||
g.addColorStop(1, `rgba(${col.r},${col.g},${col.b},0)`);
|
||||
ctx.beginPath(); ctx.arc(n.x, n.y, glowR, 0, Math.PI*2);
|
||||
ctx.fillStyle = g; ctx.fill();
|
||||
|
||||
// Rotating orbit ring for hub and agents
|
||||
if (n.type === 'hub' || n.agent) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, baseR + 6, _nmT * (n.type==='hub'?0.8:0.5), _nmT * (n.type==='hub'?0.8:0.5) + Math.PI * 1.4);
|
||||
ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},${0.3 + pulse*0.2})`;
|
||||
ctx.lineWidth = 1; ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Node fill
|
||||
const filled = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, baseR);
|
||||
const a = n.online ? pulse * 0.5 : 0.15;
|
||||
filled.addColorStop(0, `rgba(${col.r},${col.g},${col.b},${a})`);
|
||||
filled.addColorStop(1, `rgba(${col.r},${col.g},${col.b},${a*0.3})`);
|
||||
ctx.beginPath(); ctx.arc(n.x, n.y, baseR, 0, Math.PI*2);
|
||||
ctx.fillStyle = filled; ctx.fill();
|
||||
|
||||
// Node border
|
||||
ctx.beginPath(); ctx.arc(n.x, n.y, baseR, 0, Math.PI*2);
|
||||
ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},${n.online ? 0.7+pulse*0.3 : 0.25})`;
|
||||
ctx.lineWidth = n.type === 'hub' ? 2 : 1.2; ctx.stroke();
|
||||
|
||||
// Hub cross-hairs
|
||||
if (n.type === 'hub') {
|
||||
ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},0.25)`;
|
||||
ctx.lineWidth = 0.5;
|
||||
[[n.x-40,n.y,n.x-baseR-4,n.y],[n.x+baseR+4,n.y,n.x+40,n.y],
|
||||
[n.x,n.y-40,n.x,n.y-baseR-4],[n.x,n.y+baseR+4,n.x,n.y+40]].forEach(([x1,y1,x2,y2])=>{
|
||||
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// Label
|
||||
ctx.font = `${n.type==='hub'?'700 ':''} ${n.type==='hub'?9:7.5}px Share Tech Mono,monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = n.online ? `rgba(${col.r},${col.g},${col.b},0.9)` : 'rgba(200,80,80,0.6)';
|
||||
ctx.fillText(n.label, n.x, n.y + baseR + 12);
|
||||
if (n.sub && (n.type==='hub'||isHover)) {
|
||||
ctx.font = '6.5px Share Tech Mono,monospace';
|
||||
ctx.fillStyle = 'rgba(150,200,220,0.55)';
|
||||
ctx.fillText(n.sub, n.x, n.y + baseR + 20);
|
||||
}
|
||||
ctx.textAlign = 'left';
|
||||
});
|
||||
}
|
||||
draw();
|
||||
|
||||
// Mouse hover for node info
|
||||
canvas.onmousemove = function(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
let found = -1;
|
||||
_nmNodes.forEach((n, i) => {
|
||||
if (Math.hypot(n.x - mx, n.y - my) < n.r + 8) found = i;
|
||||
});
|
||||
_nmHoverNode = found >= 0 ? found : null;
|
||||
const info = document.getElementById('nmNodeInfo');
|
||||
if (!info) return;
|
||||
if (found >= 0) {
|
||||
const n = _nmNodes[found];
|
||||
const col = _nodeColor(n);
|
||||
document.getElementById('ni-name').textContent = n.label;
|
||||
document.getElementById('ni-name').style.color = `rgb(${col.r},${col.g},${col.b})`;
|
||||
document.getElementById('ni-ip').textContent = 'IP: ' + (n.sub || '—');
|
||||
document.getElementById('ni-status').textContent = 'STATUS: ' + (n.online ? 'ONLINE' : 'OFFLINE');
|
||||
document.getElementById('ni-type').textContent = 'TYPE: ' + n.type.toUpperCase();
|
||||
info.style.display = 'block';
|
||||
info.style.left = (mx + 14) + 'px';
|
||||
info.style.top = (my - 10) + 'px';
|
||||
} else {
|
||||
info.style.display = 'none';
|
||||
}
|
||||
};
|
||||
canvas.onmouseleave = () => {
|
||||
_nmHoverNode = null;
|
||||
const info = document.getElementById('nmNodeInfo');
|
||||
if (info) info.style.display = 'none';
|
||||
};
|
||||
}
|
||||
|
||||
// ── GLOBALS ──────────────────────────────────────────────────────────
|
||||
let sessionToken = '';
|
||||
let sessionUser = '';
|
||||
@@ -2580,8 +2982,33 @@ async function sendMessage() {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Local panel-toggle voice commands (handled without API call)
|
||||
// Local commands — handled client-side without API round-trip
|
||||
const t = text.toLowerCase();
|
||||
|
||||
// Network map open
|
||||
if (NM_OPEN_RE.test(t)) {
|
||||
input.value = '';
|
||||
addMessage('user', text);
|
||||
addMessage('jarvis', 'Launching network topology display. Stand by.');
|
||||
speak('Launching network topology display.');
|
||||
openNetMap();
|
||||
return;
|
||||
}
|
||||
// Network map close
|
||||
if (NM_CLOSE_RE.test(t)) {
|
||||
input.value = '';
|
||||
addMessage('user', text);
|
||||
const isOpen = document.getElementById('netMapOverlay')?.classList.contains('nm-open');
|
||||
if (isOpen) {
|
||||
closeNetMap();
|
||||
addMessage('jarvis', 'Network topology display closed.');
|
||||
speak('Network topology display closed.');
|
||||
} else {
|
||||
addMessage('jarvis', 'Network map is not currently active.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (/\b(focus\s*mode|hide\s*(panels?|stats?|statistics)|full\s*screen\s*jarvis)\b/.test(t)) {
|
||||
input.value = '';
|
||||
addMessage('user', text);
|
||||
|
||||
Reference in New Issue
Block a user