Private/Get-DhJsNetwork.ps1

function Get-DhJsNetwork {
    # Fragment of the dashboard runtime JS (see Get-DhJsContent). v2.4.0.
    return @'
  /* =========================================================================
     NETWORK MAP (v2.4.0) - Add-DhNetworkMap
     Layout and path finding are PURE functions (data in, numbers out) and
     deterministic: same input, same picture. No force simulation (decision D3).
     Pan/zoom is createPanZoom() shared with the topology map. One delegated
     click handler on the SVG; no per-node listeners.
     ========================================================================= */

  var DH_NET = { GX: 26, GY: 30, PAD: 14, HEAD: 26, TOP: 1100, MARGIN: 20, LANE_LABEL: 96, LANE_PAD: 30 };

  /* Place boxes left to right, wrapping at `limit`. Mutates x/y on each item. */
  function dhNetFlow(items, limit, gap) {
    var x = 0, y = 0, rowH = 0, maxW = 0;
    items.forEach(function (it) {
      if (x > 0 && x + it.w > limit) { x = 0; y += rowH + gap; rowH = 0; }
      it.x = x; it.y = y; x += it.w + gap;
      if (it.h > rowH) rowH = it.h;
      if (x - gap > maxW) maxW = x - gap;
    });
    return { w: maxW, h: items.length ? y + rowH : 0 };
  }

  /* Rect-edge point on the line from the rect centre toward (tx, ty). */
  function dhNetBoundary(r, tx, ty) {
    var cx = r.x + r.w / 2, cy = r.y + r.h / 2, dx = tx - cx, dy = ty - cy;
    if (!dx && !dy) return { x: cx, y: cy };
    var s = Math.min(dx ? (r.w / 2) / Math.abs(dx) : Infinity, dy ? (r.h / 2) / Math.abs(dy) : Infinity);
    return { x: cx + dx * s, y: cy + dy * s };
  }

  /* Layout of a network map. Returns
     { pos:{id:{x,y,w,h}}, groups:[{id,label,status,x,y,w,h,depth}], lanes:[{tier,label,y,h}], width, height } */
  function dhNetLayout(block) {
    var G = DH_NET, nodes = block.nodes || [], edges = block.edges || [], groups = block.groups || [];
    var nw = block.nodeWidth || 150, nh = block.nodeHeight || 46;
    var pos = {}, outGroups = [], lanes = [];

    if (String(block.layout || 'grouped') === 'layered') {
      var byId = {}, out = {}, indeg = {}, depth = {}, order = {};
      nodes.forEach(function (n, i) { byId[n.id] = n; out[n.id] = []; indeg[n.id] = 0; order[n.id] = i; });
      edges.forEach(function (e) {
        if (e.from === e.to || !byId[e.from] || !byId[e.to]) return;
        out[e.from].push(e.to); indeg[e.to]++;
      });
      var seen = {};
      function bfs(startId) {
        var q = [startId]; seen[startId] = true; depth[startId] = 0;
        for (var qi = 0; qi < q.length; qi++) {
          out[q[qi]].forEach(function (t) {
            if (!seen[t]) { seen[t] = true; depth[t] = depth[q[qi]] + 1; q.push(t); }
          });
        }
      }
      nodes.forEach(function (n) { if (!indeg[n.id] && !seen[n.id]) bfs(n.id); });
      nodes.forEach(function (n) { if (!seen[n.id]) bfs(n.id); }); /* what is left sits in a cycle */

      var tierOf = {}, tiers = [];
      nodes.forEach(function (n) {
        var t = (n.tier !== undefined && n.tier !== null) ? n.tier : depth[n.id];
        tierOf[n.id] = t;
        if (tiers.indexOf(t) === -1) tiers.push(t);
      });
      tiers.sort(function (a, b) { return a - b; });
      var lane = tiers.map(function (t) { return nodes.filter(function (n) { return tierOf[n.id] === t; }).map(function (n) { return n.id; }); });

      /* one barycentre sweep, top to bottom; stable, ties by declaration order */
      var rank = {};
      lane[0].forEach(function (id, i) { rank[id] = (i + 0.5) / lane[0].length; });
      var nbr = {};
      nodes.forEach(function (n) { nbr[n.id] = []; });
      edges.forEach(function (e) {
        if (e.from === e.to || !byId[e.from] || !byId[e.to]) return;
        nbr[e.from].push(e.to); nbr[e.to].push(e.from);
      });
      for (var li = 1; li < lane.length; li++) {
        var keyed = lane[li].map(function (id, i) {
          var sum = 0, cnt = 0;
          nbr[id].forEach(function (o) {
            if (tiers.indexOf(tierOf[o]) < li && rank[o] !== undefined) { sum += rank[o]; cnt++; }
          });
          return { id: id, key: cnt ? sum / cnt : (i + 0.5) / lane[li].length, o: order[id] };
        });
        keyed.sort(function (a, b) { return (a.key - b.key) || (a.o - b.o); });
        lane[li] = keyed.map(function (k) { return k.id; });
        lane[li].forEach(function (id, i) { rank[id] = (i + 0.5) / lane[li].length; });
      }
      var maxW = 0;
      lane.forEach(function (ids) { var w = ids.length * nw + (ids.length - 1) * G.GX; if (w > maxW) maxW = w; });
      var laneH = nh + 2 * G.LANE_PAD, y0 = G.MARGIN;
      lane.forEach(function (ids, i) {
        var w = ids.length * nw + (ids.length - 1) * G.GX, x0 = G.MARGIN + G.LANE_LABEL + (maxW - w) / 2;
        ids.forEach(function (id, k) { pos[id] = { x: x0 + k * (nw + G.GX), y: y0 + G.LANE_PAD, w: nw, h: nh }; });
        lanes.push({ tier: tiers[i], label: 'Tier ' + tiers[i], y: y0, h: laneH });
        y0 += laneH;
      });
      return { pos: pos, groups: [], lanes: lanes, width: G.MARGIN * 2 + G.LANE_LABEL + maxW, height: y0 + G.MARGIN };
    }

    /* grouped */
    var gById = {}, kidsOf = {}, nodesOf = { '': [] };
    groups.forEach(function (g) { gById[g.id] = g; kidsOf[g.id] = []; nodesOf[g.id] = []; });
    var rootGroups = [];
    groups.forEach(function (g) { if (g.parent && gById[g.parent]) kidsOf[g.parent].push(g); else rootGroups.push(g); });
    nodes.forEach(function (n) { (nodesOf[(n.group && gById[n.group]) ? n.group : ''] || nodesOf['']).push(n); });

    function nodesBlock(members, inner) {
      var fit = Math.max(1, Math.floor((inner + G.GX) / (nw + G.GX)));
      var cols = Math.max(1, Math.min(Math.ceil(Math.sqrt(members.length)), fit, 5));
      var rows = Math.ceil(members.length / cols);
      return { kind: 'nodes', members: members, cols: cols, w: cols * nw + (cols - 1) * G.GX, h: rows * nh + (rows - 1) * G.GY };
    }
    function groupBox(g, limit) {
      var inner = limit - 2 * G.PAD, items = [];
      if (nodesOf[g.id].length) items.push(nodesBlock(nodesOf[g.id], inner));
      kidsOf[g.id].forEach(function (c) { var b = groupBox(c, inner); b.kind = 'group'; items.push(b); });
      var f = dhNetFlow(items, inner, G.GX);
      var labelW = String(g.label || '').length * 7 + 2 * G.PAD;
      return { g: g, items: items, w: Math.max(f.w + 2 * G.PAD, labelW), h: G.HEAD + f.h + G.PAD };
    }
    function place(box, ox, oy, d) {
      outGroups.push({ id: box.g.id, label: box.g.label, status: box.g.status || '', x: ox, y: oy, w: box.w, h: box.h, depth: d });
      box.items.forEach(function (it) {
        var bx = ox + G.PAD + it.x, by = oy + G.HEAD + it.y;
        if (it.kind === 'nodes') {
          it.members.forEach(function (n, i) {
            pos[n.id] = { x: bx + (i % it.cols) * (nw + G.GX), y: by + Math.floor(i / it.cols) * (nh + G.GY), w: nw, h: nh };
          });
        } else place(it, bx, by, d + 1);
      });
    }
    var top = [];
    if (nodesOf[''].length) top.push(nodesBlock(nodesOf[''], G.TOP));
    rootGroups.forEach(function (g) { var b = groupBox(g, G.TOP); b.kind = 'group'; top.push(b); });
    var tf = dhNetFlow(top, G.TOP, G.GX);
    top.forEach(function (it) {
      var bx = G.MARGIN + it.x, by = G.MARGIN + it.y;
      if (it.kind === 'nodes') {
        it.members.forEach(function (n, i) {
          pos[n.id] = { x: bx + (i % it.cols) * (nw + G.GX), y: by + Math.floor(i / it.cols) * (nh + G.GY), w: nw, h: nh };
        });
      } else place(it, bx, by, 0);
    });
    return { pos: pos, groups: outGroups, lanes: [], width: tf.w + 2 * G.MARGIN, height: tf.h + 2 * G.MARGIN };
  }

  /* Geometry of every edge: parallel edges between a pair fan out; a self edge is a loop.
     Returns [{ d, mx, my, loop }] in the order of `edges`. */
  function dhNetEdgeGeom(edges, pos) {
    var pairs = {}, idx = [];
    edges.forEach(function (e, i) {
      var k = e.from < e.to ? e.from + '\u0001' + e.to : e.to + '\u0001' + e.from;
      (pairs[k] = pairs[k] || []).push(i);
      idx[i] = k;
    });
    return edges.map(function (e, i) {
      var a = pos[e.from], b = pos[e.to];
      if (!a || !b) return null;
      if (e.from === e.to) {
        var tx = a.x + a.w / 2, ty = a.y;
        return { d: 'M' + (tx - 14) + ',' + ty + ' C' + (tx - 34) + ',' + (ty - 40) + ' ' + (tx + 34) + ',' + (ty - 40) + ' ' + (tx + 14) + ',' + ty,
                 mx: tx, my: ty - 30, loop: true };
      }
      var group = pairs[idx[i]], k = group.indexOf(i), n = group.length;
      var lo = e.from < e.to ? a : b, hi = e.from < e.to ? b : a;
      var cax = lo.x + lo.w / 2, cay = lo.y + lo.h / 2, cbx = hi.x + hi.w / 2, cby = hi.y + hi.h / 2;
      var dx = cbx - cax, dy = cby - cay, len = Math.sqrt(dx * dx + dy * dy) || 1;
      var off = (k - (n - 1) / 2) * 30;
      var cx = (cax + cbx) / 2 + (-dy / len) * off * 2, cy = (cay + cby) / 2 + (dx / len) * off * 2;
      var p0 = dhNetBoundary(a, cx, cy), p2 = dhNetBoundary(b, cx, cy);
      return { d: 'M' + p0.x.toFixed(1) + ',' + p0.y.toFixed(1) + ' Q' + cx.toFixed(1) + ',' + cy.toFixed(1) + ' ' + p2.x.toFixed(1) + ',' + p2.y.toFixed(1),
               mx: 0.25 * p0.x + 0.5 * cx + 0.25 * p2.x, my: 0.25 * p0.y + 0.5 * cy + 0.25 * p2.y, loop: false };
    });
  }

  /* Shortest path from startId to the nearest Target node. Directed edges are followed
     forward only; 'both' and 'none' edges either way. BFS in declaration order, so ties
     are broken deterministically. Returns { nodes:[ids], edges:[edge indexes] } or null. */
  function dhNetPath(block, startId) {
    var nodes = block.nodes || [], edges = block.edges || [], byId = {}, adj = {};
    nodes.forEach(function (n) { byId[n.id] = n; adj[n.id] = []; });
    if (!byId[startId]) return null;
    edges.forEach(function (e, i) {
      if (!byId[e.from] || !byId[e.to] || e.from === e.to) return;
      adj[e.from].push({ to: e.to, i: i });
      if (e.direction === 'both' || e.direction === 'none') adj[e.to].push({ to: e.from, i: i });
    });
    if (byId[startId].target) return { nodes: [startId], edges: [] };
    var prev = {}, q = [startId], seen = {};
    seen[startId] = true;
    for (var qi = 0; qi < q.length; qi++) {
      var cur = q[qi];
      for (var k = 0; k < adj[cur].length; k++) {
        var s = adj[cur][k];
        if (seen[s.to]) continue;
        seen[s.to] = true; prev[s.to] = { from: cur, i: s.i }; q.push(s.to);
        if (byId[s.to].target) {
          var pn = [s.to], pe = [], at = s.to;
          while (at !== startId) { pe.unshift(prev[at].i); at = prev[at].from; pn.unshift(at); }
          return { nodes: pn, edges: pe };
        }
      }
    }
    return null;
  }

  function dhNetTrunc(s, max) {
    s = String(s == null ? '' : s);
    return s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s;
  }

  var DH_NET_STATUSES = ['ok', 'warn', 'danger', 'info', 'nodata'];

  function renderNetworkMap(container, block) {
    container.className = 'network-map-section';
    var nodes = block.nodes || [], edges = block.edges || [];
    var titleHtml = block.title ? '<h3 class="network-map-title">' + esc(block.title) + '</h3>' : '';
    if (!nodes.length) { container.innerHTML = titleHtml + '<div class="network-map-empty">No nodes.</div>'; return; }

    var nw = block.nodeWidth || 150, nh = block.nodeHeight || 46;
    var L = dhNetLayout(block), geom = dhNetEdgeGeom(edges, L.pos);
    var byId = {};
    nodes.forEach(function (n) { byId[n.id] = n; });
    var uid = 'nm-' + block.id.replace(/[^A-Za-z0-9_-]/g, '_');

    /* ---- svg body ---------------------------------------------------- */
    var markerDefs = DH_NET_STATUSES.concat(['none']).map(function (s) {
      return '<marker id="' + uid + '-arr-' + s + '" class="nm-arrow nm-e-' + s + '" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z"/></marker>';
    }).join('');

    var lanesSvg = L.lanes.map(function (ln, i) {
      return '<g class="nm-lane"><rect class="nm-lane-band' + (i % 2 ? ' nm-lane-alt' : '') + '" x="' + DH_NET.MARGIN + '" y="' + ln.y + '" width="' + (L.width - 2 * DH_NET.MARGIN) + '" height="' + ln.h + '"/>' +
             '<text class="nm-lane-label" x="' + (DH_NET.MARGIN + 10) + '" y="' + (ln.y + ln.h / 2 + 4) + '">' + esc(ln.label) + '</text></g>';
    }).join('');

    var groupsSvg = L.groups.map(function (g) {
      var st = g.status ? ' nm-st-' + g.status : '';
      return '<g class="nm-group' + st + '"><rect class="nm-group-box" x="' + g.x + '" y="' + g.y + '" width="' + g.w + '" height="' + g.h + '" rx="8"/>' +
             '<text class="nm-group-label" x="' + (g.x + 10) + '" y="' + (g.y + 17) + '">' + esc(dhNetTrunc(g.label, Math.floor(g.w / 7))) + '</text></g>';
    }).join('');

    var edgesSvg = edges.map(function (e, i) {
      var gm = geom[i]; if (!gm) return '';
      var st = e.status || 'none', dir = e.direction || 'forward';
      var mk = (dir === 'forward' || dir === 'both' ? ' marker-end="url(#' + uid + '-arr-' + st + ')"' : '') +
               (dir === 'both' ? ' marker-start="url(#' + uid + '-arr-' + st + ')"' : '');
      var tipText = e.tooltip || ((byId[e.from] ? byId[e.from].label : e.from) + ' → ' + (byId[e.to] ? byId[e.to].label : e.to) + (e.label ? ': ' + e.label : ''));
      var lbl = '';
      if (e.label) {
        var lw = Math.max(18, String(e.label).length * 6 + 10);
        lbl = '<g class="nm-edge-label"><rect x="' + (gm.mx - lw / 2).toFixed(1) + '" y="' + (gm.my - 8).toFixed(1) + '" width="' + lw + '" height="16" rx="8"/>' +
              '<text x="' + gm.mx.toFixed(1) + '" y="' + (gm.my + 3.5).toFixed(1) + '" text-anchor="middle">' + esc(e.label) + '</text></g>';
      }
      return '<g class="nm-edge nm-e-' + st + (e.style === 'dashed' ? ' nm-dashed' : '') + ((e.linkTableId) ? ' nm-clickable' : '') + '" data-ei="' + i + '" data-tip="' + esc(tipText) + '">' +
             '<path class="nm-edge-hit" d="' + gm.d + '"/><path class="nm-edge-line" d="' + gm.d + '"' + mk + '/>' + lbl + '</g>';
    }).join('');

    var maxChars = Math.max(4, Math.floor((nw - 34) / 6.6));
    var nodesSvg = nodes.map(function (n, i) {
      var p = L.pos[n.id]; if (!p) return '';
      var st = n.status ? ' nm-st-' + n.status : '';
      var badges = n.badges || [];
      var bTxt = badges.slice(0, 3).join(' · ') + (badges.length > 3 ? ' +' + (badges.length - 3) : '');
      var yLabel = bTxt ? p.h * 0.42 : p.h / 2 + 4, yBadge = p.h * 0.78;
      var label = (n.icon ? n.icon + ' ' : '') + n.label;
      var aria = n.label + (n.status ? ', ' + (SEC_LABEL[n.status] || n.status) : '') + (badges.length ? ', ' + badges.join(', ') : '') + (n.target ? ', target' : '');
      var tip = n.tooltip || (n.label + (badges.length ? ' (' + badges.join(', ') + ')' : ''));
      return '<g class="nm-node' + st + (n.target ? ' nm-target' : '') + '" data-id="' + esc(n.id) + '" tabindex="0" role="button" aria-label="' + esc(aria) + '"' +
             ' data-tip="' + esc(tip) + '" transform="translate(' + p.x.toFixed(1) + ',' + p.y.toFixed(1) + ')">' +
             '<rect class="nm-node-box" width="' + p.w + '" height="' + p.h + '" rx="7"/>' +
             '<circle class="nm-led" cx="13" cy="' + (p.h / 2) + '" r="4.5"/>' +
             '<text class="nm-node-label" x="26" y="' + yLabel.toFixed(1) + '">' + esc(dhNetTrunc(label, maxChars)) + '</text>' +
             (bTxt ? '<text class="nm-node-badges" x="26" y="' + yBadge.toFixed(1) + '">' + esc(dhNetTrunc(bTxt, maxChars + 2)) + '</text>' : '') +
             (n.target ? '<text class="nm-target-mark" x="' + (p.w - 14) + '" y="15" text-anchor="middle">◎</text>' : '') +
             '</g>';
    }).join('');

    /* ---- toolbar ------------------------------------------------------ */
    var stSet = [], bdSet = [];
    nodes.forEach(function (n) {
      if (n.status && stSet.indexOf(n.status) === -1) stSet.push(n.status);
      (n.badges || []).forEach(function (b) { if (bdSet.indexOf(b) === -1) bdSet.push(b); });
    });
    stSet.sort(function (a, b) { return DH_NET_STATUSES.indexOf(a) - DH_NET_STATUSES.indexOf(b); });
    var hasTarget = nodes.some(function (n) { return n.target; });
    var chips =
      stSet.map(function (s) { return '<button type="button" class="nm-chip nm-st-' + s + '" data-f="s:' + esc(s) + '" aria-pressed="false"><span class="nm-chip-dot"></span>' + esc(SEC_LABEL[s] || s) + '</button>'; }).join('') +
      bdSet.slice(0, 12).map(function (b) { return '<button type="button" class="nm-chip" data-f="b:' + esc(b) + '" aria-pressed="false">' + esc(b) + '</button>'; }).join('');
    var legendHtml = '';
    if (block.legend) {
      legendHtml = '<div class="nm-legend" aria-label="Legend">' +
        DH_NET_STATUSES.map(function (s) { return '<span class="nm-legend-item nm-st-' + s + '"><span class="nm-chip-dot"></span>' + esc(SEC_LABEL[s]) + '</span>'; }).join('') +
        '<span class="nm-legend-item"><svg width="30" height="8" aria-hidden="true"><line x1="0" y1="4" x2="30" y2="4" class="nm-legend-line"/></svg>link</span>' +
        '<span class="nm-legend-item"><svg width="30" height="8" aria-hidden="true"><line x1="0" y1="4" x2="30" y2="4" class="nm-legend-line nm-dashed-line"/></svg>dashed</span>' +
        (hasTarget ? '<span class="nm-legend-item"><span class="nm-target-sample">◎</span>target</span>' : '') + '</div>';
    }
    var srList = '<ul class="nm-sr">' + edges.map(function (e) {
      var a = byId[e.from], b = byId[e.to];
      return '<li>' + esc((a ? a.label : e.from) + (e.direction === 'both' ? ' ↔ ' : e.direction === 'none' ? ' — ' : ' → ') + (b ? b.label : e.to) +
             (e.label ? ' (' + e.label + ')' : '') + (e.status ? ', ' + (SEC_LABEL[e.status] || e.status) : '')) + '</li>';
    }).join('') + '</ul>';

    var initH = Math.min(Math.max(block.height || 520, 200), 1600);
    container.innerHTML = titleHtml +
      '<div class="network-map-toolbar" role="toolbar" aria-label="Network map controls">' +
        '<button type="button" class="topo-btn" data-topo-act="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>' +
        '<button type="button" class="topo-btn" data-topo-act="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>' +
        '<button type="button" class="topo-btn" data-topo-act="reset" title="Reset view" aria-label="Reset view">⤢</button>' +
        '<input type="search" class="nm-search" placeholder="Find node…" aria-label="Find a node" autocomplete="off">' +
        '<button type="button" class="nm-btn" data-nm="labels" aria-pressed="true" title="Show or hide link labels">Labels</button>' +
        (hasTarget ? '<button type="button" class="nm-btn" data-nm="path" aria-pressed="false" title="Select a node, then trace the shortest path to the nearest target">Trace path</button>' : '') +
        '<button type="button" class="nm-btn" data-nm="svg" title="Download as SVG">SVG</button>' +
        (chips ? '<span class="nm-chips" role="group" aria-label="Filters">' + chips + '</span>' : '') +
      '</div>' + legendHtml +
      '<div class="network-map-inner topo-interactive" style="height:' + initH + 'px">' +
        '<svg class="network-map-svg" width="100%" height="100%" role="group" aria-label="' + esc((block.title || 'Network map') + ': ' + nodes.length + ' nodes, ' + edges.length + ' links') + '">' +
          '<defs>' + markerDefs + '</defs><g class="topo-viewport nm-viewport">' +
          '<g class="nm-lanes">' + lanesSvg + '</g><g class="nm-groups">' + groupsSvg + '</g><g class="nm-edges">' + edgesSvg + '</g><g class="nm-nodes">' + nodesSvg + '</g>' +
        '</g></svg></div>' +
      '<div class="nm-status" role="status" aria-live="polite"></div>' + srList;

    var innerDiv = container.querySelector('.network-map-inner');
    var svgEl = container.querySelector('.network-map-svg');
    var vp = container.querySelector('.nm-viewport');
    var tbar = container.querySelector('.network-map-toolbar');
    var statusEl = container.querySelector('.nm-status');
    var search = container.querySelector('.nm-search');
    var nodeEls = {}, edgeEls = [];
    container.querySelectorAll('.nm-node').forEach(function (el) { nodeEls[el.getAttribute('data-id')] = el; });
    container.querySelectorAll('.nm-edge').forEach(function (el) { edgeEls[parseInt(el.getAttribute('data-ei'), 10)] = el; });
    if (edges.length > 400) container.classList.add('nm-hide-labels');

    var pz = createPanZoom({
      innerDiv: innerDiv, svgEl: svgEl, vp: vp, initH: initH, toolbar: tbar,
      getContent: function () { return { w: L.width, h: L.height }; }
    });
    pz.syncViewBox(); pz.fit();
    /* v3.0.0 - on paper the map box takes the drawing's own proportions, so a small map no longer
       prints inside a tall empty frame. No measuring: a map on another menu page is still hidden when
       beforeprint fires. The viewBox becomes the drawing and the box its aspect ratio, capped so the
       drawing is not enlarged past about its own size; the browser fits it to the paper. The screen
       view (and the reader's zoom) comes back after. */
    var printT = null;
    dhTtPrintHooks.push({
      before: function () {
        printT = vp.getAttribute('transform') || '';
        vp.removeAttribute('transform');
        svgEl.setAttribute('viewBox', '0 0 ' + L.width + ' ' + L.height);
        innerDiv.style.height = 'auto';
        innerDiv.style.aspectRatio = L.width + ' / ' + L.height;
        innerDiv.style.maxHeight = Math.min(initH, L.height + 16) + 'px';
      },
      after: function () {
        if (printT === null) return;
        innerDiv.style.aspectRatio = ''; innerDiv.style.maxHeight = '';
        innerDiv.style.height = initH + 'px';
        if (printT) vp.setAttribute('transform', printT);
        printT = null;
        pz.syncViewBox();
      }
    });

    /* ---- state + painting -------------------------------------------- */
    var fStatus = {}, fBadge = {}, selected = null, pathMode = false, pathRes = null, q = '';
    function nodeMatches(n) {
      var sK = Object.keys(fStatus), bK = Object.keys(fBadge);
      if (sK.length && !fStatus[n.status || '']) return false;
      if (bK.length && !(n.badges || []).some(function (b) { return fBadge[b]; })) return false;
      if (q) {
        var hay = (n.label + ' ' + n.id + ' ' + (n.badges || []).join(' ')).toLowerCase();
        if (hay.indexOf(q) === -1) return false;
      }
      return true;
    }
    function paint() {
      var anyFilter = Object.keys(fStatus).length || Object.keys(fBadge).length || q;
      var near = null;
      if (selected && !pathRes) {
        near = {}; near[selected] = true;
        edges.forEach(function (e) {
          if (e.from === selected) near[e.to] = true;
          if (e.to === selected) near[e.from] = true;
        });
      }
      var onPathN = {}, onPathE = {};
      if (pathRes) { pathRes.nodes.forEach(function (id) { onPathN[id] = true; }); pathRes.edges.forEach(function (i) { onPathE[i] = true; }); }
      nodes.forEach(function (n) {
        var el = nodeEls[n.id]; if (!el) return;
        var dim = (anyFilter && !nodeMatches(n)) || (near && !near[n.id]) || (pathRes && !onPathN[n.id]);
        el.classList.toggle('nm-dim', !!dim);
        el.classList.toggle('nm-sel', selected === n.id);
        el.classList.toggle('nm-onpath', !!onPathN[n.id] && !!pathRes);
      });
      edges.forEach(function (e, i) {
        var el = edgeEls[i]; if (!el) return;
        var a = byId[e.from], b = byId[e.to];
        var dim = (anyFilter && ((a && !nodeMatches(a)) || (b && !nodeMatches(b)))) ||
                  (selected && !pathRes && e.from !== selected && e.to !== selected) || (pathRes && !onPathE[i]);
        el.classList.toggle('nm-dim', !!dim);
        el.classList.toggle('nm-onpath', !!onPathE[i] && !!pathRes);
      });
    }
    function say(t) { statusEl.textContent = t || ''; }
    function updatePath() {
      pathRes = null;
      if (pathMode && selected) {
        pathRes = dhNetPath(block, selected);
        var s = byId[selected];
        if (!pathRes) say('No path from ' + s.label + ' to a target node.');
        else if (pathRes.nodes.length === 1) say(s.label + ' is itself a target.');
        else say('Path to ' + byId[pathRes.nodes[pathRes.nodes.length - 1]].label + ' (' + (pathRes.nodes.length - 1) + ' hop' + (pathRes.nodes.length === 2 ? '' : 's') + '): ' +
                 pathRes.nodes.map(function (id) { return byId[id].label; }).join(' → '));
      } else if (selected) {
        say(byId[selected].label + ' selected. Click the background to clear.');
      } else say('');
    }
    function select(id, el) {
      selected = (selected === id && !pathMode) ? null : id;
      updatePath(); paint();
      var n = byId[id];
      if (selected !== id) return;
      if (n.detail) {
        dhDrawerOpen({ title: n.detail.title || n.label, subtitle: n.detail.subtitle || '', sections: n.detail.sections,
                       tableId: n.linkTableId || '', filter: n.linkFilter || '' }, { opener: el });
      } else if (n.linkTableId) jumpToTable(n.linkTableId, n.linkFilter);
    }
    function centreOn(id) {
      var p = L.pos[id]; if (!p) return;
      var r = innerDiv.getBoundingClientRect(), sc = pz.view.scale;
      pz.view.tx = (r.width || 600) / 2 - (p.x + p.w / 2) * sc;
      pz.view.ty = (r.height || initH) / 2 - (p.y + p.h / 2) * sc;
      pz.zoomAround((r.width || 600) / 2, (r.height || initH) / 2, 1);
    }

    /* ---- events ------------------------------------------------------- */
    svgEl.addEventListener('click', function (e) {
      if (pz.dragged()) return;
      var nodeEl = e.target.closest('.nm-node');
      if (nodeEl) { select(nodeEl.getAttribute('data-id'), nodeEl); return; }
      var edgeEl = e.target.closest('.nm-edge');
      if (edgeEl) {
        var ed = edges[parseInt(edgeEl.getAttribute('data-ei'), 10)];
        if (ed && ed.linkTableId) jumpToTable(ed.linkTableId, ed.linkFilter);
        return;
      }
      if (selected) { selected = null; updatePath(); paint(); }
    });
    svgEl.addEventListener('keydown', function (e) {
      var nodeEl = e.target.closest && e.target.closest('.nm-node');
      if (nodeEl && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); select(nodeEl.getAttribute('data-id'), nodeEl); }
      else if (e.key === 'Escape' && selected) { selected = null; updatePath(); paint(); }
    });
    svgEl.addEventListener('mousemove', function (e) {
      var t = e.target.closest && e.target.closest('[data-tip]');
      if (t && !pz.dragged()) _topoShowTooltip(t.getAttribute('data-tip'), e); else _topoHideTooltip();
    });
    svgEl.addEventListener('mouseleave', _topoHideTooltip);

    tbar.addEventListener('click', function (e) {
      var chip = e.target.closest('.nm-chip');
      if (chip) {
        var f = chip.getAttribute('data-f'), set = f.charAt(0) === 's' ? fStatus : fBadge, key = f.slice(2);
        if (set[key]) delete set[key]; else set[key] = true;
        chip.setAttribute('aria-pressed', set[key] ? 'true' : 'false');
        paint(); return;
      }
      var btn = e.target.closest('[data-nm]'); if (!btn) return;
      var a = btn.getAttribute('data-nm');
      if (a === 'labels') {
        var on = container.classList.toggle('nm-hide-labels');
        btn.setAttribute('aria-pressed', on ? 'false' : 'true');
      } else if (a === 'path') {
        pathMode = !pathMode;
        btn.setAttribute('aria-pressed', pathMode ? 'true' : 'false');
        if (pathMode && !selected) say('Select a node to trace its path to a target.'); else { updatePath(); paint(); }
      } else if (a === 'svg') dhNetDownload(svgEl, L, block);
    });
    var labelsBtn = tbar.querySelector('[data-nm="labels"]');
    if (labelsBtn && edges.length > 400) labelsBtn.setAttribute('aria-pressed', 'false');
    search.addEventListener('input', function () { q = search.value.trim().toLowerCase(); paint(); });
    search.addEventListener('keydown', function (e) {
      if (e.key !== 'Enter') return;
      var hit = nodes.filter(nodeMatches)[0];
      if (hit) centreOn(hit.id);
    });
    paint();
  }

  /* Download the map as a stand-alone SVG: the .nm-* rules and the theme variables
     they use are copied in, because a saved file has no page stylesheet. */
  function dhNetDownload(svgEl, L, block) {
    try {
      var clone = svgEl.cloneNode(true);
      var vpc = clone.querySelector('.nm-viewport');
      if (vpc) vpc.removeAttribute('transform');
      clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
      clone.setAttribute('viewBox', '0 0 ' + L.width + ' ' + L.height);
      clone.setAttribute('width', L.width); clone.setAttribute('height', L.height);
      var css = '', vars = {};
      Array.prototype.forEach.call(document.styleSheets, function (ss) {
        var rules; try { rules = ss.cssRules; } catch (e) { return; }
        Array.prototype.forEach.call(rules || [], function (r) {
          if (r.type === 1 && /\.nm-|\.topo-/.test(r.selectorText) && !/network-map|\.nm-(chip|btn|search|status|sr|legend)/.test(r.selectorText)) css += r.cssText + '\n';
        });
      });
      var cs = getComputedStyle(svgEl), m, rx = /var\((--[\w-]+)/g;
      while ((m = rx.exec(css))) vars[m[1]] = cs.getPropertyValue(m[1]).trim();
      var decl = Object.keys(vars).map(function (k) { return k + ':' + vars[k]; }).join(';');
      var st = document.createElementNS('http://www.w3.org/2000/svg', 'style');
      st.textContent = 'svg{' + decl + ';font-family:' + (cs.fontFamily || 'sans-serif') + '}\n' + css;
      clone.insertBefore(st, clone.firstChild);
      var bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
      bg.setAttribute('width', '100%'); bg.setAttribute('height', '100%');
      bg.setAttribute('fill', getComputedStyle(document.body).backgroundColor || '#fff');
      clone.insertBefore(bg, st.nextSibling);
      var blob = new Blob(['<?xml version="1.0" encoding="UTF-8"?>\n' + new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml' });
      var a = document.createElement('a');
      a.href = URL.createObjectURL(blob); a.download = (block.id || 'network-map') + '.svg';
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
    } catch (e) { /* download is a convenience; never break the page */ }
  }
'@

}