Преглед на файлове

web topo: cap frame pads (5-level nesting pushed the outer frame above the canvas — the 'top frame cut off'), draw the map at container width with scroll, add a persisted cluster filter (hidden clusters recompute the nest depth)

Alexander Korolev преди 2 седмици
родител
ревизия
b5cf4a4ba8
променени са 3 файла, в които са добавени 116 реда и са изтрити 17 реда
  1. 12 4
      .agents/skills/web-topology/SKILL.md
  2. 78 13
      src/web/ui.html
  3. 26 0
      src/web/ui.playwright.mjs

+ 12 - 4
.agents/skills/web-topology/SKILL.md

@@ -26,6 +26,12 @@ this is the working manual.
     window is minimized; windows overflow the layer otherwise and the
     layer silently scrolls, breaking click tests).
 - `src/web/topo.rs` — `derive_topo(config) -> TopoSnap` (pure, unit-tested):
+  (the map is **scrollable**: `#topo` draws the SVG at container width with
+  `min-width: 720px` and scrolls; a **cluster filter** — `#topofilter`
+  chip top-right, checkboxes, persisted to `localStorage
+  sm121-topo-hidden` — drops clusters from `topoFrames` *before* the depth
+  calc, so hiding an outer frame shrinks the nest; the filter state is part
+  of `topoSig`):
   the semantic contract below. Snapshot builder in `events.rs` fills
   `TopoSnap.states` from probe HA states and `NodeSnap.ha` per node plug.
 - `src/web/probe.rs` — `ha_states()`: one parallel HA `get_state` batch per
@@ -63,10 +69,12 @@ this is the working manual.
   no hop. Row 0 and DAC are unchanged in both modes.
 - Frames: clusters with **identical node sets merge into one rect** with
   side-by-side chips (kills doubled frames + mutual-containment depth
-  inflation). Pads grow with region rank Δ = regionMaxDepth − depth:
-  x `14+10Δ`, top `12+18Δ`, bottom `12+5Δ` — outer cluster sits furthest
-  out on both edges, so chips land on separate "floors" (y 18/36/54) and
-  bottom edges don't stack into one line.
+  inflation). Pads grow with region rank Δ = regionMaxDepth − depth, but
+  **capped at 2** (`x 14+10·min(Δ,2)`, top `12+18·min(Δ,2)`, bottom
+  `12+5·min(Δ,2)`) — nesting now runs 5 deep (4nodes ⊃ 6nodes ⊃ 8nodes ⊃
+  dgx58 ⊃ dgx56) and uncapped pads pushed the outer frame above y=0, where
+  the viewBox clipped it. Chip bands are quantized (`round(y/16)`) so
+  near-colliding floors stagger horizontally.
 - Render order: **all frame rects first, then all chips** — no frame edge
   ever crosses a label. Chips x-stagger per floor band (cursor walks
   `rect.x+12`, widths ≈ `len*6.6+30`).

+ 78 - 13
src/web/ui.html

@@ -304,8 +304,19 @@
   }
 
   /* --- 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 text { font: 12px/1.25 ui-sans-serif, system-ui; fill: var(--fg); }
   #topo .sub { fill: var(--muted); font-size: 10px; }
@@ -1057,6 +1068,45 @@ const hueOf = (name) => {
   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) {
   return t.nodes.join(",") + "|" + t.links.map((l) => l.kind + l.a + ">" + l.b).join(",")
     + "|" + t.switches.map((s) => s.entity).join(",");
@@ -1150,11 +1200,15 @@ function fanSvg(sw) {
 
 function topoFrames(snap) {
   const t = snap.topo;
+  const hidden = state.topoHidden;
   // 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 groups = [];
   for (const c of snap.clusters) {
+    if (hidden.has(c.name)) continue;
     const key = c.nodes.map((n) => n.name).sort().join("|");
     if (seen.has(key)) seen.get(key).clusters.push(c);
     else {
@@ -1173,7 +1227,10 @@ function topoFrames(snap) {
     const d = depthOf(g);
     const region = groups.filter((o) => o.nodes.some((n) => g.nodes.includes(n)));
     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 = [];
   for (const g of groups) {
@@ -1201,11 +1258,12 @@ function topoFrames(snap) {
     }
     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();
   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()) {
     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 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})">
       <title>${esc(c.name)}</title>
       <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 t = snap.topo;
   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";
-  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;
     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}
     </svg>`;
+    wireTopoFilter(host);
     for (const g of host.querySelectorAll("[data-cluster]")) {
       g.onclick = () => {
         state.active = g.dataset.cluster;
@@ -1965,7 +2030,7 @@ function initManagedWindows() {
   // 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);
   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,
     bodyHtml: `<div id="topo"><div class="topo-empty">waiting for the first fleet scan…</div></div>`,
   });

+ 26 - 0
src/web/ui.playwright.mjs

@@ -502,6 +502,32 @@ const band = await page.evaluate(() => {
 });
 ok("topo: window fits the drawing (no letterbox bands)", band < 8, String(band));
 
+// nothing may be drawn above the canvas: pads are capped, so deep cluster
+// nesting (5 levels) must not push the outer frame above y=0
+const cut = await page.evaluate(() => {
+  const svg = document.querySelector("#topo svg");
+  if (!svg) return 0;
+  const top = svg.getBoundingClientRect().y;
+  const ys = [...svg.querySelectorAll(".cluster-frame .frame, .fchip")].map(
+    (e) => e.getBoundingClientRect().y - top,
+  );
+  return ys.length ? Math.round(Math.min(...ys)) : 0;
+});
+ok("topo: nothing drawn above the canvas", cut >= -1, String(cut));
+
+// cluster filter: unchecking hides the frame (pads recompute), show-all restores
+await page.evaluate(() => front(document.querySelector("#win-topo")));
+await page.click("#topofilter [data-tf-toggle]");
+await page.waitForSelector("#topofilter .tfpanel");
+const f0 = await page.locator("#topo .cluster-frame").count();
+await page.click('[data-tf-c="fake"]');
+await page.waitForTimeout(400);
+const f1 = await page.locator("#topo .cluster-frame").count();
+ok("topo: cluster filter hides a frame", f1 === f0 - 1, `${f0} -> ${f1}`);
+await page.click("#topofilter [data-tf-all]");
+await page.waitForTimeout(300);
+ok("topo: show-all restores the frames", (await page.locator("#topo .cluster-frame").count()) === f0);
+
 // fan glyph: the rotation must be hub-centered (fill-box origin wobbled it)
 {
   const fanHub = await page.evaluate(() => {