Extend subnet leader-pipe labels to all map layouts.

Orbit, Galaxy, Lanes, and Helix now place VLAN/CIDR pills on long pipes clear of edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-07-18 22:01:50 +02:00
parent cd347e6e4c
commit 126e34093e
2 changed files with 168 additions and 38 deletions
+160 -30
View File
@@ -9,6 +9,7 @@
layoutMode: "orbit", layoutMode: "orbit",
clusterHubs: [], clusterHubs: [],
laneGuides: [], laneGuides: [],
labelAnchors: [],
subnet: "all", subnet: "all",
model: null, model: null,
groupHint: null, groupHint: null,
@@ -147,6 +148,67 @@
return [...bySubnet.keys()].sort().map((k) => ({ cidr: k, members: bySubnet.get(k) })); 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) { function nodeRadius(n) {
return n.connected ? 8 : 5.5; return n.connected ? 8 : 5.5;
} }
@@ -171,49 +233,75 @@
const groups = groupBySubnet(devices); const groups = groupBySubnet(devices);
const ringBase = Math.min(w, h) * 0.18; const ringBase = Math.min(w, h) * 0.18;
const nodes = []; const nodes = [];
const anchors = [];
const omeHub = { x: cx, y: cy };
groups.forEach((g, si) => { groups.forEach((g, si) => {
const ring = ringBase + si * Math.min(70, Math.max(42, 360 / Math.max(groups.length, 1))); const ring = ringBase + si * Math.min(70, Math.max(42, 360 / Math.max(groups.length, 1)));
const pts = [];
g.members.forEach((n, i) => { g.members.forEach((n, i) => {
const a = (i / Math.max(g.members.length, 1)) * Math.PI * 2 - Math.PI / 2 + si * 0.15; 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 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({ nodes.push({
...n, ...n,
tx: cx + Math.cos(a) * (ring + jitter), tx: x,
ty: cy + Math.sin(a) * (ring + jitter), ty: y,
r: nodeRadius(n), r: nodeRadius(n),
depth: 1, depth: 1,
}); });
}); });
const a = anchorsFromPoints(g.cidr, pts, omeHub, { pipeExtra: 44, pad: 10 });
if (a) anchors.push(a);
}); });
state.clusterHubs = []; state.clusterHubs = [];
state.laneGuides = []; state.laneGuides = [];
state.labelAnchors = anchors;
setTargets(nodes); setTargets(nodes);
} }
function layoutGalaxy(devices, cx, cy, w, h) { function layoutGalaxy(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices); const groups = groupBySubnet(devices);
const nodes = []; const nodes = [];
const anchors = [];
const omeHub = { x: cx, y: cy };
const arms = Math.max(groups.length, 1); const arms = Math.max(groups.length, 1);
const maxR = Math.min(w, h) * 0.42; const maxR = Math.min(w, h) * 0.42;
groups.forEach((g, si) => { groups.forEach((g, si) => {
const armAngle = (si / arms) * Math.PI * 2; const armAngle = (si / arms) * Math.PI * 2;
const pts = [];
g.members.forEach((n, i) => { g.members.forEach((n, i) => {
const t = (i + 1) / (g.members.length + 1); const t = (i + 1) / (g.members.length + 1);
const r = 70 + t * maxR; const r = 70 + t * maxR;
const twist = t * 3.2 + armAngle; const twist = t * 3.2 + armAngle;
const wobble = Math.sin(i * 1.7 + si) * 12; 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({ nodes.push({
...n, ...n,
tx: cx + Math.cos(twist) * r + Math.cos(twist + Math.PI / 2) * wobble * 0.35, tx: x,
ty: cy + Math.sin(twist) * r + Math.sin(twist + Math.PI / 2) * wobble * 0.35, ty: y,
r: nodeRadius(n), r: nodeRadius(n),
depth: 0.6 + t * 0.8, depth: 0.6 + t * 0.8,
arm: si, 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.clusterHubs = [];
state.laneGuides = []; state.laneGuides = [];
state.labelAnchors = anchors;
setTargets(nodes); setTargets(nodes);
} }
@@ -221,26 +309,36 @@
const groups = groupBySubnet(devices); const groups = groupBySubnet(devices);
const nodes = []; const nodes = [];
const hubs = []; const hubs = [];
const anchors = [];
const R = Math.min(w, h) * 0.32; 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) => { groups.forEach((g, si) => {
const a = (si / Math.max(groups.length, 1)) * Math.PI * 2 - Math.PI / 2; const a = (si / Math.max(groups.length, 1)) * Math.PI * 2 - Math.PI / 2;
const hx = cx + Math.cos(a) * R; const hx = cx + Math.cos(a) * R;
const hy = cy + Math.sin(a) * R; const hy = cy + Math.sin(a) * R;
const localR = 28 + Math.min(90, g.members.length * 4.5); const localR = 28 + Math.min(90, g.members.length * 4.5);
const meta = subnetMeta.get(g.cidr) || {}; const info = subnetLabelInfo(g.cidr);
const vlanBit = meta.vlan_id != null ? `VLAN ${meta.vlan_id}` : null;
const label = vlanBit ? `${vlanBit} · ${g.cidr}` : g.cidr;
hubs.push({ hubs.push({
x: hx, x: hx,
y: hy, y: hy,
label, label: info.label,
cidr: g.cidr, cidr: g.cidr,
color: meta.vlan_color || colorFor(state.subnetColors, g.cidr), color: info.color,
localR, localR,
angle: a, angle: a,
memberCount: g.members.length, 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) => { g.members.forEach((n, i) => {
const la = (i / Math.max(g.members.length, 1)) * Math.PI * 2; const la = (i / Math.max(g.members.length, 1)) * Math.PI * 2;
nodes.push({ nodes.push({
@@ -255,6 +353,7 @@
}); });
state.clusterHubs = hubs; state.clusterHubs = hubs;
state.laneGuides = []; state.laneGuides = [];
state.labelAnchors = anchors;
setTargets(nodes); setTargets(nodes);
} }
@@ -262,12 +361,26 @@
const groups = groupBySubnet(devices); const groups = groupBySubnet(devices);
const nodes = []; const nodes = [];
const guides = []; const guides = [];
const anchors = [];
const top = 70; const top = 70;
const bottom = h - 50; const bottom = h - 50;
const span = Math.max(bottom - top, 120); const span = Math.max(bottom - top, 120);
groups.forEach((g, si) => { groups.forEach((g, si) => {
const y = top + (groups.length <= 1 ? span / 2 : (si / (groups.length - 1 || 1)) * span); 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) => { g.members.forEach((n, i) => {
const t = g.members.length <= 1 ? 0.5 : i / (g.members.length - 1); const t = g.members.length <= 1 ? 0.5 : i / (g.members.length - 1);
const x = 90 + t * (w - 160); const x = 90 + t * (w - 160);
@@ -284,6 +397,7 @@
}); });
state.clusterHubs = []; state.clusterHubs = [];
state.laneGuides = guides; state.laneGuides = guides;
state.labelAnchors = anchors;
// hub left side // hub left side
state.hub = { x: 48, y: cy, r: 26 }; state.hub = { x: 48, y: cy, r: 26 };
setTargets(nodes); 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 list = [...devices].sort((a, b) => Number(b.connected) - Number(a.connected) || (b.watts || 0) - (a.watts || 0));
const nodes = []; const nodes = [];
const turns = 2.4; const turns = 2.4;
const byCidr = new Map();
list.forEach((n, i) => { list.forEach((n, i) => {
const t = list.length <= 1 ? 0.5 : i / (list.length - 1); const t = list.length <= 1 ? 0.5 : i / (list.length - 1);
const angle = t * Math.PI * 2 * turns; const angle = t * Math.PI * 2 * turns;
const y = 60 + t * (h - 120); const y = 60 + t * (h - 120);
const amp = Math.min(w, h) * 0.28; 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 depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(angle));
const x = cx + Math.cos(angle) * amp * depth; const x = cx + Math.cos(angle) * amp * depth;
const ty = y + Math.sin(angle * 2) * 8;
nodes.push({ nodes.push({
...n, ...n,
tx: x, tx: x,
ty: y + Math.sin(angle * 2) * 8, ty,
r: (n.connected ? 7 : 4.5) * (0.75 + depth * 0.55), r: (n.connected ? 7 : 4.5) * (0.75 + depth * 0.55),
depth, depth,
helixT: t, 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.clusterHubs = [];
state.laneGuides = []; state.laneGuides = [];
state.labelAnchors = anchors;
setTargets(nodes); setTargets(nodes);
} }
@@ -533,10 +662,6 @@
ctx.arc(dashX, g.y, 2.5, 0, Math.PI * 2); ctx.arc(dashX, g.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = g.color || "#3dffe0"; ctx.fillStyle = g.color || "#3dffe0";
ctx.fill(); 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") { } else if (mode === "helix") {
// depth rails // depth rails
@@ -559,8 +684,10 @@
} }
} }
function drawClusterLabelPipes(omeHub) { function drawSubnetLabelPipes(omeHub) {
const hubs = state.clusterHubs || []; const hubs = (state.labelAnchors && state.labelAnchors.length
? state.labelAnchors
: state.clusterHubs) || [];
if (!hubs.length || !omeHub) return; if (!hubs.length || !omeHub) return;
const th = canvasTheme(); const th = canvasTheme();
const light = isLightTheme(); const light = isLightTheme();
@@ -569,7 +696,13 @@
const label = String(ch.label || ch.cidr || "").trim(); const label = String(ch.label || ch.cidr || "").trim();
if (!label) continue; if (!label) continue;
// Radial outward from OME → cluster, then past the spoke ring ("long pipe") 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 dx = ch.x - omeHub.x;
let dy = ch.y - omeHub.y; let dy = ch.y - omeHub.y;
let len = Math.hypot(dx, dy); let len = Math.hypot(dx, dy);
@@ -578,23 +711,23 @@
dy = -1; dy = -1;
len = 1; len = 1;
} }
const ux = dx / len; ux = dx / len;
const uy = dy / len; uy = dy / len;
}
const ring = ch.localR || 52; const ring = ch.localR || 52;
const pipeStart = 12; const pipeStart = Math.min(12, Math.max(4, ring * 0.25));
const pipeEnd = ring + 36; const pipeEnd = ring + (ch.pipeExtra != null ? ch.pipeExtra : 36);
const sx = ch.x + ux * pipeStart; const sx = ch.x + ux * pipeStart;
const sy = ch.y + uy * pipeStart; const sy = ch.y + uy * pipeStart;
const ex = ch.x + ux * pipeEnd; const ex = ch.x + ux * pipeEnd;
const ey = ch.y + uy * 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 lx = ch.x + ux * (pipeEnd + 14);
const ly = ch.y + uy * (pipeEnd + 14); const ly = ch.y + uy * (pipeEnd + 14);
const color = ch.color || "#00a8e8"; const color = ch.color || "#00a8e8";
const rgb = hexToRgb(color); const rgb = hexToRgb(color);
// Long pipe / leader
ctx.save(); ctx.save();
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`; ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`;
ctx.lineWidth = 1.6; ctx.lineWidth = 1.6;
@@ -603,18 +736,15 @@
ctx.moveTo(sx, sy); ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey); ctx.lineTo(ex, ey);
ctx.stroke(); ctx.stroke();
// tip tick
ctx.beginPath(); ctx.beginPath();
ctx.arc(ex, ey, 2.4, 0, Math.PI * 2); ctx.arc(ex, ey, 2.4, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`; ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`;
ctx.fill(); ctx.fill();
ctx.restore(); ctx.restore();
// Pill label (drawn last so it sits above spokes)
ctx.font = "600 10px IBM Plex Mono, monospace"; ctx.font = "600 10px IBM Plex Mono, monospace";
const tw = ctx.measureText(label).width; const tw = ctx.measureText(label).width;
const padX = 8; const padX = 8;
const padY = 5;
const bw = tw + padX * 2; const bw = tw + padX * 2;
const bh = 18; const bh = 18;
const bx = lx - bw / 2; const bx = lx - bw / 2;
@@ -764,7 +894,7 @@
drawLinks(hub, t); drawLinks(hub, t);
drawHub(hub, t); drawHub(hub, t);
drawNodes(t); drawNodes(t);
if (state.layoutMode === "clusters") drawClusterLabelPipes(hub); drawSubnetLabelPipes(hub);
ctx.restore(); ctx.restore();
+1 -1
View File
@@ -633,7 +633,7 @@
<script src="/vendor/xterm/xterm.min.js?v=home1"></script> <script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script> <script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=clusterpipe1"></script> <script src="/app.js?v=labelpipes2"></script>
<script src="/ops.js?v=opsfde2"></script> <script src="/ops.js?v=opsfde2"></script>
<script src="/ssh.js?v=fabric4"></script> <script src="/ssh.js?v=fabric4"></script>
<script src="/rdp.js?v=fabric4"></script> <script src="/rdp.js?v=fabric4"></script>