From 8bdea2ce2c8cf3393aa437fe10c2483670f6356f Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Tue, 2 Jun 2026 00:44:35 +0000 Subject: [PATCH] Redesign network map: 4 clear zones, better spacing, outward labels, section headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quadrant layout: Proxmox (top-left), Services (top-right), Agents (bottom-left), Devices (bottom-right) - Dashed divider lines + subtle per-zone color gradient fill separates sections visually - Zone watermark labels (PROXMOX CLUSTER, SERVICES, AGENTS, NETWORK DEVICES) with online/total count - Nodes arranged in tidy grid within each zone — no more single crowded ring - Labels positioned OUTWARD from hub center (atan2 to calculate angle) so they never overlap nodes - Bezier lines bow outward away from hub center (control point pushed along hub→midpoint vector) so lines spread out and each is individually traceable - IP shown only on hub and hovered nodes — reduces label clutter - Overflow indicator: shows +N MORE when zone has too many nodes (max per zone: 6/6/8/10) - Intra-zone cross-links for Proxmox cluster (green) and Services cluster (gold) - RGB color system replaces old r/g/b object — cleaner rgba() template strings --- public_html/index.html | 515 +++++++++++++++++++++++------------------ 1 file changed, 294 insertions(+), 221 deletions(-) diff --git a/public_html/index.html b/public_html/index.html index e74dd08..8e79bb4 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -1769,288 +1769,361 @@ function closeNetMap() { 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}; + +// Zone definitions — quadrant positions, labels, colors, max visible nodes +const NM_ZONES = { + proxmox: { label:'PROXMOX CLUSTER', qx:-1, qy:-1, rgb:'0,255,136', nodeR:14, maxNodes:6 }, + services: { label:'SERVICES', qx: 1, qy:-1, rgb:'255,215,0', nodeR:13, maxNodes:6 }, + agents: { label:'AGENTS', qx:-1, qy: 1, rgb:'0,212,255', nodeR:11, maxNodes:8 }, + devices: { label:'NETWORK DEVICES', qx: 1, qy: 1, rgb:'0,160,200', nodeR: 8, maxNodes:10 }, +}; + +function _classifyDevice(d) { + const h = (d.name||'').toLowerCase(); + if (d.source !== 'agent') return 'devices'; + if (d.agent_type==='proxmox' || h.includes('pve') || h.includes('proxmox')) return 'proxmox'; + if (d.agent_type==='homeassistant'|| h.includes('homeassist') || h.includes('_ha') || + h.includes('ollama') || h.includes('ai') || h.includes('fusion') || h.includes('pbx') || + h.includes('jellyfin') || h.includes('homebridge')) return 'services'; + return 'agents'; +} + +function _nodeTypeFromZone(d, zone) { + const h = (d.name||'').toLowerCase(); + if (zone === 'proxmox') return 'proxmox'; + if (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'; + if (zone === 'devices') return 'device'; + return 'agent'; } function _buildNetGraph(devices) { - const W = document.getElementById('nmCanvas')?.clientWidth || window.innerWidth; - const H = document.getElementById('nmCanvas')?.clientHeight || (window.innerHeight - 130); - + const canvas = document.getElementById('nmCanvas'); + const W = canvas?.clientWidth || window.innerWidth; + const H = canvas?.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 }); + // Hub + _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, zone:'hub' }); - // Categorise devices - const agents = devices.filter(d => d.source === 'agent'); - const scanned = devices.filter(d => d.source !== 'agent'); + // Bucket devices into zones + const buckets = { proxmox:[], services:[], agents:[], devices:[] }; + devices.forEach(d => { const z = _classifyDevice(d); buckets[z].push(d); }); - // 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 each zone's nodes in their quadrant + Object.entries(NM_ZONES).forEach(([zoneName, zd]) => { + const list = buckets[zoneName] || []; + const visible = list.slice(0, zd.maxNodes); + const overflow = list.length - visible.length; - // 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; + // Zone anchor: pull toward quadrant corner, leaving breathing room from edges and center + const zAnchorX = cx + zd.qx * cx * 0.58; + const zAnchorY = cy + zd.qy * cy * 0.60; - 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 }); + // Arrange nodes in a tidy grid/arc within the zone + const count = visible.length; + visible.forEach((d, i) => { + let nx, ny; + if (count === 1) { + nx = zAnchorX; ny = zAnchorY; + } else if (count <= 3) { + const angle = ((i / count) * Math.PI * 0.7) - Math.PI * 0.35; + const sectorAngle = Math.atan2(zd.qy, zd.qx); + nx = zAnchorX + Math.cos(sectorAngle + angle) * 65; + ny = zAnchorY + Math.sin(sectorAngle + angle) * 65; + } else { + // Grid layout: cols × rows centered on anchor + const cols = Math.ceil(Math.sqrt(count * 1.4)); + const row = Math.floor(i / cols), col = i % cols; + const spacing = Math.min(72, (Math.min(cx, cy) * 0.38) / cols); + nx = zAnchorX + (col - (cols - 1) / 2) * spacing; + ny = zAnchorY + (row - (Math.ceil(count / cols) - 1) / 2) * spacing; } + + // Keep nodes inside canvas with generous margin + nx = Math.max(55, Math.min(W - 55, nx + (Math.random() - 0.5) * 12)); + ny = Math.max(35, Math.min(H - 35, ny + (Math.random() - 0.5) * 12)); + + _nmNodes.push({ + id: d.agent_id || d.ip || (zoneName + i), + label: (d.name || d.ip || '?').replace(/_[a-f0-9]{6,}$/, '').substring(0, 13), + sub: d.ip || '', + type: _nodeTypeFromZone(d, zoneName), + online: !!(d.alive || d.status === 'online'), + agent: d.source === 'agent', + x: nx, y: ny, r: zd.nodeR, + pulse: Math.random() * Math.PI * 2, + zone: zoneName, + rgb: zd.rgb, + }); + }); + + // Overflow indicator node ("+N more") + if (overflow > 0) { + const zAnchorX2 = cx + zd.qx * cx * 0.68; + const zAnchorY2 = cy + zd.qy * cy * 0.73; + _nmNodes.push({ + id: zoneName+'_overflow', label:'+'+overflow+' MORE', sub:'', + type:'overflow', online:false, agent:false, + x: Math.max(40, Math.min(W-40, zAnchorX2)), + y: Math.max(30, Math.min(H-30, zAnchorY2)), + r: 9, pulse: 0, zone: zoneName, rgb: zd.rgb, + }); } }); - // Update stats bar + // Edges: every non-hub node → hub + for (let i = 1; i < _nmNodes.length; i++) { + const n = _nmNodes[i]; + if (n.type === 'overflow') continue; + _nmEdges.push({ from:i, to:0, strength: n.agent ? 1 : 0.45, zoneName: n.zone }); + } + // Cross-link within proxmox cluster + const pveIdx = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.zone==='proxmox'&&n.type!=='overflow'); + for (let a = 0; a < pveIdx.length; a++) + for (let b = a+1; b < pveIdx.length; b++) + _nmEdges.push({ from:pveIdx[a].i, to:pveIdx[b].i, strength:0.55, intra:true }); + // Cross-link within services cluster + const svcIdx = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.zone==='services'&&n.type!=='overflow'); + for (let a = 0; a < svcIdx.length; a++) + for (let b = a+1; b < svcIdx.length; b++) + _nmEdges.push({ from:svcIdx[a].i, to:svcIdx[b].i, strength:0.35, intra:true }); + + // Spawn particles for hub edges + _nmEdges.filter(e => !e.intra).forEach((e, ei) => { + const cnt = e.strength > 0.8 ? 4 : 2; + for (let p = 0; p < cnt; p++) { + _nmParticles.push({ edge:ei, t:Math.random(), dir:'in', speed:0.0025+Math.random()*0.003, r:2+Math.random()*0.8 }); + if (e.strength > 0.6 && Math.random() > 0.5) + _nmParticles.push({ edge:ei, t:Math.random(), dir:'out', speed:0.0016+Math.random()*0.002, r:1.6+Math.random()*0.7 }); + } + }); + + // Stats 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; + const agt = _nmNodes.filter(n=>n.agent).length; + const g = id => document.getElementById(id); + if(g('nm-node-count')) g('nm-node-count').textContent = _nmNodes.length; + if(g('nm-online-count')) g('nm-online-count').textContent = online; + if(g('nm-agent-count')) g('nm-agent-count').textContent = agt; } -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, - }; +// Bezier point — bows OUTWARD from hub center so lines don't bundle in center +function _bezierPt(ax, ay, bx, by, t, hx, hy) { + const mx = (ax+bx)/2, my = (ay+by)/2; + const dx = mx-hx, dy = my-hy; + const dist = Math.hypot(dx,dy)||1; + const bow = 35 + dist * 0.12; // bow scales with distance from center + const cpx = mx + (dx/dist)*bow, cpy = my + (dy/dist)*bow; + const iv = 1-t; + return { x: iv*iv*ax + 2*iv*t*cpx + t*t*bx, y: iv*iv*ay + 2*iv*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); + const W = canvas.width, H = canvas.height; + const hub = _nmNodes[0]; + const hx = hub?.x || W/2, hy = hub?.y || H/2; 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(); } + // ── Fine grid ──────────────────────────────────────────────────── + ctx.strokeStyle = 'rgba(0,180,255,0.035)'; ctx.lineWidth = 0.5; + for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); } + for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); } - // Draw edges + // ── Zone backgrounds + watermark labels ────────────────────────── + Object.entries(NM_ZONES).forEach(([zoneName, zd]) => { + // Quadrant fill + const qx1 = zd.qx < 0 ? 0 : W/2; + const qy1 = zd.qy < 0 ? 0 : H/2; + const qW = W/2, qH = H/2; + const grad = ctx.createRadialGradient( + qx1 + (zd.qx<0?qW:0), qy1 + (zd.qy<0?qH:0), 0, + qx1 + qW/2, qy1 + qH/2, Math.max(qW,qH) + ); + grad.addColorStop(0, `rgba(${zd.rgb},0.04)`); + grad.addColorStop(1, `rgba(${zd.rgb},0)`); + ctx.fillStyle = grad; + ctx.fillRect(qx1, qy1, qW, qH); + + // Section divider lines (thin, along the midpoint cross) + ctx.strokeStyle = 'rgba(0,180,255,0.07)'; ctx.lineWidth = 1; + ctx.setLineDash([4,6]); + ctx.beginPath(); ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke(); + ctx.setLineDash([]); + + // Zone watermark label + const lx = qx1 + qW/2, ly = qy1 + (zd.qy < 0 ? 18 : qH - 12); + ctx.font = '700 11px Share Tech Mono,monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = `rgba(${zd.rgb},0.18)`; + ctx.fillText(zd.label, lx, ly); + + // Online/total count per zone + const zNodes = _nmNodes.filter(n=>n.zone===zoneName&&n.type!=='overflow'); + const zOn = zNodes.filter(n=>n.online).length; + ctx.font = '9px Share Tech Mono,monospace'; + ctx.fillStyle = `rgba(${zd.rgb},0.35)`; + ctx.fillText(`${zOn}/${zNodes.length} ONLINE`, lx, ly + 14); + ctx.textAlign = 'left'; + }); + + // ── 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); - } + if (!a||!b) return; 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(); + ctx.beginPath(); + const steps = 50; + for (let i = 0; i <= steps; i++) { + const p = e.intra + ? { x: a.x+(b.x-a.x)*(i/steps), y: a.y+(b.y-a.y)*(i/steps) } + : _bezierPt(a.x, a.y, b.x, b.y, i/steps, hx, hy); + i===0 ? ctx.moveTo(p.x,p.y) : ctx.lineTo(p.x,p.y); + } + ctx.strokeStyle = alive + ? (e.intra ? 'rgba(255,215,0,0.18)' : 'rgba(0,180,220,0.15)') + : 'rgba(100,20,40,0.1)'; + ctx.lineWidth = e.strength * 1.2; ctx.stroke(); }); - // Draw particles + // ── Particles ──────────────────────────────────────────────────── _nmParticles.forEach(p => { - p.t += p.speed; - if (p.t > 1) p.t -= 1; - const e = _nmEdges[p.edge]; - if (!e) return; + p.t = (p.t + p.speed) % 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 (!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, hx, hy); + const fade = Math.min(t*7, (1-t)*7, 1); + const bright = 0.55 + Math.sin(_nmT*3+p.t*8)*0.3; + ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r, 0, Math.PI*2); 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; + ctx.fillStyle = `rgba(0,212,255,${bright*fade})`; + ctx.shadowColor='rgba(0,212,255,0.7)'; } 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; + ctx.fillStyle = `rgba(255,140,0,${(bright*0.85)*fade})`; + ctx.shadowColor='rgba(255,120,0,0.6)'; } + ctx.shadowBlur=7; ctx.fill(); ctx.shadowBlur=0; }); - // Draw nodes + // ── 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(); - } + if (n.type === 'overflow') { + // Simple dimmed placeholder + ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, Math.PI*2); + ctx.strokeStyle = `rgba(${n.rgb||'0,180,200'},0.3)`; ctx.lineWidth=1; ctx.stroke(); + ctx.font='7px Share Tech Mono,monospace'; ctx.textAlign='center'; + ctx.fillStyle=`rgba(${n.rgb||'0,180,200'},0.5)`; + ctx.fillText(n.label, n.x, n.y+3); ctx.textAlign='left'; + return; } - // 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(); + const rgb = n.rgb || _nodeRgb(n); + const pulse = 0.5 + Math.sin(_nmT*1.5+n.pulse)*0.3; + const isHub = n.type==='hub'; + const isHov = _nmHoverNode===ni; + const baseR = n.r + (isHov?5:0); - // 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(); + // Outer glow + if (n.online) { + const gr = baseR + 12 + Math.sin(_nmT+n.pulse)*3; + const g = ctx.createRadialGradient(n.x,n.y,baseR*0.4,n.x,n.y,gr); + g.addColorStop(0,`rgba(${rgb},${pulse*0.28})`); g.addColorStop(1,`rgba(${rgb},0)`); + ctx.beginPath(); ctx.arc(n.x,n.y,gr,0,Math.PI*2); ctx.fillStyle=g; ctx.fill(); + } + + // Rotating arc for agents and hub + if (n.online && (isHub||n.agent)) { + ctx.beginPath(); + const arcStart = _nmT*(isHub?0.7:0.45); + ctx.arc(n.x,n.y,baseR+5,arcStart,arcStart+Math.PI*1.3); + ctx.strokeStyle=`rgba(${rgb},${0.28+pulse*0.18})`; ctx.lineWidth=isHub?1.5:1; ctx.stroke(); + } + + // Fill + border + const fg = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,baseR); + const fa = n.online ? pulse*0.45 : 0.12; + fg.addColorStop(0,`rgba(${rgb},${fa})`); fg.addColorStop(1,`rgba(${rgb},${fa*0.25})`); + ctx.beginPath(); ctx.arc(n.x,n.y,baseR,0,Math.PI*2); + ctx.fillStyle=fg; ctx.fill(); + ctx.strokeStyle=`rgba(${rgb},${n.online?0.65+pulse*0.3:0.22})`; ctx.lineWidth=isHub?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(); - }); + if (isHub) { + ctx.strokeStyle=`rgba(${rgb},0.22)`; ctx.lineWidth=0.6; + [[n.x-48,n.y,n.x-baseR-4,n.y],[n.x+baseR+4,n.y,n.x+48,n.y], + [n.x,n.y-48,n.x,n.y-baseR-4],[n.x,n.y+baseR+4,n.x,n.y+48]].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); + // Label: position OUTWARD from hub center so it never overlaps the node + const labelAngle = isHub ? -Math.PI/2 : Math.atan2(n.y-hy, n.x-hx); + const labelDist = baseR + (isHub?14:11); + const lx = n.x + Math.cos(labelAngle)*labelDist; + const ly = n.y + Math.sin(labelAngle)*labelDist + 3; + const cosA = Math.cos(labelAngle); + ctx.font = `${isHub?'700 10':'8'}px Share Tech Mono,monospace`; + ctx.textAlign = isHub ? 'center' : (cosA > 0.25 ? 'left' : cosA < -0.25 ? 'right' : 'center'); + ctx.fillStyle = n.online ? `rgba(${rgb},0.95)` : 'rgba(200,80,80,0.65)'; + ctx.fillText(n.label, lx, ly); + + // Sub (IP) only on hub or hover + if (n.sub && (isHub||isHov)) { + ctx.font='6.5px Share Tech Mono,monospace'; + ctx.fillStyle='rgba(150,200,220,0.5)'; + ctx.fillText(n.sub, lx, ly+(isHub?11:10)); } - ctx.textAlign = 'left'; + ctx.textAlign='left'; }); } draw(); - // Mouse hover for node info + // Hover detection 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'; + const r = canvas.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top; + let found=-1; + _nmNodes.forEach((n,i)=>{ if(n.type!=='overflow'&&Math.hypot(n.x-mx,n.y-my)=0?found:null; + const info=document.getElementById('nmNodeInfo'); + if(!info) return; + if(found>=0){ + const n=_nmNodes[found], rgb=n.rgb||_nodeRgb(n); + document.getElementById('ni-name').textContent=n.label; + document.getElementById('ni-name').style.color=`rgb(${rgb})`; + 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+16)+'px'; info.style.top=(my-8)+'px'; + } else { info.style.display='none'; } }; + canvas.onmouseleave=()=>{ _nmHoverNode=null; const i=document.getElementById('nmNodeInfo'); if(i) i.style.display='none'; }; +} + +// Fallback RGB if node has no zone-assigned rgb +function _nodeRgb(n) { + if (n.type==='hub') return '0,212,255'; + if (n.type==='proxmox') return '0,255,136'; + if (n.type==='ha'||n.type==='ai') return '255,215,0'; + if (n.type==='pbx') return '255,140,0'; + if (!n.online) return '255,50,80'; + return '0,180,220'; } // ── GLOBALS ──────────────────────────────────────────────────────────