|
@@ -304,8 +304,19 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/* --- topology map ------------------------------------------------------- */
|
|
/* --- topology map ------------------------------------------------------- */
|
|
|
- #topo { height: 100%; overflow: hidden; }
|
|
|
|
|
- #topo svg { width: 100%; height: 100%; display: block; }
|
|
|
|
|
|
|
+ .topowin .win-body { padding: 0; overflow: hidden; }
|
|
|
|
|
+ #topo { height: 100%; overflow: auto; } /* the map draws at full width; scroll if taller */
|
|
|
|
|
+ #topo svg { width: 100%; min-width: 720px; height: auto; display: block; }
|
|
|
|
|
+ /* below the titlebar (36px) so the window controls stay reachable */
|
|
|
|
|
+ #topofilter { position: absolute; top: 36px; right: 10px; z-index: 3; }
|
|
|
|
|
+ #topofilter .tfpanel {
|
|
|
|
|
+ margin-top: 4px; padding: 6px 10px; background: var(--panel);
|
|
|
|
|
+ border: 1px solid var(--border); border-radius: 8px; font-size: 12px;
|
|
|
|
|
+ display: flex; flex-direction: column; gap: 3px; box-shadow: 0 4px 14px rgba(0,0,0,.3);
|
|
|
|
|
+ }
|
|
|
|
|
+ #topofilter label { display: flex; gap: 6px; align-items: center; cursor: pointer; }
|
|
|
|
|
+ #topofilter .tflink { color: var(--muted); cursor: pointer; font-size: 11px; }
|
|
|
|
|
+ #topofilter .tflink:hover { color: var(--fg); }
|
|
|
#topo .topo-empty { height: 100%; display: grid; place-items: center; color: var(--muted); }
|
|
#topo .topo-empty { height: 100%; display: grid; place-items: center; color: var(--muted); }
|
|
|
#topo text { font: 12px/1.25 ui-sans-serif, system-ui; fill: var(--fg); }
|
|
#topo text { font: 12px/1.25 ui-sans-serif, system-ui; fill: var(--fg); }
|
|
|
#topo .sub { fill: var(--muted); font-size: 10px; }
|
|
#topo .sub { fill: var(--muted); font-size: 10px; }
|
|
@@ -1057,6 +1068,45 @@ const hueOf = (name) => {
|
|
|
return TG_HUES[h % TG_HUES.length];
|
|
return TG_HUES[h % TG_HUES.length];
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+function topoFilterHtml() {
|
|
|
|
|
+ const names = state.snap ? [...new Set(state.snap.clusters.map((c) => c.name))].sort() : [];
|
|
|
|
|
+ const open = state.topoFilterOpen;
|
|
|
|
|
+ const chips = `<button class="dock-chip" data-tf-toggle>${names.length ? "clusters ▾" : ""}</button>`;
|
|
|
|
|
+ if (!names.length) return "";
|
|
|
|
|
+ const rows = names.map((n) => `<label><input type="checkbox" data-tf-c="${esc(n)}" ${state.topoHidden.has(n) ? "" : "checked"}> ${esc(n)}</label>`).join("");
|
|
|
|
|
+ const all = `<span class="tflink" data-tf-all>${state.topoHidden.size ? "show all" : "hide all"}</span>`;
|
|
|
|
|
+ return `<div id="topofilter">${chips}${open ? `<div class="tfpanel">${all}${rows}</div>` : ""}</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+function wireTopoFilter(host) {
|
|
|
|
|
+ const panel = host.querySelector("#topofilter");
|
|
|
|
|
+ if (!panel) return;
|
|
|
|
|
+ panel.querySelector("[data-tf-toggle]").onclick = (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ state.topoFilterOpen = !state.topoFilterOpen;
|
|
|
|
|
+ renderTopo(state.snap, true);
|
|
|
|
|
+ };
|
|
|
|
|
+ const all = panel.querySelector("[data-tf-all]");
|
|
|
|
|
+ if (all) all.onclick = () => {
|
|
|
|
|
+ if (state.topoHidden.size) state.topoHidden.clear();
|
|
|
|
|
+ else for (const c of state.snap.clusters) state.topoHidden.add(c.name);
|
|
|
|
|
+ saveTopoHidden();
|
|
|
|
|
+ state.topoFilterOpen = true;
|
|
|
|
|
+ renderTopo(state.snap, true);
|
|
|
|
|
+ };
|
|
|
|
|
+ for (const box of panel.querySelectorAll("[data-tf-c]")) {
|
|
|
|
|
+ box.onchange = () => {
|
|
|
|
|
+ box.checked ? state.topoHidden.delete(box.dataset.tfC) : state.topoHidden.add(box.dataset.tfC);
|
|
|
|
|
+ saveTopoHidden();
|
|
|
|
|
+ renderTopo(state.snap, true);
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+function saveTopoHidden() {
|
|
|
|
|
+ try { localStorage.setItem("sm121-topo-hidden", JSON.stringify([...state.topoHidden])); } catch {}
|
|
|
|
|
+ state.topoSig = null; // force a rebuild on the next render
|
|
|
|
|
+ renderTopo(state.snap, true);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function topoSig(t) {
|
|
function topoSig(t) {
|
|
|
return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
|
|
return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
|
|
|
+ "|" + t.switches.map((s) => s.entity).join(",");
|
|
+ "|" + t.switches.map((s) => s.entity).join(",");
|
|
@@ -1150,11 +1200,15 @@ function fanSvg(sw) {
|
|
|
|
|
|
|
|
function topoFrames(snap) {
|
|
function topoFrames(snap) {
|
|
|
const t = snap.topo;
|
|
const t = snap.topo;
|
|
|
|
|
+ const hidden = state.topoHidden;
|
|
|
// clusters with identical node sets share one frame (several chips on
|
|
// clusters with identical node sets share one frame (several chips on
|
|
|
- // it) — no doubled rectangles, no mutual-containment depth inflation
|
|
|
|
|
|
|
+ // it) — no doubled rectangles, no mutual-containment depth inflation.
|
|
|
|
|
+ // Hidden clusters leave the groups entirely, so the pads recompute for
|
|
|
|
|
+ // what is actually shown (hiding an outer frame shrinks the nest).
|
|
|
const seen = new Map();
|
|
const seen = new Map();
|
|
|
const groups = [];
|
|
const groups = [];
|
|
|
for (const c of snap.clusters) {
|
|
for (const c of snap.clusters) {
|
|
|
|
|
+ if (hidden.has(c.name)) continue;
|
|
|
const key = c.nodes.map((n) => n.name).sort().join("|");
|
|
const key = c.nodes.map((n) => n.name).sort().join("|");
|
|
|
if (seen.has(key)) seen.get(key).clusters.push(c);
|
|
if (seen.has(key)) seen.get(key).clusters.push(c);
|
|
|
else {
|
|
else {
|
|
@@ -1173,7 +1227,10 @@ function topoFrames(snap) {
|
|
|
const d = depthOf(g);
|
|
const d = depthOf(g);
|
|
|
const region = groups.filter((o) => o.nodes.some((n) => g.nodes.includes(n)));
|
|
const region = groups.filter((o) => o.nodes.some((n) => g.nodes.includes(n)));
|
|
|
const rmax = Math.max(...region.map(depthOf));
|
|
const rmax = Math.max(...region.map(depthOf));
|
|
|
- return { x: 14 + 10 * (rmax - d), top: 12 + 18 * (rmax - d), bottom: 12 + 5 * (rmax - d) };
|
|
|
|
|
|
|
+ // capped: nesting can run 5 deep (4nodes ⊃ 6nodes ⊃ 8nodes ⊃ dgx58 ⊃
|
|
|
|
|
+ // dgx56) — uncapped pads pushed the outer frame above the canvas
|
|
|
|
|
+ const rank = Math.min(rmax - d, 2);
|
|
|
|
|
+ return { x: 14 + 10 * rank, top: 12 + 18 * rank, bottom: 12 + 5 * rank };
|
|
|
};
|
|
};
|
|
|
const rects = [];
|
|
const rects = [];
|
|
|
for (const g of groups) {
|
|
for (const g of groups) {
|
|
@@ -1201,11 +1258,12 @@ function topoFrames(snap) {
|
|
|
}
|
|
}
|
|
|
r.hue = r.g.clusters[0]._hue;
|
|
r.hue = r.g.clusters[0]._hue;
|
|
|
}
|
|
}
|
|
|
- // chip bands: chips sharing a frame-top floor get x-staggered so labels
|
|
|
|
|
- // never overlap (merged frames put several chips on one frame)
|
|
|
|
|
|
|
+ // chip bands: chips whose frame tops sit within ~16px share one band and
|
|
|
|
|
+ // get x-staggered so labels never overlap (deep nests squeeze floors)
|
|
|
const bands = new Map();
|
|
const bands = new Map();
|
|
|
for (const r of rects) {
|
|
for (const r of rects) {
|
|
|
- for (const c of r.g.clusters) (bands.get(r.y) ?? bands.set(r.y, []).get(r.y)).push({ r, c });
|
|
|
|
|
|
|
+ r.band = Math.round(r.y / 16);
|
|
|
|
|
+ for (const c of r.g.clusters) (bands.get(r.band) ?? bands.set(r.band, []).get(r.band)).push({ r, c });
|
|
|
}
|
|
}
|
|
|
for (const band of bands.values()) {
|
|
for (const band of bands.values()) {
|
|
|
band.sort((a, b) => a.r.x - b.r.x || a.c.name.localeCompare(b.c.name));
|
|
band.sort((a, b) => a.r.x - b.r.x || a.c.name.localeCompare(b.c.name));
|
|
@@ -1232,7 +1290,7 @@ function topoFrames(snap) {
|
|
|
}
|
|
}
|
|
|
for (const r of rects) {
|
|
for (const r of rects) {
|
|
|
for (const c of r.g.clusters) {
|
|
for (const c of r.g.clusters) {
|
|
|
- const chip = bands.get(r.y).find((x) => x.c === c);
|
|
|
|
|
|
|
+ const chip = bands.get(r.band).find((x) => x.c === c);
|
|
|
chips += `<g class="fchip" data-cluster="${esc(c.name)}" transform="translate(${chip.x},${r.y - 8})">
|
|
chips += `<g class="fchip" data-cluster="${esc(c.name)}" transform="translate(${chip.x},${r.y - 8})">
|
|
|
<title>${esc(c.name)}</title>
|
|
<title>${esc(c.name)}</title>
|
|
|
<rect width="${chip.cw}" height="16" rx="8" style="stroke:${c._hue}"/>
|
|
<rect width="${chip.cw}" height="16" rx="8" style="stroke:${c._hue}"/>
|
|
@@ -1417,18 +1475,25 @@ function topoTick(snap) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function renderTopo(snap) {
|
|
|
|
|
|
|
+function renderTopo(snap, force) {
|
|
|
const host = document.getElementById("topo");
|
|
const host = document.getElementById("topo");
|
|
|
const t = snap.topo;
|
|
const t = snap.topo;
|
|
|
if (!host || !t) return;
|
|
if (!host || !t) return;
|
|
|
|
|
+ if (!state.topoHidden) {
|
|
|
|
|
+ let saved = [];
|
|
|
|
|
+ try { saved = JSON.parse(localStorage.getItem("sm121-topo-hidden") || "[]"); } catch {}
|
|
|
|
|
+ state.topoHidden = new Set(Array.isArray(saved) ? saved : []);
|
|
|
|
|
+ }
|
|
|
if (state.topoBus === undefined) state.topoBus = localStorage.getItem("sm121-topobus") === "1";
|
|
if (state.topoBus === undefined) state.topoBus = localStorage.getItem("sm121-topobus") === "1";
|
|
|
- const sig = topoSig(t) + (state.topoBus ? ":bus" : ":direct");
|
|
|
|
|
- if (state.topoSig !== sig) {
|
|
|
|
|
|
|
+ const sig = topoSig(t) + (state.topoBus ? ":bus" : ":direct")
|
|
|
|
|
+ + ":" + [...state.topoHidden].sort().join(",");
|
|
|
|
|
+ if (force || state.topoSig !== sig) {
|
|
|
state.topoSig = sig;
|
|
state.topoSig = sig;
|
|
|
const tf = topoFrames(snap);
|
|
const tf = topoFrames(snap);
|
|
|
- host.innerHTML = `<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
|
|
|
|
|
|
|
+ host.innerHTML = `${topoFilterHtml()}<svg viewBox="0 0 ${TG_W} ${TG_H}" preserveAspectRatio="xMidYMid meet">
|
|
|
${tf.frames}${cablesSvg(t, state.topoBus)}${switchesSvg(t)}${devsSvg(t)}${legendSvg(state.topoBus)}${tf.chips}
|
|
${tf.frames}${cablesSvg(t, state.topoBus)}${switchesSvg(t)}${devsSvg(t)}${legendSvg(state.topoBus)}${tf.chips}
|
|
|
</svg>`;
|
|
</svg>`;
|
|
|
|
|
+ wireTopoFilter(host);
|
|
|
for (const g of host.querySelectorAll("[data-cluster]")) {
|
|
for (const g of host.querySelectorAll("[data-cluster]")) {
|
|
|
g.onclick = () => {
|
|
g.onclick = () => {
|
|
|
state.active = g.dataset.cluster;
|
|
state.active = g.dataset.cluster;
|
|
@@ -1965,7 +2030,7 @@ function initManagedWindows() {
|
|
|
// the SVG letterboxes itself with big empty bands above and below
|
|
// the SVG letterboxes itself with big empty bands above and below
|
|
|
const TH = Math.min(Math.round(tw * (TG_H / TG_W)) + 34, L - 260);
|
|
const TH = Math.min(Math.round(tw * (TG_H / TG_W)) + 34, L - 260);
|
|
|
wins.topo = makeWin({
|
|
wins.topo = makeWin({
|
|
|
- id: "win-topo", title: "Topology", x: 12, y: 12,
|
|
|
|
|
|
|
+ id: "win-topo", title: "Topology", x: 12, y: 12, extraCls: "topowin",
|
|
|
w: tw, h: TH,
|
|
w: tw, h: TH,
|
|
|
bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
|
|
bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
|
|
|
});
|
|
});
|