diff --git a/ui/app.js b/ui/app.js index 2bea683..782ff43 100644 --- a/ui/app.js +++ b/ui/app.js @@ -9,6 +9,7 @@ layoutMode: "orbit", clusterHubs: [], laneGuides: [], + labelAnchors: [], subnet: "all", model: null, groupHint: null, @@ -147,6 +148,67 @@ return [...bySubnet.keys()].sort().map((k) => ({ cidr: k, members: bySubnet.get(k) })); } + function subnetLabelInfo(cidr) { + const meta = (state.data?.subnets || []).find((s) => s.cidr === cidr) || {}; + const vlanBit = meta.vlan_id != null ? `VLAN ${meta.vlan_id}` : null; + return { + cidr, + label: vlanBit ? `${vlanBit} · ${cidr}` : cidr, + color: meta.vlan_color || colorFor(state.subnetColors, cidr || "?"), + }; + } + + function makeLabelAnchor({ x, y, cidr, localR = 28, dirX = null, dirY = null, pipeExtra = 36 }) { + const info = subnetLabelInfo(cidr); + return { + x, + y, + cidr: info.cidr, + label: info.label, + color: info.color, + localR, + pipeExtra, + dirX, + dirY, + }; + } + + function anchorsFromPoints(cidr, points, omeHub, opts = {}) { + if (!points.length) return null; + let sx = 0; + let sy = 0; + for (const p of points) { + sx += p.x; + sy += p.y; + } + const ax = sx / points.length; + const ay = sy / points.length; + let localR = 16; + for (const p of points) { + localR = Math.max(localR, Math.hypot(p.x - ax, p.y - ay)); + } + let dirX = opts.dirX; + let dirY = opts.dirY; + if (dirX == null || dirY == null) { + if (omeHub) { + dirX = ax - omeHub.x; + dirY = ay - omeHub.y; + } else { + dirX = 0; + dirY = -1; + } + } + return makeLabelAnchor({ + x: ax, + y: ay, + cidr, + localR: localR + (opts.pad || 8), + dirX, + dirY, + pipeExtra: opts.pipeExtra || 40, + }); + } + function nodeRadius(n) { return n.connected ? 8 : 5.5; } @@ -171,49 +233,75 @@ const groups = groupBySubnet(devices); const ringBase = Math.min(w, h) * 0.18; const nodes = []; + const anchors = []; + const omeHub = { x: cx, y: cy }; groups.forEach((g, si) => { const ring = ringBase + si * Math.min(70, Math.max(42, 360 / Math.max(groups.length, 1))); + const pts = []; g.members.forEach((n, i) => { const a = (i / Math.max(g.members.length, 1)) * Math.PI * 2 - Math.PI / 2 + si * 0.15; const jitter = (n.id % 7) * 2.2; + const x = cx + Math.cos(a) * (ring + jitter); + const y = cy + Math.sin(a) * (ring + jitter); + pts.push({ x, y }); nodes.push({ ...n, - tx: cx + Math.cos(a) * (ring + jitter), - ty: cy + Math.sin(a) * (ring + jitter), + tx: x, + ty: y, r: nodeRadius(n), depth: 1, }); }); + const a = anchorsFromPoints(g.cidr, pts, omeHub, { pipeExtra: 44, pad: 10 }); + if (a) anchors.push(a); }); state.clusterHubs = []; state.laneGuides = []; + state.labelAnchors = anchors; setTargets(nodes); } function layoutGalaxy(devices, cx, cy, w, h) { const groups = groupBySubnet(devices); const nodes = []; + const anchors = []; + const omeHub = { x: cx, y: cy }; const arms = Math.max(groups.length, 1); const maxR = Math.min(w, h) * 0.42; groups.forEach((g, si) => { const armAngle = (si / arms) * Math.PI * 2; + const pts = []; g.members.forEach((n, i) => { const t = (i + 1) / (g.members.length + 1); const r = 70 + t * maxR; const twist = t * 3.2 + armAngle; const wobble = Math.sin(i * 1.7 + si) * 12; + const x = cx + Math.cos(twist) * r + Math.cos(twist + Math.PI / 2) * wobble * 0.35; + const y = cy + Math.sin(twist) * r + Math.sin(twist + Math.PI / 2) * wobble * 0.35; + pts.push({ x, y }); nodes.push({ ...n, - tx: cx + Math.cos(twist) * r + Math.cos(twist + Math.PI / 2) * wobble * 0.35, - ty: cy + Math.sin(twist) * r + Math.sin(twist + Math.PI / 2) * wobble * 0.35, + tx: x, + ty: y, r: nodeRadius(n), depth: 0.6 + t * 0.8, arm: si, }); }); + // Prefer arm outward direction + const midT = 0.65; + const midTwist = midT * 3.2 + armAngle; + const a = anchorsFromPoints(g.cidr, pts, omeHub, { + dirX: Math.cos(midTwist), + dirY: Math.sin(midTwist), + pipeExtra: 48, + pad: 12, + }); + if (a) anchors.push(a); }); state.clusterHubs = []; state.laneGuides = []; + state.labelAnchors = anchors; setTargets(nodes); } @@ -221,26 +309,36 @@ const groups = groupBySubnet(devices); const nodes = []; const hubs = []; + const anchors = []; const R = Math.min(w, h) * 0.32; - const subnetMeta = new Map((state.data?.subnets || []).map((s) => [s.cidr, s])); + const omeHub = { x: cx, y: cy }; groups.forEach((g, si) => { const a = (si / Math.max(groups.length, 1)) * Math.PI * 2 - Math.PI / 2; const hx = cx + Math.cos(a) * R; const hy = cy + Math.sin(a) * R; const localR = 28 + Math.min(90, g.members.length * 4.5); - const meta = subnetMeta.get(g.cidr) || {}; - const vlanBit = meta.vlan_id != null ? `VLAN ${meta.vlan_id}` : null; - const label = vlanBit ? `${vlanBit} · ${g.cidr}` : g.cidr; + const info = subnetLabelInfo(g.cidr); hubs.push({ x: hx, y: hy, - label, + label: info.label, cidr: g.cidr, - color: meta.vlan_color || colorFor(state.subnetColors, g.cidr), + color: info.color, localR, angle: a, memberCount: g.members.length, }); + anchors.push( + makeLabelAnchor({ + x: hx, + y: hy, + cidr: g.cidr, + localR, + dirX: hx - omeHub.x, + dirY: hy - omeHub.y, + pipeExtra: 36, + }) + ); g.members.forEach((n, i) => { const la = (i / Math.max(g.members.length, 1)) * Math.PI * 2; nodes.push({ @@ -255,6 +353,7 @@ }); state.clusterHubs = hubs; state.laneGuides = []; + state.labelAnchors = anchors; setTargets(nodes); } @@ -262,12 +361,26 @@ const groups = groupBySubnet(devices); const nodes = []; const guides = []; + const anchors = []; const top = 70; const bottom = h - 50; const span = Math.max(bottom - top, 120); groups.forEach((g, si) => { const y = top + (groups.length <= 1 ? span / 2 : (si / (groups.length - 1 || 1)) * span); - guides.push({ y, label: g.cidr, color: colorFor(state.subnetColors, g.cidr) }); + const info = subnetLabelInfo(g.cidr); + guides.push({ y, label: info.label, color: info.color, cidr: g.cidr }); + // Pipe goes up from the left end of the lane — clear of the horizontal edge + anchors.push( + makeLabelAnchor({ + x: 96, + y, + cidr: g.cidr, + localR: 6, + dirX: 0, + dirY: -1, + pipeExtra: 22, + }) + ); g.members.forEach((n, i) => { const t = g.members.length <= 1 ? 0.5 : i / (g.members.length - 1); const x = 90 + t * (w - 160); @@ -284,6 +397,7 @@ }); state.clusterHubs = []; state.laneGuides = guides; + state.labelAnchors = anchors; // hub left side state.hub = { x: 48, y: cy, r: 26 }; setTargets(nodes); @@ -293,25 +407,40 @@ const list = [...devices].sort((a, b) => Number(b.connected) - Number(a.connected) || (b.watts || 0) - (a.watts || 0)); const nodes = []; const turns = 2.4; + const byCidr = new Map(); list.forEach((n, i) => { const t = list.length <= 1 ? 0.5 : i / (list.length - 1); const angle = t * Math.PI * 2 * turns; const y = 60 + t * (h - 120); const amp = Math.min(w, h) * 0.28; - // perspective: scale by "depth" const depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(angle)); const x = cx + Math.cos(angle) * amp * depth; + const ty = y + Math.sin(angle * 2) * 8; nodes.push({ ...n, tx: x, - ty: y + Math.sin(angle * 2) * 8, + ty, r: (n.connected ? 7 : 4.5) * (0.75 + depth * 0.55), depth, helixT: t, }); + const cidr = n.subnet || "unknown"; + if (!byCidr.has(cidr)) byCidr.set(cidr, []); + byCidr.get(cidr).push({ x, y: ty }); }); + const anchors = []; + for (const [cidr, pts] of byCidr.entries()) { + const a = anchorsFromPoints(cidr, pts, { x: cx, y: cy }, { + dirX: 1, + dirY: 0, + pipeExtra: 52, + pad: 10, + }); + if (a) anchors.push(a); + } state.clusterHubs = []; state.laneGuides = []; + state.labelAnchors = anchors; setTargets(nodes); } @@ -533,10 +662,6 @@ ctx.arc(dashX, g.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = g.color || "#3dffe0"; ctx.fill(); - ctx.fillStyle = canvasTheme().laneLabel; - ctx.font = "600 9px IBM Plex Mono, monospace"; - ctx.textAlign = "left"; - ctx.fillText((g.label || "").slice(0, 16), 74, g.y - 8); } } else if (mode === "helix") { // depth rails @@ -559,8 +684,10 @@ } } - function drawClusterLabelPipes(omeHub) { - const hubs = state.clusterHubs || []; + function drawSubnetLabelPipes(omeHub) { + const hubs = (state.labelAnchors && state.labelAnchors.length + ? state.labelAnchors + : state.clusterHubs) || []; if (!hubs.length || !omeHub) return; const th = canvasTheme(); const light = isLightTheme(); @@ -569,32 +696,38 @@ const label = String(ch.label || ch.cidr || "").trim(); if (!label) continue; - // Radial outward from OME → cluster, then past the spoke ring ("long pipe") - let dx = ch.x - omeHub.x; - let dy = ch.y - omeHub.y; - let len = Math.hypot(dx, dy); - if (len < 1) { - dx = 0; - dy = -1; - len = 1; + let ux; + let uy; + if (ch.dirX != null && ch.dirY != null && (ch.dirX !== 0 || ch.dirY !== 0)) { + const len = Math.hypot(ch.dirX, ch.dirY) || 1; + ux = ch.dirX / len; + uy = ch.dirY / len; + } else { + let dx = ch.x - omeHub.x; + let dy = ch.y - omeHub.y; + let len = Math.hypot(dx, dy); + if (len < 1) { + dx = 0; + dy = -1; + len = 1; + } + ux = dx / len; + uy = dy / len; } - const ux = dx / len; - const uy = dy / len; + const ring = ch.localR || 52; - const pipeStart = 12; - const pipeEnd = ring + 36; + const pipeStart = Math.min(12, Math.max(4, ring * 0.25)); + const pipeEnd = ring + (ch.pipeExtra != null ? ch.pipeExtra : 36); const sx = ch.x + ux * pipeStart; const sy = ch.y + uy * pipeStart; const ex = ch.x + ux * pipeEnd; const ey = ch.y + uy * pipeEnd; - // Label sits a bit further out past the pipe tip const lx = ch.x + ux * (pipeEnd + 14); const ly = ch.y + uy * (pipeEnd + 14); const color = ch.color || "#00a8e8"; const rgb = hexToRgb(color); - // Long pipe / leader ctx.save(); ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`; ctx.lineWidth = 1.6; @@ -603,18 +736,15 @@ ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); - // tip tick ctx.beginPath(); ctx.arc(ex, ey, 2.4, 0, Math.PI * 2); ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`; ctx.fill(); ctx.restore(); - // Pill label (drawn last so it sits above spokes) ctx.font = "600 10px IBM Plex Mono, monospace"; const tw = ctx.measureText(label).width; const padX = 8; - const padY = 5; const bw = tw + padX * 2; const bh = 18; const bx = lx - bw / 2; @@ -764,7 +894,7 @@ drawLinks(hub, t); drawHub(hub, t); drawNodes(t); - if (state.layoutMode === "clusters") drawClusterLabelPipes(hub); + drawSubnetLabelPipes(hub); ctx.restore(); diff --git a/ui/index.html b/ui/index.html index 88e9de7..4a78839 100644 --- a/ui/index.html +++ b/ui/index.html @@ -633,7 +633,7 @@ - +