diff --git a/ui/app.js b/ui/app.js
index cd301bf..7a69138 100644
--- a/ui/app.js
+++ b/ui/app.js
@@ -10,6 +10,7 @@
clusterHubs: [],
laneGuides: [],
labelAnchors: [],
+ bloomPetals: [],
subnet: "all",
model: null,
groupHint: null,
@@ -250,6 +251,7 @@
state.clusterHubs = [];
state.laneGuides = [];
state.labelAnchors = [];
+ state.bloomPetals = [];
setTargets(nodes);
}
@@ -280,6 +282,7 @@
state.clusterHubs = [];
state.laneGuides = [];
state.labelAnchors = [];
+ state.bloomPetals = [];
setTargets(nodes);
}
@@ -332,6 +335,79 @@
state.clusterHubs = hubs;
state.laneGuides = [];
state.labelAnchors = anchors;
+ state.bloomPetals = [];
+ setTargets(nodes);
+ }
+
+ function layoutBloom(devices, cx, cy, w, h) {
+ const groups = groupBySubnet(devices);
+ const nodes = [];
+ const anchors = [];
+ const petals = [];
+ const R = Math.min(w, h) * 0.38;
+ const omeHub = { x: cx, y: cy };
+ const nGroups = Math.max(groups.length, 1);
+
+ groups.forEach((g, si) => {
+ const a = (si / nGroups) * Math.PI * 2 - Math.PI / 2;
+ const info = subnetLabelInfo(g.cidr);
+ const tipX = cx + Math.cos(a) * R;
+ const tipY = cy + Math.sin(a) * R;
+ const midX = cx + Math.cos(a) * R * 0.52;
+ const midY = cy + Math.sin(a) * R * 0.52;
+ const petalWidth = 22 + Math.min(48, g.members.length * 2.8);
+
+ petals.push({
+ angle: a,
+ tipX,
+ tipY,
+ midX,
+ midY,
+ width: petalWidth,
+ color: info.color,
+ cidr: g.cidr,
+ label: info.label,
+ count: g.members.length,
+ });
+
+ anchors.push(
+ makeLabelAnchor({
+ x: tipX,
+ y: tipY,
+ cidr: g.cidr,
+ localR: 10,
+ dirX: Math.cos(a),
+ dirY: Math.sin(a),
+ pipeExtra: 28,
+ })
+ );
+
+ g.members.forEach((n, i) => {
+ const t = g.members.length <= 1 ? 0.55 : 0.18 + (i / Math.max(g.members.length - 1, 1)) * 0.72;
+ const alongX = cx + Math.cos(a) * R * t;
+ const alongY = cy + Math.sin(a) * R * t;
+ // Petal bulge: widest mid-petal
+ const bulge = Math.sin(Math.min(1, Math.max(0, (t - 0.12) / 0.76)) * Math.PI);
+ const lane = (i % 2 === 0 ? 1 : -1) * (0.4 + ((i * 7) % 5) * 0.11);
+ const px = -Math.sin(a);
+ const py = Math.cos(a);
+ const spread = bulge * petalWidth * lane;
+ nodes.push({
+ ...n,
+ tx: alongX + px * spread,
+ ty: alongY + py * spread,
+ r: nodeRadius(n),
+ depth: 0.65 + t * 0.55,
+ bloom: si,
+ bloomT: t,
+ });
+ });
+ });
+
+ state.clusterHubs = [];
+ state.laneGuides = [];
+ state.labelAnchors = anchors;
+ state.bloomPetals = petals;
setTargets(nodes);
}
@@ -438,6 +514,9 @@
case "clusters":
layoutClusters(devices, cx, cy, w, h);
break;
+ case "bloom":
+ layoutBloom(devices, cx, cy, w, h);
+ break;
case "lanes":
layoutLanes(devices, cx, cy, w, h);
break;
@@ -626,6 +705,60 @@
ctx.globalAlpha = 1;
drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55);
}
+ } else if (mode === "bloom") {
+ // Soft petal outlines + spine pulse
+ for (const p of state.bloomPetals || []) {
+ const rgb = hexToRgb(p.color || "#00a8e8");
+ const a = p.angle || 0;
+ const nx = -Math.sin(a);
+ const ny = Math.cos(a);
+ // Petal path: hub → left bulge → tip → right bulge → hub
+ const c1x = hub.x + Math.cos(a) * (Math.hypot(p.midX - hub.x, p.midY - hub.y) || 80);
+ const c1y = hub.y + Math.sin(a) * (Math.hypot(p.midX - hub.x, p.midY - hub.y) || 80);
+ ctx.beginPath();
+ ctx.moveTo(hub.x, hub.y);
+ ctx.quadraticCurveTo(
+ c1x + nx * p.width,
+ c1y + ny * p.width,
+ p.tipX,
+ p.tipY
+ );
+ ctx.quadraticCurveTo(
+ c1x - nx * p.width,
+ c1y - ny * p.width,
+ hub.x,
+ hub.y
+ );
+ ctx.closePath();
+ ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.06)`;
+ ctx.fill();
+ ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.28)`;
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
+
+ // Spine
+ ctx.beginPath();
+ ctx.moveTo(hub.x, hub.y);
+ ctx.lineTo(p.tipX, p.tipY);
+ ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.35)`;
+ ctx.lineWidth = 1.4;
+ ctx.stroke();
+
+ // Traveling bloom spark along spine
+ const u = (t * 0.012 + (p.angle || 0)) % 1;
+ const sx = hub.x + (p.tipX - hub.x) * u;
+ const sy = hub.y + (p.tipY - hub.y) * u;
+ ctx.beginPath();
+ ctx.arc(sx, sy, 2.6, 0, Math.PI * 2);
+ ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.85)`;
+ ctx.fill();
+ }
+ // Inner halo
+ ctx.beginPath();
+ ctx.arc(hub.x, hub.y, 42, 0, Math.PI * 2);
+ ctx.strokeStyle = canvasTheme().decor(2);
+ ctx.lineWidth = 1;
+ ctx.stroke();
} else if (mode === "lanes") {
for (const g of state.laneGuides || []) {
ctx.beginPath();
@@ -769,6 +902,19 @@
}
return;
}
+ if (mode === "bloom") {
+ // Node → petal mid (spine), plus faint hub link
+ for (const n of state.nodes) {
+ const petal = (state.bloomPetals || [])[n.bloom];
+ if (petal) {
+ drawPulseLink(petal.midX, petal.midY, n.x, n.y, n.connected, t, n.id, 0.75);
+ if (n.connected) drawPulseLink(hub.x, hub.y, petal.midX, petal.midY, true, t, n.id + 9000, 0.35);
+ } else {
+ drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 0.8);
+ }
+ }
+ return;
+ }
if (mode === "lanes") {
for (const n of state.nodes) {
drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 0.55);
diff --git a/ui/index.html b/ui/index.html
index 591260d..05d6d55 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -58,6 +58,7 @@
+
@@ -633,7 +634,7 @@
-
+