From cd347e6e4cabf0031e3b6be280b1f8f0f1a55e89 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 21:57:19 +0200 Subject: [PATCH] Offset cluster subnet labels with outward leader pipes. Keep CIDR/VLAN pills outside the spoke ring so pulse edges no longer cover the IP text. Co-authored-by: Cursor --- ui/app.js | 105 +++++++++++++++++++++++++++++++++++++++++++++++--- ui/index.html | 2 +- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/ui/app.js b/ui/app.js index da6459a..2bea683 100644 --- a/ui/app.js +++ b/ui/app.js @@ -222,12 +222,25 @@ const nodes = []; const hubs = []; const R = Math.min(w, h) * 0.32; + const subnetMeta = new Map((state.data?.subnets || []).map((s) => [s.cidr, s])); 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; - hubs.push({ x: hx, y: hy, label: g.cidr, color: colorFor(state.subnetColors, g.cidr) }); 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; + hubs.push({ + x: hx, + y: hy, + label, + cidr: g.cidr, + color: meta.vlan_color || colorFor(state.subnetColors, g.cidr), + localR, + angle: a, + memberCount: g.members.length, + }); g.members.forEach((n, i) => { const la = (i / Math.max(g.members.length, 1)) * Math.PI * 2; nodes.push({ @@ -492,8 +505,9 @@ } } else if (mode === "clusters") { for (const ch of state.clusterHubs || []) { + const ring = (ch.localR || 52) + 10; ctx.beginPath(); - ctx.arc(ch.x, ch.y, 52, 0, Math.PI * 2); + ctx.arc(ch.x, ch.y, ring, 0, Math.PI * 2); ctx.strokeStyle = (ch.color || "#00a8e8") + "33"; ctx.lineWidth = 1.2; ctx.stroke(); @@ -503,10 +517,6 @@ ctx.globalAlpha = 0.55; ctx.fill(); ctx.globalAlpha = 1; - ctx.fillStyle = canvasTheme().clusterLabel; - ctx.font = "600 9px IBM Plex Mono, monospace"; - ctx.textAlign = "center"; - ctx.fillText((ch.label || "").slice(0, 18), ch.x, ch.y - 18); drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55); } } else if (mode === "lanes") { @@ -549,6 +559,88 @@ } } + function drawClusterLabelPipes(omeHub) { + const hubs = state.clusterHubs || []; + if (!hubs.length || !omeHub) return; + const th = canvasTheme(); + const light = isLightTheme(); + + for (const ch of hubs) { + 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; + } + const ux = dx / len; + const uy = dy / len; + const ring = ch.localR || 52; + const pipeStart = 12; + const pipeEnd = ring + 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; + ctx.setLineDash([]); + ctx.beginPath(); + 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; + const by = ly - bh / 2; + + ctx.beginPath(); + const r = 6; + ctx.moveTo(bx + r, by); + ctx.arcTo(bx + bw, by, bx + bw, by + bh, r); + ctx.arcTo(bx + bw, by + bh, bx, by + bh, r); + ctx.arcTo(bx, by + bh, bx, by, r); + ctx.arcTo(bx, by, bx + bw, by, r); + ctx.closePath(); + ctx.fillStyle = light ? "rgba(255,255,255,0.92)" : "rgba(6,14,24,0.88)"; + ctx.fill(); + ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.75)`; + ctx.lineWidth = 1.2; + ctx.stroke(); + + ctx.fillStyle = th.clusterLabel; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, lx, ly); + } + } + function drawLinks(hub, t) { const mode = state.layoutMode; if (mode === "clusters") { @@ -672,6 +764,7 @@ drawLinks(hub, t); drawHub(hub, t); drawNodes(t); + if (state.layoutMode === "clusters") drawClusterLabelPipes(hub); ctx.restore(); diff --git a/ui/index.html b/ui/index.html index 58bed49..88e9de7 100644 --- a/ui/index.html +++ b/ui/index.html @@ -633,7 +633,7 @@ - +