From 795707e47bcfe7bb3ae02d2a401077787745fded Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 22:14:41 +0200 Subject: [PATCH] Replace Constellation with Radar sector sweep layout. VLAN wedges on range rings with a rotating sweep beam, distinct from subnet islands. Co-authored-by: Cursor --- ui/app.js | 225 ++++++++++++++++++++++++-------------------------- ui/index.html | 6 +- 2 files changed, 113 insertions(+), 118 deletions(-) diff --git a/ui/app.js b/ui/app.js index f41c17c..553483c 100644 --- a/ui/app.js +++ b/ui/app.js @@ -11,7 +11,8 @@ laneGuides: [], labelAnchors: [], bloomPetals: [], - constellationCharts: [], + radarSectors: [], + radarMaxR: 0, subnet: "all", model: null, groupHint: null, @@ -253,7 +254,8 @@ state.laneGuides = []; state.labelAnchors = []; state.bloomPetals = []; - state.constellationCharts = []; + state.radarSectors = []; + state.radarMaxR = 0; setTargets(nodes); } @@ -285,7 +287,8 @@ state.laneGuides = []; state.labelAnchors = []; state.bloomPetals = []; - state.constellationCharts = []; + state.radarSectors = []; + state.radarMaxR = 0; setTargets(nodes); } @@ -339,100 +342,83 @@ state.laneGuides = []; state.labelAnchors = anchors; state.bloomPetals = []; - state.constellationCharts = []; + state.radarSectors = []; + state.radarMaxR = 0; setTargets(nodes); } - function layoutConstellation(devices, cx, cy, w, h) { + function layoutRadar(devices, cx, cy, w, h) { const groups = groupBySubnet(devices); const nodes = []; const anchors = []; - const charts = []; - const orbitR = Math.min(w, h) * 0.3; - const omeHub = { x: cx, y: cy }; + const sectors = []; + const maxR = Math.min(w, h) * 0.42; const nGroups = Math.max(groups.length, 1); - const golden = Math.PI * (3 - Math.sqrt(5)); + const gap = 0.045; + const usable = Math.PI * 2 - gap * nGroups; + const slice = usable / nGroups; + let cursor = -Math.PI / 2; groups.forEach((g, si) => { - const a = (si / nGroups) * Math.PI * 2 - Math.PI / 2; - const hx = cx + Math.cos(a) * orbitR; - const hy = cy + Math.sin(a) * orbitR; + const a0 = cursor + gap / 2; + const a1 = a0 + slice; + const mid = (a0 + a1) / 2; + cursor = a1 + gap / 2; const info = subnetLabelInfo(g.cidr); - const localR = 22 + Math.min(72, Math.sqrt(Math.max(g.members.length, 1)) * 15); - const pts = []; - g.members.forEach((n, i) => { - const r = localR * Math.sqrt((i + 0.55) / Math.max(g.members.length, 1)); - const ang = i * golden + si * 0.35; - const jx = Math.sin((n.id || i) * 0.17) * 2.8; - const jy = Math.cos((n.id || i) * 0.13) * 2.8; - const x = hx + Math.cos(ang) * r + jx; - const y = hy + Math.sin(ang) * r + jy; - pts.push({ x, y, id: n.id }); - nodes.push({ - ...n, - tx: x, - ty: y, - r: nodeRadius(n), - depth: 1, - constellation: si, - }); - }); - - // Constellation lines: each star to its 2 nearest neighbors - const edgeSet = new Set(); - const edges = []; - for (let i = 0; i < pts.length; i++) { - const ranked = pts - .map((p, j) => ({ - j, - d: i === j ? Infinity : (p.x - pts[i].x) ** 2 + (p.y - pts[i].y) ** 2, - })) - .sort((aa, bb) => aa.d - bb.d); - for (const nb of ranked.slice(0, Math.min(2, ranked.length))) { - if (!Number.isFinite(nb.d)) continue; - const lo = Math.min(i, nb.j); - const hi = Math.max(i, nb.j); - const key = lo + ":" + hi; - if (edgeSet.has(key)) continue; - edgeSet.add(key); - edges.push({ - x1: pts[lo].x, - y1: pts[lo].y, - x2: pts[hi].x, - y2: pts[hi].y, - }); - } - } - - charts.push({ - x: hx, - y: hy, - localR, + sectors.push({ + a0, + a1, + mid, color: info.color, cidr: g.cidr, label: info.label, - edges, count: g.members.length, }); + + const labelR = maxR + 6; anchors.push( makeLabelAnchor({ - x: hx, - y: hy, + x: cx + Math.cos(mid) * labelR, + y: cy + Math.sin(mid) * labelR, cidr: g.cidr, - localR, - dirX: hx - omeHub.x, - dirY: hy - omeHub.y, - pipeExtra: 34, + localR: 4, + dirX: Math.cos(mid), + dirY: Math.sin(mid), + pipeExtra: 26, }) ); + + const sorted = [...g.members].sort( + (a, b) => Number(b.connected) - Number(a.connected) || (b.watts || 0) - (a.watts || 0) + ); + const ringCount = Math.max(3, Math.min(7, Math.ceil(Math.sqrt(Math.max(sorted.length, 1))))); + sorted.forEach((n, i) => { + const ring = 1 + (i % ringCount); + const slot = Math.floor(i / ringCount); + const slotsOnRing = Math.max(1, Math.ceil(sorted.length / ringCount)); + const t = slotsOnRing <= 1 ? 0.5 : (slot + 0.5) / slotsOnRing; + // keep a small inset from sector edges + const ang = a0 + 0.08 * (a1 - a0) + t * 0.84 * (a1 - a0); + const r = maxR * (0.22 + (ring / (ringCount + 0.35)) * 0.72); + nodes.push({ + ...n, + tx: cx + Math.cos(ang) * r, + ty: cy + Math.sin(ang) * r, + r: nodeRadius(n), + depth: 0.45 + ring * 0.12, + radar: si, + radarRing: ring, + }); + }); }); state.clusterHubs = []; state.laneGuides = []; state.labelAnchors = anchors; state.bloomPetals = []; - state.constellationCharts = charts; + state.radarSectors = sectors; + state.radarMaxR = maxR; setTargets(nodes); } @@ -478,7 +464,8 @@ state.laneGuides = guides; state.labelAnchors = anchors; state.bloomPetals = []; - state.constellationCharts = []; + state.radarSectors = []; + state.radarMaxR = 0; // hub left side state.hub = { x: 48, y: cy, r: 26 }; setTargets(nodes); @@ -523,7 +510,8 @@ state.laneGuides = []; state.labelAnchors = anchors; state.bloomPetals = []; - state.constellationCharts = []; + state.radarSectors = []; + state.radarMaxR = 0; setTargets(nodes); } @@ -543,8 +531,8 @@ case "clusters": layoutClusters(devices, cx, cy, w, h); break; - case "constellation": - layoutConstellation(devices, cx, cy, w, h); + case "radar": + layoutRadar(devices, cx, cy, w, h); break; case "lanes": layoutLanes(devices, cx, cy, w, h); @@ -734,52 +722,62 @@ ctx.globalAlpha = 1; drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55); } - } else if (mode === "constellation") { - // Celestial guide rings + } else if (mode === "radar") { + const maxR = state.radarMaxR || Math.min(w, h) * 0.42; + // Range rings for (let i = 1; i <= 4; i++) { + const rr = maxR * (i / 4); ctx.beginPath(); - ctx.arc(hub.x, hub.y, 55 * i * 0.55, 0, Math.PI * 2); + ctx.arc(hub.x, hub.y, rr, 0, Math.PI * 2); ctx.strokeStyle = canvasTheme().decor(i); ctx.lineWidth = 1; - ctx.setLineDash(i % 2 ? [2, 6] : []); + ctx.setLineDash(i === 4 ? [] : [3, 5]); ctx.stroke(); ctx.setLineDash([]); } - // Radial tick marks - for (let i = 0; i < 12; i++) { - const ang = (i / 12) * Math.PI * 2; + // Sector wedges + outer arcs + for (const s of state.radarSectors || []) { + const rgb = hexToRgb(s.color || "#00a8e8"); ctx.beginPath(); - ctx.moveTo(hub.x + Math.cos(ang) * 36, hub.y + Math.sin(ang) * 36); - ctx.lineTo(hub.x + Math.cos(ang) * 48, hub.y + Math.sin(ang) * 48); - ctx.strokeStyle = canvasTheme().decor(2); - ctx.lineWidth = 1; - ctx.stroke(); - } - for (const ch of state.constellationCharts || []) { - const rgb = hexToRgb(ch.color || "#00a8e8"); - // Soft halo around constellation - ctx.beginPath(); - ctx.arc(ch.x, ch.y, (ch.localR || 40) + 14, 0, Math.PI * 2); - ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.2)`; - ctx.lineWidth = 1; - ctx.stroke(); - // Star-chart edges - for (const e of ch.edges || []) { + ctx.moveTo(hub.x, hub.y); + ctx.arc(hub.x, hub.y, maxR, s.a0, s.a1); + ctx.closePath(); + ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.045)`; + ctx.fill(); + // divider rays + for (const ang of [s.a0, s.a1]) { ctx.beginPath(); - ctx.moveTo(e.x1, e.y1); - ctx.lineTo(e.x2, e.y2); - ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.38)`; + ctx.moveTo(hub.x, hub.y); + ctx.lineTo(hub.x + Math.cos(ang) * maxR, hub.y + Math.sin(ang) * maxR); + ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.22)`; ctx.lineWidth = 1; ctx.stroke(); } - // Dim center marker + // outer rim arc ctx.beginPath(); - ctx.arc(ch.x, ch.y, 3, 0, Math.PI * 2); - ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`; - ctx.fill(); - drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 19, 0.4); + ctx.arc(hub.x, hub.y, maxR, s.a0, s.a1); + ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`; + ctx.lineWidth = 2; + ctx.stroke(); } - } else if (mode === "lanes") { + // Rotating sweep beam + const sweep = (t * 0.018) % (Math.PI * 2); + const grad = ctx.createRadialGradient(hub.x, hub.y, 8, hub.x, hub.y, maxR); + grad.addColorStop(0, "rgba(61,255,224,0.18)"); + grad.addColorStop(1, "rgba(61,255,224,0)"); + ctx.beginPath(); + ctx.moveTo(hub.x, hub.y); + ctx.arc(hub.x, hub.y, maxR, sweep - 0.22, sweep); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(hub.x, hub.y); + ctx.lineTo(hub.x + Math.cos(sweep) * maxR, hub.y + Math.sin(sweep) * maxR); + ctx.strokeStyle = "rgba(61,255,224,0.55)"; + ctx.lineWidth = 1.5; + ctx.stroke(); + } else if (mode === "lanes") { } else if (mode === "lanes") { for (const g of state.laneGuides || []) { ctx.beginPath(); ctx.moveTo(70, g.y); @@ -922,15 +920,10 @@ } return; } - if (mode === "constellation") { - // Soft hub link to each star; constellation edges already in decor + if (mode === "radar") { + // Radial blips to OME — classic radar spokes for (const n of state.nodes) { - const ch = (state.constellationCharts || [])[n.constellation]; - if (ch) { - drawPulseLink(ch.x, ch.y, n.x, n.y, n.connected, t, n.id, 0.55); - } else { - drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 0.7); - } + drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, n.connected ? 0.85 : 0.35); } return; } @@ -1696,8 +1689,10 @@ } if (state.hub) expand(state.hub.x, state.hub.y, (state.hub.r || 28) + 16); for (const ch of state.clusterHubs || []) expand(ch.x, ch.y, 56); - for (const c of state.constellationCharts || []) expand(c.x, c.y, (c.localR || 40) + 24); for (const a of state.labelAnchors || []) expand(a.x, a.y, 48); + if (state.layoutMode === "radar" && state.radarMaxR) { + expand(state.hub?.x || 0, state.hub?.y || 0, state.radarMaxR + 56); + } const bw = Math.max(80, maxX - minX); const bh = Math.max(80, maxY - minY); diff --git a/ui/index.html b/ui/index.html index c8dc70d..23df042 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,7 +7,7 @@ - + - +