Private/ReportNetworkApps.ps1

# The Apps & Services view of "Big Picture" (ReportNetwork.ps1, ADR-0014): what the Apps and the
# Target Machine rely on - the Apps and Windows services on the machine, the Services they
# use, the Servers that provide them, the shares, mapped drives and Namespaces between - and
# every Link between those as an edge. The network itself - adapters, Subnets, Domains, Sites -
# stays on the Network Map and is not drawn here; what an adapter uses, the machine uses. Where Routes answers how fast the way to each Server is, this answers how it hangs
# together: drive -> Namespace -> share -> Server -> Subnet as one picture.
#
# What is drawn is built here, by one pure function (Get-ReportNetworkApps), and handed to
# the script on the model; the script only lays it out. A Server is drawn in the colour of its
# node, as on Routes, a Service with a URL and a mapped drive in that of their Findings, and
# nothing else in any colour: a Link says how things are connected, not whether they work.
# Where the Run did not look, a ghost says so where the part would be.
#
# The layout is the script's and is looked at, not tested: rings around the Target Machine,
# from what is on it out to the Servers, each thing in a share of its ring as large as what
# hangs on it - judged in a Report rendered in a headless browser from fixture Measurements,
# as the geometry of Routes is.

# The kinds of edge by how they are drawn, two ways only, in the order the legend explains
# them: what belongs to what - solid - and what talks to what over the network - dashed, in
# the accent. Each edge still names its own kind where it is pointed at.
$script:ReportNetworkAppsEdgeLooks = [ordered]@{
    BelongsTo = @('HasApp', 'HasService', 'HasDrive', 'OnServer', 'OfferedBy', 'LeadsTo')
    TalksTo   = @('Uses', 'MachineUses', 'UsesGateway', 'UsesDns', 'UsesDhcp')
}

# How many Shares, and how many Services, of one Server are drawn; the rest stand behind a
# "+N weitere" a click opens. A file Server with forty shares buries the picture.
$script:ReportNetworkAppsCrowd = 5

$script:ReportNetworkAppsCss = @'
<style>
/* Big Picture: Apps & Services (ReportNetworkApps.ps1). An edge by its kind, never by a
   Severity; a thing in a neutral tone by its kind, a Server, a Service and a drive striped
   in the colour of their Finding. */
.appslegend{margin:0 0 8px}
.appslone{margin:0 0 8px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 13px;color:var(--dim);font-size:12.5px;cursor:pointer}
.appslone:hover{color:var(--text);border-color:var(--accent)}
@media print{.appslone{display:none!important}}
/* Two ways only: what belongs to what, solid (the default, and so a "+N weitere" and a
   ghost too); what talks to what over the network, dashed in the accent. */
.tedge{fill:none;stroke:var(--dim);stroke-width:1.3;opacity:.8}
.tedge.e-Uses,.tedge.e-MachineUses,.tedge.e-UsesGateway,.tedge.e-UsesDns,.tedge.e-UsesDhcp{stroke:var(--accent);stroke-width:1.6;stroke-dasharray:5 3;opacity:.8}
.appssvg{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}
.appssvg .ring{fill:none;stroke:var(--dim);stroke-width:1;stroke-dasharray:3 5;opacity:.45}
.appssvg.panning{cursor:grabbing}
.appssvg .tlink .hit{stroke:transparent;stroke-width:9}
.appssvg .tlink:focus{outline:none}
.appssvg .tlink:hover .tedge,.appssvg .tlink:focus .tedge{stroke:var(--accent)}
.appssvg .thing{cursor:pointer;touch-action:none}
.appssvg .thing:focus{outline:none}
.appssvg .tbox,.appslegend .tbox{fill:var(--panel2);stroke:var(--line);stroke-width:1}
.appssvg .k-Machine .tbox{stroke:var(--dim);stroke-width:1.6}
.appssvg .k-More .tbox{fill:var(--panel);stroke-dasharray:3 2}
.appssvg .k-App .tbox{fill:var(--panel);stroke:var(--accent);stroke-width:1.2}
.appssvg .linked .tbox,.appslegend .linked{stroke:var(--faint);stroke-dasharray:4 3}
.appssvg .ghost .tbox{fill:none;stroke:var(--faint);stroke-dasharray:4 3}
.appssvg .ghost text{fill:var(--faint);font-style:italic}
.appssvg .stripe{fill:var(--c)}
/* What the logs held, apart from what was measured now: dashed, on the right. */
.appssvg .logmark{stroke:var(--c);stroke-width:4;stroke-dasharray:3 2;stroke-linecap:butt}
.appslegend .stripe{fill:var(--dim)} .appslegend .logmark{stroke:var(--dim);stroke-width:4;stroke-dasharray:3 2}
.appssvg .k-WindowsService .tbox{fill:var(--panel)}
.appssvg .thing:hover .tbox,.appssvg .thing:focus .tbox,.appssvg .thing.sel .tbox{stroke:var(--accent);stroke-width:1.6}
.appssvg.focus .thing:not(.on),.appssvg.focus .tlink:not(.on){opacity:.15}
.appssvg.focus .tlink.on .tedge{stroke:var(--text)}
.appslegend .tghost{color:var(--faint);font-style:italic}
@media print{
  .appssvg .tbox{fill:#fff}
  .appssvg.focus .thing,.appssvg.focus .tlink{opacity:1!important}
}
</style>
'@


$script:ReportNetworkAppsScript = @'
<script>
// Apps & Services: the whole Network Map as one graph. The model hands over what is drawn - the
// things, the edges, the ghosts (Get-ReportNetworkApps) - and this lays it out in rings
// around the Target Machine: its Apps and mapped drives, its Windows services, then out on
// the network the Namespaces, the shares, the Services, and last the Servers that offer
// them. The Servers stand round the outermost ring as far apart as they can, each with its
// Services beside it, and everything further in at the middle of what it links to further
// out; their order is the one that crosses fewest Links, a hash of the Key where none does
// better. So the same Map gives the same picture, a Service stands by its Server, and
// nothing moves about.
// What no edge joins stands apart, in columns below the graph, under a heading. Widths are
// counted in characters and not measured: the chapter may be hidden when it is drawn.
if (window.GutcheckNet) GutcheckNet.view('apps', function(root, net){
  var svg = root.querySelector('svg.appssvg'), d = net.data, graph = d.apps, w = net.words, tw = w.apps, esc = net.esc;
  if (!svg || !graph) return;
  var nodeOf = {};
  d.n.forEach(function(n){ nodeOf[n.id] = n; });
  // A Service and a Windows service have a detail of their own, as a Server has.
  (graph.nodes || []).forEach(function(n){ nodeOf[n.id] = n; });
  function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, n - 1) + String.fromCharCode(8230) : s; }
  // A text in lines of at most room characters, at most most lines, the last cut.
  function wrap(s, room, most){
    var out = [], cur = '';
    String(s || '').split(' ').forEach(function(x){
      if (cur && (cur + ' ' + x).length > room) { out.push(cur); cur = x; } else cur = cur ? cur + ' ' + x : x;
    });
    if (cur) out.push(cur);
    if (out.length > most) out = out.slice(0, most - 1).concat([out.slice(most - 1).join(' ')]);
    return out.map(function(l){ return cut(l, room); });
  }
  function hash(s){
    var h = 2166136261;
    for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
    return h >>> 0;
  }
 
  // Each thing and each ghost as the picture holds it: what it says, how large it is, where
  // it stands, and whether a drag has pinned it.
  var all = [], byId = {};
  function judged(t){ return t.kind === 'Server' || t.kind === 'Service' || t.kind === 'Drive' || t.kind === 'WindowsService' || t.kind === 'Namespace'; }
  function place(t, seed){
    var h = hash(seed), a = (h % 3600) / 3600 * 2 * Math.PI, r = 60 + ((h >>> 12) % 280);
    t.x = t.kind === 'Machine' ? 0 : Math.cos(a) * r;
    t.y = t.kind === 'Machine' ? 0 : Math.sin(a) * r;
  }
  graph.things.forEach(function(x){
    var t = { id: x.id, kind: x.kind, key: x.key, s: x.s || '', sl: x.sl || '', group: x.group || '', linked: !!x.linked, inside: x['in'] || '', rest: x.rest || [], node: nodeOf[x.id] || null };
    if (t.kind === 'Server' && t.node) { t.title = net.label(t.node); t.sub = net.roles(t.node).join(', '); }
    else if (t.kind === 'Machine') { t.title = d.machine || w.role.Machine; t.sub = w.role.Machine; }
    // A Service by its name where its port has one, its Server and port under it.
    else if (x.name) { t.title = x.name; t.sub = x.label || x.key; }
    else { t.title = x.label || x.key; t.sub = t.kind === 'More' ? '' : (tw.thing[t.kind] || ''); }
    t.lines = [cut(t.title, 34)];
    if (t.sub) t.lines.push(cut(t.sub, 38));
    t.w = Math.min(270, Math.max(64, Math.max(t.lines[0].length * 6.9, (t.lines[1] || '').length * 6) + 24));
    t.h = t.lines.length > 1 ? 36 : 24;
    place(t, t.kind + '|' + t.key);
    all.push(t); byId[t.id] = t;
  });
  (graph.ghosts || []).forEach(function(x){
    var t = { id: x.id, kind: 'Ghost', key: x.part + '|' + x.on, s: '', group: '', on: x.on, title: x.label, sub: '' };
    t.lines = wrap(x.label, 30, 3);
    t.w = Math.min(230, Math.max.apply(null, t.lines.map(function(l){ return l.length * 6.3; })) + 22);
    t.h = 12 + t.lines.length * 13;
    place(t, 'Ghost|' + t.key);
    all.push(t); byId[t.id] = t;
  });
  var edges = graph.edges.map(function(e){ return { kind: e.kind, from: e.from, to: e.to, checks: e.checks || [] }; });
  (graph.ghosts || []).forEach(function(x){ edges.push({ kind: 'Ghost', from: x.on, to: x.id, checks: [] }); });
 
  // What is drawn: every thing but those behind a "+N weitere" not yet opened, and the
  // Servers nothing leads to unless the Technician asks for them (the toggle).
  var opened = {}, loneShown = false, lone = {};
  function shown(t){ return (t.kind === 'More' ? !opened[t.id] : (!t.inside || opened[t.inside])) && (loneShown || !lone[t.id]); }
  function visible(){ return all.filter(shown); }
  function joining(list){
    var on = {};
    list.forEach(function(t){ on[t.id] = 1; });
    return edges.filter(function(e){ return on[e.from] && on[e.to]; });
  }
 
  // What no edge joins: drawn apart, on no ring. The Target Machine is always the graph's
  // centre.
  function lonely(list){
    var has = {};
    joining(list).forEach(function(e){ has[e.from] = 1; has[e.to] = 1; });
    return list.filter(function(t){ return !has[t.id] && t.kind !== 'Machine'; });
  }
  function joined(list){
    var lone = lonely(list);
    return list.filter(function(t){ return lone.indexOf(t) < 0; });
  }
  // The rings, from the inside out. A ring nothing stands on is left out. A "+N weitere"
  // stands on the ring of what it stands for, a ghost one ring out from what it would hang
  // on.
  var RINGS = { Machine: 0, App: 1, Drive: 1, WindowsService: 2, Namespace: 3, Share: 4, Service: 5, Server: 6 }, LAST = 6;
  function ringOf(t){
    if (RINGS.hasOwnProperty(t.kind)) return RINGS[t.kind];
    if (t.kind === 'More') { var r = byId[t.rest[0]]; return r ? ringOf(r) : LAST; }
    if (t.kind === 'Ghost') { var on = byId[t.on]; return Math.min(LAST, (on ? ringOf(on) : 0) + 1); }
    return LAST;
  }
  // Where each thing stands: at its angle on its ring. The order of the angles comes from a
  // tree out from the Target Machine along the chain a thing hangs in - machine, App,
  // Service, Server; drive, Namespace, share, Service, Server - by the Links that make that
  // chain first, and only then by the others (a DNS server, a controller), so that a share
  // stands by its Server and not by the Domain. Neighbours in the order that crosses fewest
  // Links. Each thing has a share of its parent's arc as large as the leaves that hang on it,
  // and stands in its middle; then the angles are laid anew from the outside in, in that
  // order (outsideIn). What the machine does not reach hangs on it all the same. Each ring is an
  // ellipse - things are wider than they are tall - and as large as it has to be for the
  // things on it to have room beside each other, outside the ring before it.
  var CHAIN = { HasApp: 1, HasService: 1, HasDrive: 1, OnServer: 1, OfferedBy: 1, LeadsTo: 1, Uses: 1, MachineUses: 1, UsesDns: 1, UsesDhcp: 1, More: 1, Ghost: 1 };
  var rings = [];
  function seed(list){
    rings = [];
    if (!list.length) return;
    var chain = {}, any = {}, kids = {}, depth = {}, seen = {}, size = {};
    function rank(id){ return hash(byId[id].kind + '|' + byId[id].key); }
    list.forEach(function(t){ chain[t.id] = []; any[t.id] = []; });
    joining(list).forEach(function(e){
      any[e.from].push(e.to); any[e.to].push(e.from);
      if (CHAIN[e.kind]) { chain[e.from].push(e.to); chain[e.to].push(e.from); }
    });
    [chain, any].forEach(function(adj){ Object.keys(adj).forEach(function(id){ adj[id].sort(function(a, b){ return rank(a) - rank(b); }); }); });
    function walk(queue, adj){
      while (queue.length) {
        var id = queue.shift();
        adj[id].forEach(function(n){ if (seen[n]) return; seen[n] = 1; depth[n] = depth[id] + 1; kids[n] = []; kids[id].push(n); queue.push(n); });
      }
    }
    function grow(root, level){
      seen[root] = 1; depth[root] = level; kids[root] = [];
      var reached = [root];
      // Along the chain as far as it goes, then one step by any Link from what was reached,
      // nearest first, and along the chain again from there, until nothing more is reached.
      for (;;) {
        walk(reached.slice(), chain);
        reached = Object.keys(seen).sort(function(a, b){ return depth[a] - depth[b]; });
        var more = [];
        reached.forEach(function(id){ any[id].forEach(function(n){ if (seen[n]) return; seen[n] = 1; depth[n] = depth[id] + 1; kids[n] = []; kids[id].push(n); more.push(n); }); });
        if (!more.length) break;
        reached = more;
      }
    }
    var top = any.machine ? 'machine' : list[0].id;
    grow(top, 0);
    list.forEach(function(t){ if (!seen[t.id]) { kids[top].push(t.id); grow(t.id, 1); } });
    function leaves(id){ var n = 0; kids[id].forEach(function(k){ n += leaves(k); }); size[id] = Math.max(1, n); return size[id]; }
    leaves(top);
    var angle = {}, span = {};
    function lay(id, a0, a1){
      angle[id] = (a0 + a1) / 2; span[id] = a1 - a0;
      var at = a0;
      kids[id].forEach(function(k){ var part = (a1 - a0) * size[k] / size[id]; lay(k, at, at + part); at += part; });
    }
    // While the order is sought, a Server stands by its Services: where it is offered by more
    // than one, in their middle and not in the share of the one the tree hung it on, and what
    // hangs on it with it. That is the order its Server takes round the outermost ring after.
    var offers = {}, below = {};
    joining(list).forEach(function(e){ if (e.kind === 'OnServer' && byId[e.from].kind === 'Service') (offers[e.to] = offers[e.to] || []).push(e.from); });
    function branchOf(id){ if (!below[id]) { below[id] = {}; below[id][id] = 1; kids[id].forEach(function(k){ Object.keys(branchOf(k)).forEach(function(x){ below[id][x] = 1; }); }); } return below[id]; }
    function anchor(){
      Object.keys(offers).forEach(function(s){
        if (offers[s].length < 2 || angle[s] === undefined) return;
        var sx = 0, sy = 0;
        offers[s].forEach(function(v){ sx += Math.cos(angle[v]); sy += Math.sin(angle[v]); });
        if (Math.abs(sx) + Math.abs(sy) < 1e-9) return;
        var by = Math.atan2(sy, sx) - angle[s];
        Object.keys(branchOf(s)).forEach(function(x){ angle[x] += by; });
      });
    }
    function settle(id, a0, a1){ lay(id, a0, a1); anchor(); }
    // How many Links cross, each thing at its angle on an ellipse as far out as its ring - not
    // the rings as drawn, which tried no better: what moves the drawing off the count are the
    // boxes pushed apart after (separate). Only Links whose boxes overlap are tested, taken in
    // the order of their left edge, so a count costs far less than every pair.
    var links = joining(list);
    function crossings(){
      var p = {}, segs = [];
      list.forEach(function(t){ var r = t.id === top ? 0 : ringOf(t) + 1; p[t.id] = [Math.cos(angle[t.id]) * r * 1.6, Math.sin(angle[t.id]) * r]; });
      links.forEach(function(e){
        var a = p[e.from], b = p[e.to];
        segs.push({ e: e, a: a, b: b, x0: Math.min(a[0], b[0]), x1: Math.max(a[0], b[0]), y0: Math.min(a[1], b[1]), y1: Math.max(a[1], b[1]) });
      });
      segs.sort(function(s, u){ return s.x0 - u.x0; });
      function side(a, b, c){ var v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); return v > 1e-9 ? 1 : v < -1e-9 ? -1 : 0; }
      var n = 0;
      for (var i = 0; i < segs.length; i++) {
        var s = segs[i];
        for (var j = i + 1; j < segs.length && segs[j].x0 <= s.x1; j++) {
          var u = segs[j], e = s.e, f = u.e;
          if (u.y0 > s.y1 || u.y1 < s.y0) continue;
          if (e.from === f.from || e.from === f.to || e.to === f.from || e.to === f.to) continue;
          if (side(s.a, s.b, u.a) * side(s.a, s.b, u.b) < 0 && side(u.a, u.b, s.a) * side(u.a, u.b, s.b) < 0) n++;
        }
      }
      return n;
    }
    // From the top, clockwise.
    settle(top, -Math.PI / 2, Math.PI * 1.5);
    untangle();
    outsideIn();
 
    // Then laid out from the outside in. The Servers first, round their ring as far apart as
    // they can stand, each in a slot as wide as the Services it offers need; its Services in
    // its slot, in the order of where what uses them stands; and everything further in at the
    // middle of what it links to further out, ring by ring in to the machine (Bachmaier's
    // Cartesian barycentre). So a Service stands by its Server, and what uses it turns towards
    // it. The order of the Servers is sought by sweeps: out, each Server to the middle of what
    // uses it and its Services; in again, everything laid anew from that order. The sweeps
    // start from the order the tree found, and the order that crosses fewest Links is kept;
    // then each Server is sifted round the ring. docs/research/radial-ring-layout.md.
    function outsideIn(){
      var two = 2 * Math.PI, adj = {}, done = {}, was = {};
      list.forEach(function(t){ adj[t.id] = []; was[t.id] = angle[t.id]; });
      links.forEach(function(e){ adj[e.from].push(e.to); adj[e.to].push(e.from); });
      function from(a){ a = (a + Math.PI / 2) % two; return a < 0 ? a + two : a; }
      function turn(a, near){ while (a < near - Math.PI) a += two; while (a > near + Math.PI) a -= two; return a; }
      function mean(ids){
        var sx = 0, sy = 0;
        ids.forEach(function(v){ sx += Math.cos(angle[v]); sy += Math.sin(angle[v]); });
        return ids.length && Math.abs(sx) + Math.abs(sy) > 1e-9 ? Math.atan2(sy, sx) : null;
      }
      var order = list.filter(function(t){ return t.kind === 'Server' && t.id !== top; })
        .sort(function(a, b){ return from(was[a.id]) - from(was[b.id]); }).map(function(t){ return t.id; });
      if (!order.length) return;
      function need(s){ return Math.max(1, (offers[s] || []).length); }
      // Everything laid from the Servers in this order, as the tree left it where nothing
      // further out says otherwise.
      function inward(order){
        done = {};
        list.forEach(function(t){ angle[t.id] = was[t.id]; });
        var total = 0, at = -Math.PI / 2;
        order.forEach(function(s){ total += need(s); });
        order.forEach(function(s){
          var slot = two * need(s) / total;
          angle[s] = at + slot / 2; done[s] = 1;
          var vs = (offers[s] || []).slice().sort(function(a, b){ return turn(was[a], was[s]) - turn(was[b], was[s]); });
          vs.forEach(function(v, i){ angle[v] = at + slot * (i + 0.5) / vs.length; done[v] = 1; });
          at += slot;
        });
        // The middle of what a thing links to that already stands, further out where it can.
        function middle(id, outer){
          return mean(adj[id].filter(function(v){ return done[v] && !(outer && ringOf(byId[v]) <= ringOf(byId[id])); }));
        }
        var later = [];
        for (var k = LAST; k >= 1; k--) {
          list.forEach(function(t){
            if (done[t.id] || t.id === top || ringOf(t) !== k) return;
            var a = middle(t.id, true);
            if (a === null) later.push(t.id); else { angle[t.id] = a; done[t.id] = 1; }
          });
        }
        later.forEach(function(id){ var a = middle(id, false); if (a !== null) angle[id] = a; done[id] = 1; });
      }
      // Out: each Server to the middle of what, further in, uses it or its Services; where
      // nothing does, it keeps its place.
      function outward(order){
        var own = {}, at = {};
        order.forEach(function(s){ own[s] = 1; (offers[s] || []).forEach(function(v){ own[v] = 1; }); });
        order.forEach(function(s, i){
          var users = [];
          [s].concat(offers[s] || []).forEach(function(u){ adj[u].forEach(function(v){ if (!own[v] && v !== top) users.push(v); }); });
          var a = mean(users);
          at[s] = [a === null ? angle[s] : turn(a, angle[s]), i];
        });
        return order.slice().sort(function(a, b){ return from(at[a][0]) - from(at[b][0]) || at[a][1] - at[b][1]; });
      }
      var best = order, fewest = Infinity, seen = {};
      for (var pass = 0; pass < 12; pass++) {
        inward(order);
        var now = crossings();
        if (now < fewest) { fewest = now; best = order; }
        if (!fewest) break;
        order = outward(order);
        if (seen[order.join('|')]) break;
        seen[order.join('|')] = 1;
      }
      // Then each Server, with its Services, tried at every place round the ring and left where
      // fewest Links cross (sifting), while that stays quick: every try lays every thing anew
      // and counts again, so a very large Map keeps the order of the sweeps.
      order = best.slice();
      if (fewest && order.length > 2 && order.length * order.length * (list.length + links.length) <= 3e6) {
        for (var round = 0, better = true; better && round < 3; round++) {
          better = false;
          best.forEach(function(s){
            if (!fewest) return;
            var rest = order.filter(function(x){ return x !== s; }), place = -1;
            for (var i = 0; i < rest.length; i++) {
              var o = rest.slice(0, i).concat([s], rest.slice(i));
              inward(o);
              var c = crossings();
              if (c < fewest) { fewest = c; place = i; }
            }
            if (place >= 0) { order = rest.slice(0, place).concat([s], rest.slice(place)); better = true; }
          });
        }
      }
      inward(order);
    }
    // The order of neighbours that crosses fewest Links. The tree alone crosses nothing; what
    // crosses are the Links it does not follow - a Server two Apps use, a DNS server, the
    // machine's own Services - and those that skip a ring. So each thing's neighbours are put
    // in the order of where, round the circle, what they link to outside their own branch
    // stands (the barycentre), from the machine outwards, a few times over; the order that costs
    // least - crossings, and Services far round from their Servers - is kept, the hash's own first
    // where none does better.
    function untangle(){
      var up = {}, inside = {}, best = null, fewest = Infinity;
      Object.keys(kids).forEach(function(id){ kids[id].forEach(function(k){ up[k] = id; }); });
      function branch(id, into){ into[id] = 1; kids[id].forEach(function(k){ branch(k, into); }); return into; }
      Object.keys(kids).forEach(function(id){ inside[id] = branch(id, {}); });
      // Where on the circle what a branch links to stands, near the branch's own angle; its
      // own angle where it links to nothing beyond its parent.
      function pull(id){
        var own = inside[id], sx = 0, sy = 0, n = 0;
        Object.keys(own).forEach(function(u){
          any[u].forEach(function(v){
            if (own[v] || v === up[id] || angle[v] === undefined) return;
            sx += Math.cos(angle[v]); sy += Math.sin(angle[v]); n++;
          });
        });
        if (!n) return angle[id];
        var a = Math.atan2(sy, sx);
        while (a < angle[id] - Math.PI) a += 2 * Math.PI;
        while (a > angle[id] + Math.PI) a -= 2 * Math.PI;
        return a;
      }
      // What an order costs: each pair of Links that cross, each thing at its angle on a circle
      // as far out as its ring; and each Service as far round the circle from its Server as a
      // few crossings, the more the farther - one across the circle from its Server is worse
      // than a crossing or two.
      function cost(){
        var gap = 0;
        Object.keys(offers).forEach(function(s){
          offers[s].forEach(function(v){ var g = Math.abs(angle[v] - angle[s]) % (2 * Math.PI); gap += Math.min(g, 2 * Math.PI - g) / Math.PI; });
        });
        return crossings() + 4 * gap;
      }
 
      for (var pass = 0; pass < 8; pass++) {
        var now = cost();
        if (now < fewest) { fewest = now; best = {}; Object.keys(kids).forEach(function(id){ best[id] = kids[id].slice(); }); }
        if (!fewest) break;
        // From the machine outwards, so that each branch is ordered by where its parent now stands.
        var queue = [top];
        while (queue.length) {
          var id = queue.shift(), at = {};
          kids[id].forEach(function(k, i){ at[k] = [pull(k), i]; });
          kids[id].sort(function(a, b){ return at[a][0] - at[b][0] || at[a][1] - at[b][1]; });
          settle(id, angle[id] - span[id] / 2, angle[id] + span[id] / 2);
          queue = queue.concat(kids[id]);
        }
      }
      Object.keys(best).forEach(function(id){ kids[id] = best[id]; });
      settle(top, -Math.PI / 2, Math.PI * 1.5);
      // Then two neighbours swapped wherever that crosses fewer, until no swap does - as long
      // as counting stays quick: a large Map keeps the order of the barycentres.
      if (!fewest || links.length > 400) return;
      for (var round = 0, better = true; better && round < 4; round++) {
        better = false;
        Object.keys(kids).forEach(function(id){
          var ks = kids[id];
          for (var i = 0; i + 1 < ks.length && fewest; i++) {
            var x = ks[i]; ks[i] = ks[i + 1]; ks[i + 1] = x;
            settle(id, angle[id] - span[id] / 2, angle[id] + span[id] / 2);
            var c = cost();
            if (c < fewest) { fewest = c; better = true; continue; }
            ks[i + 1] = ks[i]; ks[i] = x;
            settle(id, angle[id] - span[id] / 2, angle[id] + span[id] / 2);
          }
        });
      }
    }
 
    // The rings by what stands on them, each outside the one before and with room along it
    // for every thing in its share of the circle.
    var on = {}, centre = byId[top];
    list.forEach(function(t){ if (t.id !== top) (on[ringOf(t)] = on[ringOf(t)] || []).push(t); });
    // A ring steps out from the one before by what an average thing on either needs - the
    // width across, the height up and down - and grows where the room all its things need
    // along it is more than it has. Where a few stand close, they are pushed apart after
    // (separate); a ring as large as its most crowded corner would make every thing small.
    var prev = { rx: 0, ry: 0, w: centre.w, h: centre.h }, radius = {};
    function perimeter(a, b){ return Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b))); }
    Object.keys(on).map(Number).sort(function(a, b){ return a - b; }).forEach(function(k){
      var ts = on[k], aw = 0, ah = 0, need = 0;
      ts.forEach(function(t){
        aw += t.w / ts.length; ah += t.h / ts.length;
        var s = Math.abs(Math.sin(angle[t.id])), c = Math.abs(Math.cos(angle[t.id]));
        need += c * (t.h + 12) + s * (t.w + 16);
      });
      var rx = prev.rx + (prev.w + aw) / 2 + 28, ry = prev.ry + (prev.h + ah) / 2 + 40;
      var scale = Math.max(1, need / perimeter(rx, ry));
      rx *= scale; ry *= scale;
      radius[k] = { rx: rx, ry: ry };
      rings.push(radius[k]);
      prev = { rx: rx, ry: ry, w: aw, h: ah };
    });
    list.forEach(function(t){
      if (t.pin || t.id === top) return;
      var q = radius[ringOf(t)];
      t.x = Math.cos(angle[t.id]) * q.rx;
      t.y = Math.sin(angle[t.id]) * q.ry;
    });
    if (!centre.pin) { centre.x = 0; centre.y = 0; }
  }
  // What no edge joins, in rows below the graph, as wide as the graph and centred under the
  // Target Machine: the worst first, then as the model listed them. Its heading says why it
  // stands apart: outside the machine's own Subnets, where all of it is, else that no Link
  // joins it.
  var apart = null;
  function stack(lone, list){
    apart = null;
    if (!lone.length) return;
    var x0 = 0, x1 = 0, y1 = 0, levels = d.levels || [];
    list.forEach(function(t){ x0 = Math.min(x0, t.x - t.w / 2); x1 = Math.max(x1, t.x + t.w / 2); y1 = Math.max(y1, t.y + t.h / 2); });
    // Below the outermost ring, too.
    rings.forEach(function(q){ x0 = Math.min(x0, -q.rx); x1 = Math.max(x1, q.rx); y1 = Math.max(y1, q.ry); });
    function worst(t){ var at = levels.indexOf(t.s); return at < 0 ? levels.length : at; }
    var order = lone.map(function(t, i){ return [t, i]; }).sort(function(a, b){ return worst(a[0]) - worst(b[0]) || a[1] - b[1]; }).map(function(x){ return x[0]; });
    var colW = Math.max.apply(null, order.map(function(t){ return t.w; })) + 14, rowH = 42;
    var half = Math.max(Math.abs(x0), Math.abs(x1), colW * 2);
    var cols = Math.max(1, Math.min(order.length, Math.floor(2 * half / colW)));
    var left = -cols * colW / 2, top = y1 + 80;
    order.forEach(function(t, i){
      if (t.pin) return;
      t.x = left + (i % cols) * colW + colW / 2;
      t.y = top + Math.floor(i / cols) * rowH;
    });
    var outside = order.every(function(t){ return t.group === 'outside'; });
    apart = { x: left, y: top - 30, text: outside ? tw.Outside : tw.Unlinked };
  }
  // Laid out anew where asked: the joined things on their rings, no two over each other,
  // what no edge joins below. A thing the Technician dragged stays where it was put.
  function arrange(list, again){
    var lone = lonely(list), rest = joined(list);
    if (again) seed(rest);
    separate(rest);
    stack(lone, rest);
  }
 
  // No two boxes over each other, where a ring is crowded: each pair apart along the shorter
  // way. The Target Machine and what was dragged stay.
  function separate(list){
    var n = list.length;
    for (var pass = 0; pass < 60; pass++) {
      var moved = false;
      for (var k = 0; k < n; k++) {
        for (var m = k + 1; m < n; m++) {
          var u = list[k], v = list[m], ox = (u.w + v.w) / 2 + 10 - Math.abs(v.x - u.x), oy = (u.h + v.h) / 2 + 8 - Math.abs(v.y - u.y);
          if (ox <= 0 || oy <= 0) continue;
          moved = true;
          var su = u.pin || u.kind === 'Machine' ? 0 : 1, sv = v.pin || v.kind === 'Machine' ? 0 : 1;
          if (!su && !sv) continue;
          var share = su + sv;
          if (ox < oy) { var sx = (v.x >= u.x ? 1 : -1) * ox / share; u.x -= sx * su; v.x += sx * sv; }
          else { var sy = (v.y >= u.y ? 1 : -1) * oy / share; u.y -= sy * su; v.y += sy * sv; }
        }
      }
      if (!moved) break;
    }
  }
 
  var sel = '', ends = {}, view = null, whole = null;
  function setView(){ svg.setAttribute('viewBox', [view.x.toFixed(1), view.y.toFixed(1), view.w.toFixed(1), view.h.toFixed(1)].join(' ')); }
  function edgeTitle(e){
    var said = tw.edge[e.kind] || e.kind;
    return e.checks.length ? tw.Seen.replace('{0}', said).replace('{1}', e.checks.join(', ')) : said;
  }
  function thingHtml(t){
    var cls = 'thing k-' + t.kind + (judged(t) ? ' ' + (t.s || 'NONE') : '') + (t.linked ? ' linked' : '') + (t.kind === 'Ghost' ? ' ghost' : '');
    var x = -t.w / 2, y = -t.h / 2, h = '<g class="' + cls + '" data-id="' + esc(t.id) + '" transform="translate(' + t.x.toFixed(1) + ',' + t.y.toFixed(1) + ')"' +
      (t.kind === 'Ghost' ? '' : ' tabindex="0" role="button" aria-label="' + esc(t.title) + '"') + '>';
    h += '<rect class="tbox" x="' + x + '" y="' + y + '" width="' + t.w + '" height="' + t.h + '" rx="' + (t.kind === 'Subnet' || t.kind === 'Domain' || t.kind === 'Site' ? t.h / 2 : 6) + '"/>';
    // What was measured now: solid, on the left; what the logs held: dashed, on the right.
    // Each in the colour of its own Findings, and left out where its source said nothing.
    if (judged(t) && t.s) h += '<rect class="stripe ' + t.s + '" x="' + x + '" y="' + y + '" width="4" height="' + t.h + '" rx="2"/>';
    if (judged(t) && t.sl) h += '<line class="logmark ' + t.sl + '" x1="' + (x + t.w - 3) + '" y1="' + (y + 2) + '" x2="' + (x + t.w - 3) + '" y2="' + (y + t.h - 2) + '"/>';
    if (t.kind === 'Ghost') {
      t.lines.forEach(function(l, i){ h += '<text x="0" y="' + (y + 16 + i * 13) + '" text-anchor="middle" font-size="11">' + esc(l) + '</text>'; });
    } else {
      h += '<text x="0" y="' + (t.lines.length > 1 ? y + 15 : 4) + '" text-anchor="middle" font-size="12" font-weight="' + (t.kind === 'More' ? 400 : 600) + '">' + esc(t.lines[0]) + '</text>';
      if (t.lines[1]) h += '<text class="dim" x="0" y="' + (y + 29) + '" text-anchor="middle" font-size="10.5">' + esc(t.lines[1]) + '</text>';
    }
    return h + '</g>';
  }
  function draw(){
    var list = visible(), es = joining(list), x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
    list.forEach(function(t){ x0 = Math.min(x0, t.x - t.w / 2); y0 = Math.min(y0, t.y - t.h / 2); x1 = Math.max(x1, t.x + t.w / 2); y1 = Math.max(y1, t.y + t.h / 2); });
    // The heading over what stands apart.
    var head = '';
    if (apart) {
      head = '<text class="zonehead" x="' + apart.x.toFixed(1) + '" y="' + apart.y.toFixed(1) + '">' + esc(String(apart.text).toUpperCase()) + '</text>';
      y0 = Math.min(y0, apart.y - 14); x1 = Math.max(x1, apart.x + apart.text.length * 8);
    }
    // The rings, faint and behind everything: what stands on one is as far from the machine.
    var guides = '';
    rings.forEach(function(q){
      guides += '<ellipse class="ring" cx="0" cy="0" rx="' + q.rx.toFixed(1) + '" ry="' + q.ry.toFixed(1) + '"/>';
      x0 = Math.min(x0, -q.rx); x1 = Math.max(x1, q.rx); y0 = Math.min(y0, -q.ry); y1 = Math.max(y1, q.ry);
    });
    // As wide on either side of the Target Machine, so that it stands at the centre.
    var half = Math.max(Math.abs(x0), Math.abs(x1)); x0 = -half; x1 = half;
    var pad = 16, W = x1 - x0 + 2 * pad, H = y1 - y0 + 2 * pad, out = guides + head;
    es.forEach(function(e, i){
      var a = byId[e.from], b = byId[e.to], at = ' x1="' + a.x.toFixed(1) + '" y1="' + a.y.toFixed(1) + '" x2="' + b.x.toFixed(1) + '" y2="' + b.y.toFixed(1) + '"';
      out += '<g class="tlink" data-e="' + i + '" tabindex="0" aria-label="' + esc(edgeTitle(e)) + '"><line class="hit"' + at + '/><line class="tedge e-' + esc(e.kind) + '"' + at + '/></g>';
    });
    list.forEach(function(t){ out += thingHtml(t); });
    whole = { x: x0 - pad, y: y0 - pad, w: W, h: H };
    view = { x: whole.x, y: whole.y, w: W, h: H };
    setView();
    svg.style.maxWidth = Math.ceil(W) + 'px';
    svg.innerHTML = out;
    ends = {};
    Array.prototype.forEach.call(svg.querySelectorAll('.tlink'), function(g){
      var e = es[+g.getAttribute('data-e')], lines = g.querySelectorAll('line');
      (ends[e.from] = ends[e.from] || []).push([lines, 1]);
      (ends[e.to] = ends[e.to] || []).push([lines, 2]);
      g.edge = e;
      // Hovered or focused, an edge names its kind and the Checks that observed it.
      function about(){ net.showHtml(g, '<div class="sub">' + esc(edgeTitle(e)) + '</div>'); }
      g.addEventListener('mouseenter', about); g.addEventListener('focus', about);
      g.addEventListener('mouseleave', net.hideDetail); g.addEventListener('blur', net.hideDetail);
    });
    Array.prototype.forEach.call(svg.querySelectorAll('.thing'), wire);
    mark(sel && byId[sel] && shown(byId[sel]) ? sel : '');
  }
 
  // A click on a thing: the whole chain it stands in, the rest faded - inwards ring by ring
  // to the machine, and outwards ring by ring from it, along the edges that lead there. A
  // mapped drive lights the machine, its share, the share's SMB Service and the file server;
  // a Server its Services, what uses them and the machine. A second click, Escape or a click
  // on empty space: the whole picture again.
  function chain(id){
    var near = {}, along = [], links = joining(visible());
    near[id] = 1;
    [-1, 1].forEach(function(way){
      var seen = {}, queue = [id];
      seen[id] = 1;
      while (queue.length) {
        var at = queue.shift(), ring = ringOf(byId[at]);
        links.forEach(function(e){
          var other = e.from === at ? e.to : e.to === at ? e.from : null;
          if (!other || seen[other] || (ringOf(byId[other]) - ring) * way <= 0) return;
          seen[other] = 1; near[other] = 1; along.push(e); queue.push(other);
        });
      }
    });
    return { near: near, along: along };
  }
  function mark(id){
    sel = id;
    var lit = id ? chain(id) : { near: {}, along: [] };
    svg.classList.toggle('focus', !!id);
    Array.prototype.forEach.call(svg.querySelectorAll('.thing'), function(g){ var k = g.getAttribute('data-id'); g.classList.toggle('on', !!lit.near[k]); g.classList.toggle('sel', k === id); });
    Array.prototype.forEach.call(svg.querySelectorAll('.tlink'), function(g){ g.classList.toggle('on', lit.along.indexOf(g.edge) >= 0); });
  }
  // A "+N weitere" opened: what it stands for on its ring, and the rings laid out anew
  // around it.
  function open(t){
    opened[t.id] = true;
    arrange(visible(), true);
    draw();
  }
  function point(e){
    var p = svg.createSVGPoint(), m = svg.getScreenCTM();
    p.x = e.clientX; p.y = e.clientY;
    return m ? p.matrixTransform(m.inverse()) : p;
  }
  var drag = null;
  function wire(g){
    var t = byId[g.getAttribute('data-id')];
    if (!t) return;
    function about(){
      if (t.node) net.showDetail(g, t.node);
      else if (t.kind !== 'Ghost') net.showHtml(g, '<h4>' + esc(t.title) + '</h4>' + (t.sub ? '<div class="sub">' + esc(t.sub) + '</div>' : ''));
    }
    function go(){ var f = t.node ? net.findings(t.node)[0] : null; if (f) net.jump(f.a); }
    g.addEventListener('mouseenter', about); g.addEventListener('focus', about);
    g.addEventListener('mouseleave', net.hideDetail); g.addEventListener('blur', net.hideDetail);
    g.addEventListener('click', function(e){
      e.stopPropagation();
      if (drag && drag.moved) return;
      if (t.kind === 'More') open(t); else mark(sel === t.id ? '' : t.id);
    });
    g.addEventListener('keydown', function(e){
      if (e.key === 'Enter') { e.preventDefault(); if (t.kind === 'More') open(t); else go(); }
      else if (e.key === ' ') { e.preventDefault(); mark(sel === t.id ? '' : t.id); }
    });
    // Dragged, it stays where it is put. The press is the drag's and not the page's: left
    // to the browser it starts a text selection that follows the pointer across the whole
    // Report, and that is what made a drag crawl.
    g.addEventListener('pointerdown', function(e){
      if (e.button !== 0) return;
      e.preventDefault();
      var p = point(e);
      drag = { t: t, g: g, dx: t.x - p.x, dy: t.y - p.y, x: e.clientX, y: e.clientY, moved: false, m: svg.getScreenCTM() };
      if (g.setPointerCapture) { try { g.setPointerCapture(e.pointerId); } catch (x) { } }
    });
  }
  // What a move asks for is drawn once a frame, however many moves the pointer reports.
  var pending = null;
  function later(fn){
    if (!pending) requestAnimationFrame(function(){ var f = pending; pending = null; if (f) f(); });
    pending = fn;
  }
  window.addEventListener('pointermove', function(e){
    if (!drag) return;
    if (!drag.moved && Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) < 4) return;
    if (!drag.moved) { drag.moved = true; net.hideDetail(); }
    var cx = e.clientX, cy = e.clientY, now = drag;
    later(function(){
      var p = svg.createSVGPoint(), t = now.t;
      p.x = cx; p.y = cy;
      if (now.m) p = p.matrixTransform(now.m.inverse());
      t.x = p.x + now.dx; t.y = p.y + now.dy; t.pin = true;
      now.g.setAttribute('transform', 'translate(' + t.x.toFixed(1) + ',' + t.y.toFixed(1) + ')');
      (ends[t.id] || []).forEach(function(end){
        Array.prototype.forEach.call(end[0], function(l){ l.setAttribute('x' + end[1], t.x.toFixed(1)); l.setAttribute('y' + end[1], t.y.toFixed(1)); });
      });
    });
  });
  window.addEventListener('pointerup', function(){ if (drag) setTimeout(function(){ drag = null; }, 0); });
  // Dragged on empty space, the whole picture moves under the pointer: by how far the
  // pointer went, at the scale the picture had when the press began.
  var pan = null;
  svg.addEventListener('pointerdown', function(e){
    if (e.button !== 0 || !view || (e.target.closest && e.target.closest('.thing,.tlink'))) return;
    e.preventDefault();
    var r = svg.getBoundingClientRect();
    pan = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false, id: e.pointerId, k: r.width ? view.w / r.width : 1 };
  });
  window.addEventListener('pointermove', function(e){
    if (!pan) return;
    var dx = e.clientX - pan.x, dy = e.clientY - pan.y;
    if (!pan.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
    if (!pan.moved) { pan.moved = true; svg.classList.add('panning'); net.hideDetail(); try { svg.setPointerCapture(pan.id); } catch (x) { } }
    var now = pan;
    later(function(){ view.x = now.vx - dx * now.k; view.y = now.vy - dy * now.k; setView(); });
  });
  window.addEventListener('pointerup', function(){
    if (!pan) return;
    var was = pan; pan = null; svg.classList.remove('panning');
    if (was.moved) { panned = true; setTimeout(function(){ panned = false; }, 0); }
  });
  var panned = false;
  // The wheel zooms the picture about the pointer, in to a twelfth of the whole and out to
  // twice it; + and - about the middle, from the keyboard. A double click on empty space, or
  // 0, shows the whole again. At either end the wheel is the page's again, so that the
  // Report scrolls on past the picture, and so is a wheel that turns sideways. Not while a
  // drag or a pan holds the picture: they move it at the scale it had when they began.
  function zoom(f, p){
    if (!view || !whole || pan || drag) return false;
    var w = Math.min(whole.w * 2, Math.max(whole.w / 12, view.w * f));
    f = w / view.w;
    if (Math.abs(f - 1) < 1e-6) return false;
    p = p || { x: view.x + view.w / 2, y: view.y + view.h / 2 };
    view.x = p.x - (p.x - view.x) * f; view.y = p.y - (p.y - view.y) * f; view.w *= f; view.h *= f;
    net.hideDetail();
    // At once and not in the next frame: the next wheel turn reads where it points from it.
    setView();
    return true;
  }
  svg.addEventListener('wheel', function(e){
    if (!e.deltaY) return;
    var by = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
    if (zoom(Math.exp(by * 0.0015), point(e))) e.preventDefault();
  }, { passive: false });
  function showWhole(){ if (!whole) return; view = { x: whole.x, y: whole.y, w: whole.w, h: whole.h }; setView(); }
  svg.addEventListener('dblclick', function(e){ if (!(e.target.closest && e.target.closest('.thing,.tlink'))) showWhole(); });
  svg.addEventListener('keydown', function(e){
    if (e.key === '+' || e.key === '=') { e.preventDefault(); zoom(1 / 1.25); }
    else if (e.key === '-') { e.preventDefault(); zoom(1.25); }
    else if (e.key === '0') { e.preventDefault(); showWhole(); }
  });
  svg.addEventListener('click', function(){ if (sel && !panned) mark(''); });
  // Escape undoes the highlight first, and only then closes the full view: on the window, in
  // the capture phase, before the shared script's own listener on the document.
  window.addEventListener('keydown', function(e){ if (e.key === 'Escape' && sel) { e.stopImmediatePropagation(); mark(''); } }, true);
 
  lonely(visible()).forEach(function(t){ lone[t.id] = 1; });
  arrange(visible(), true);
  draw();
  // The toggle of the Servers nothing leads to: how many, and shown or hidden.
  var toggle = root.querySelector('.appslone'), loneCount = Object.keys(lone).length;
  function sayToggle(){ toggle.textContent = (loneShown ? tw.HideLone : tw.ShowLone).replace('{0}', loneCount); }
  if (toggle && loneCount) {
    toggle.hidden = false; sayToggle();
    toggle.addEventListener('click', function(){ loneShown = !loneShown; sayToggle(); arrange(visible(), false); draw(); });
  }
});
</script>
'@


function Get-ReportNetworkPortName {
    <#
    .SYNOPSIS
        What the service on a port is called by the port's registration (IANA), or nothing
        for a port with no name of its own here. Pure.
    .DESCRIPTION
        A Service is known by its Server and port, and fs01:445 tells a Technician less than
        that it is SMB. Only ports whose name is registered and that one service uses as a
        rule are named; one an application chose for itself - 9100 is a printer and SwyxIt!
        alike - stays a number. A literal Get-Text per port and no computed key, so that the
        tests can hold every key to being defined and to being used.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Port)

    switch ("$Port".Trim()) {
        '21' { return Get-Text 'Report.Network.Apps.Port.21' }
        '22' { return Get-Text 'Report.Network.Apps.Port.22' }
        '25' { return Get-Text 'Report.Network.Apps.Port.25' }
        '53' { return Get-Text 'Report.Network.Apps.Port.53' }
        '67' { return Get-Text 'Report.Network.Apps.Port.67' }
        '80' { return Get-Text 'Report.Network.Apps.Port.80' }
        '88' { return Get-Text 'Report.Network.Apps.Port.88' }
        '110' { return Get-Text 'Report.Network.Apps.Port.110' }
        '123' { return Get-Text 'Report.Network.Apps.Port.123' }
        '135' { return Get-Text 'Report.Network.Apps.Port.135' }
        '143' { return Get-Text 'Report.Network.Apps.Port.143' }
        '389' { return Get-Text 'Report.Network.Apps.Port.389' }
        '443' { return Get-Text 'Report.Network.Apps.Port.443' }
        '445' { return Get-Text 'Report.Network.Apps.Port.445' }
        '465' { return Get-Text 'Report.Network.Apps.Port.465' }
        '587' { return Get-Text 'Report.Network.Apps.Port.587' }
        '636' { return Get-Text 'Report.Network.Apps.Port.636' }
        '993' { return Get-Text 'Report.Network.Apps.Port.993' }
        '995' { return Get-Text 'Report.Network.Apps.Port.995' }
        '1433' { return Get-Text 'Report.Network.Apps.Port.1433' }
        '1494' { return Get-Text 'Report.Network.Apps.Port.1494' }
        '2598' { return Get-Text 'Report.Network.Apps.Port.2598' }
        '3268' { return Get-Text 'Report.Network.Apps.Port.3268' }
        '3269' { return Get-Text 'Report.Network.Apps.Port.3269' }
        '3389' { return Get-Text 'Report.Network.Apps.Port.3389' }
        '5060' { return Get-Text 'Report.Network.Apps.Port.5060' }
        '5061' { return Get-Text 'Report.Network.Apps.Port.5061' }
        '5985' { return Get-Text 'Report.Network.Apps.Port.5985' }
        '5986' { return Get-Text 'Report.Network.Apps.Port.5986' }
    }
    ''
}

function Get-ReportNetworkApps {
    <#
    .SYNOPSIS
        The whole Network Map as the graph Apps & Services draws: its things, its edges and its
        ghosts. Pure.
    .DESCRIPTION
        Read off the Map as it is (ADR-0012, ADR-0014): nothing is merged or assumed here.
 
        - things: the Target Machine (id "machine"), every Server, Namespace, Share, Service,
          Drive, App and Windows service of the Map - not its adapters, Subnets, Domains and
          Sites, which stay on the Map - id, kind, key (the Map's) and
          label. A Server's id is that of its node in the chapter, so that the detail beside
          it, its Severity and its Findings are the node's, as on Routes; with group
          ("outside" for one in none of the Target Machine's own Subnets, where the Map knows
          any) and linked (only Links name it, no Check measured it).
        - name: of a Service with no URL whose port has one (Get-ReportNetworkPortName),
          which it is drawn by, its Key under it.
        - s and sl: the Severity of what was measured of a thing now, and apart of what the
          logs held of it over the 30 days - a Server by its Measurements and Timed Events, a
          Service by those of its own (the model's service), each in the colour of the part of
          its Finding about that Service where there is one (ADR-0015), a Windows service by the
          Finding about it (its Subject) and its crashes; a Drive its Finding's as s; a
          Namespace the worst of the parts that name it as s, and no sl: it logs nothing of
          its own (#172) - and empty on every other thing and on every edge.
        - nodes: the detail of each Service, Namespace and Windows service, in the shape the
          shared panel reads (id, label, m and e the indexes of its records, f its Findings:
          of a Service or a Namespace those that name it, of a Windows service the one about
          it), so that it opens beside any of them as beside a Server.
        - crowding: at most $script:ReportNetworkAppsCrowd Shares and as many Services of
          one Server; those a Link leads to first, then in the Map's order. The rest have in,
          the id of a thing of kind More - label "+N weitere", rest the ids of what it stands
          for - joined to the Server by an edge of kind More. And at most as many Services
          of one App, those not in order first; the rest, with each Server all of whose
          things are among them, behind a More joined to the App.
        - edges: one per kind of Link and pair of things drawn, with checks, the Checks that
          observed it, each once: the same Link from two Checks is one edge naming both, two
          Links that disagree two edges. A gateway, DNS or DHCP server an adapter uses, the
          Target Machine uses. And one of kind HasApp from the Target Machine to each App,
          whose Checks ran on it, and of kind HasDrive to each mapped drive.
          A DNS or DHCP server the machine uses is used through its Service, on 53 or 67;
          and a Service nothing else reaches, a Check of this machine measured or logged,
          has an edge of kind MachineUses from the Target Machine. Nothing reaches a Server
          but through a Service, save the gateway, which routes.
        - ghosts: what no Check that ran could have observed, in the words of
          Get-ReportNetworkUnknown - part, label, and on, the id of the thing it would hang on:
          the mapped drives and the applications' Servers on the Target Machine, a Namespace's
          further targets on each Namespace. Of what it does not draw, none.
    .PARAMETER Node
        The model's nodes (Get-ReportNetworkModel): id, key, label, place and s.
    .PARAMETER Row
        The model's Measurements, each with its key and Finding.
    .PARAMETER Named
        The model's Findings by Id, as Get-ReportNetworkSeverity reads them.
    .PARAMETER Naming
        The Ids of the Findings that name each Service and each Namespace of the Map, by
        its Key: its detail lists them.
    .PARAMETER Finding
        Every Finding of the Report: a mapped drive's need not name a Measurement.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [AllowNull()]$Map,
        [AllowNull()][AllowEmptyCollection()]$Node,
        [AllowNull()][AllowEmptyCollection()]$Row,
        [AllowNull()][AllowEmptyCollection()]$Timed,
        [AllowNull()]$Named,
        [AllowNull()][hashtable]$Naming,
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [string]$ComputerName = ''
    )

    $order = @{}
    for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i }
    $worst = {
        param([string[]]$Severity)
        $found = ''
        foreach ($s in @($Severity | Where-Object { $_ -and $order.ContainsKey($_) })) { if (-not $found -or $order[$s] -lt $order[$found]) { $found = $s } }
        $found
    }

    # Every thing once, by its kind and Key, in the order of the Map.
    $things = New-Object System.Collections.Generic.List[object]
    $idOf = @{}
    $count = @{ t = 0; more = 0; u = 0 }
    $add = {
        param([string]$Kind, [string]$Key, [string]$Label, [string]$Id = '', [string]$Severity = '')
        $lookup = '{0}|{1}' -f $Kind, $Key
        if ($idOf.ContainsKey($lookup)) { return }
        if (-not $Id) { $count.t++; $Id = 't{0}' -f $count.t }
        $thing = [ordered]@{ id = $Id; kind = $Kind; key = $Key; label = $Label; s = $Severity }
        $idOf[$lookup] = $Id
        $things.Add($thing)
        $thing
    }
    $find = { param($End) $lookup = '{0}|{1}' -f $End.Thing, $End.Key; if ($idOf.ContainsKey($lookup)) { $idOf[$lookup] } else { '' } }

    $null = & $add 'Machine' 'machine' $ComputerName 'machine'
    # The machine's Subnets are not drawn; they say only which Servers stand outside them.
    $subnets = @((Get-DataCollection $Map 'Subnets') | Where-Object { $_ })

    $mapServer = @{}
    foreach ($server in @((Get-DataCollection $Map 'Servers') | Where-Object { $_ })) { $mapServer["$($server.Key)"] = $server }
    foreach ($n in @($Node | Where-Object { $_ -and $_.place -ne 'machine' })) {
        $server = $mapServer["$($n.key)"]
        # Drawn by what was measured of it now (sm) and, apart, by what the logs held (sl).
        $thing = & $add 'Server' "$($n.key)" "$($n.label)" "$($n.id)" "$($n.sm)"
        if (-not $thing) { continue }
        $thing.sl = "$($n.sl)"
        # Apart, under its heading, where the Map knows the machine's own Subnets and the
        # Server is in none of them. No range is assumed around its address.
        $thing.group = $(if ($subnets.Count -and $server -and -not @($server.Subnets | Where-Object { $_ }).Count) { 'outside' } else { '' })
        $thing.linked = "$($n.place)" -eq 'linked'
    }
    $namespaces = @(foreach ($x in @((Get-DataCollection $Map 'Namespaces') | Where-Object { $_ })) { & $add 'Namespace' "$($x.Key)" "$($x.Key)" })
    foreach ($x in @((Get-DataCollection $Map 'Shares') | Where-Object { $_ })) { $null = & $add 'Share' "$($x.Key)" "$($x.Key)" }
    # Each record of a Service, by the rule the Map put it there by (Get-NetworkMapServiceKey):
    # its Measurements are what was measured of it now, its Timed Events what the logs held.
    $rows = @($Row | Where-Object { $_ })
    $events = @($Timed | Where-Object { $_ })
    $indexesOf = @{}
    $note = {
        param([string]$Key, [string]$Which, [int]$Index)
        if (-not $Key) { return }
        if (-not $indexesOf.ContainsKey($Key)) { $indexesOf[$Key] = @{ m = New-Object System.Collections.Generic.List[int]; e = New-Object System.Collections.Generic.List[int] } }
        $indexesOf[$Key][$Which].Add($Index)
    }
    # The model gave each its Service as service, where it names one.
    for ($i = 0; $i -lt $rows.Count; $i++) { & $note "$($rows[$i]['service'])" 'm' $i }
    for ($i = 0; $i -lt $events.Count; $i++) { & $note "$($events[$i]['service'])" 'e' $i }
    $severityOf = { param($Records) & $worst @($Records | ForEach-Object { Get-ReportNetworkSeverity -Record $_ -Named $Named }) }
    $details = New-Object System.Collections.Generic.List[object]
    $detail = {
        param($Thing, [int[]]$M, [int[]]$E, [string[]]$F)
        $details.Add([ordered]@{
            id = $Thing.id; key = $Thing.key; label = $(if ($Thing['name']) { '{0} - {1}' -f $Thing['name'], $Thing.label } else { $Thing.label })
            names = @(); addresses = @(); roles = @(); zone = ''
            place = 'thing'; s = $Thing.s; m = [int[]]@($M); e = [int[]]@($E); r = @(); f = [string[]]@($F)
        })
    }
    foreach ($x in @((Get-DataCollection $Map 'Services') | Where-Object { $_ })) {
        $key = "$($x.Key)"
        $m = [int[]]@($(if ($indexesOf.ContainsKey($key)) { $indexesOf[$key].m.ToArray() }))
        $e = [int[]]@($(if ($indexesOf.ContainsKey($key)) { $indexesOf[$key].e.ToArray() }))
        $thing = & $add 'Service' $key $(if ("$($x.Url)") { "$($x.Url)" } else { $key }) '' "$(& $severityOf @($m | ForEach-Object { $rows[$_] }))"
        if (-not $thing) { continue }
        # What it is called: by its Definition, its port's name, the Apps that use it
        # (Get-ReportServiceName). Not by its port alone, which the label says already.
        # Not $named: that is $Named, the Findings this graph is coloured by.
        $called = Get-ReportServiceName -Service $x
        if ($called) { $thing.name = $called }
        $thing.sl = "$(& $severityOf @($e | ForEach-Object { $events[$_] }))"
        # And the Findings that name it, though no record of it does (ADR-0015).
        & $detail $thing $m $e @($(if ($null -ne $Naming -and $Naming.ContainsKey($key)) { $Naming[$key] }))
    }
    # A Namespace in the colour of the parts that name it, the worst of them (#172): it is
    # judged by its folders, and nothing is measured or logged of it. Its detail lists those
    # Findings; one that names only the Servers behind it leaves it as it is.
    foreach ($thing in $namespaces) {
        $key = "$($thing.key)"
        $ids = @($(if ($null -ne $Naming -and $Naming.ContainsKey($key)) { $Naming[$key] }))
        $thing.s = "$(& $worst @($ids | ForEach-Object { $Named[$_].subjects } | Where-Object { "$($_.key)" -ceq $key } | ForEach-Object { "$($_.s)" }))"
        & $detail $thing @() @() $ids
    }
    # A Drive in the colour of its Finding, found by its Subject as the Map keys a Drive.
    $findings = @($Finding | Where-Object { $_ })
    foreach ($x in @((Get-DataCollection $Map 'Drives') | Where-Object { $_ })) {
        $key = "$($x.Key)"
        $severity = & $worst @($findings | Where-Object { "$(Get-DataProperty $_ 'Subject')" -eq $key } | ForEach-Object { "$(Get-DataProperty $_ 'Severity')" })
        $null = & $add 'Drive' $key $key '' "$severity"
    }
    # An App, by its name: joined to what it uses by the Links Uses, as every other thing.
    foreach ($x in @((Get-DataCollection $Map 'Apps') | Where-Object { $_ })) { $null = & $add 'App' "$($x.Key)" "$($x.Key)" }
    # A Windows service on the Target Machine: its state is the Finding about it, found by its
    # service name as its Subject; what the logs held of it, its crashes.
    foreach ($x in @((Get-DataCollection $Map 'WindowsServices') | Where-Object { $_ })) {
        $key = "$($x.Key)"
        $about = @($findings | Where-Object { "$(Get-DataProperty $_ 'Subject')".Trim().ToLowerInvariant() -eq $key })
        $e = [int[]]@(for ($i = 0; $i -lt $events.Count; $i++) { if ("$($events[$i].Lane)" -eq 'Marker' -and "$($events[$i].Subject)".Trim().ToLowerInvariant() -eq $key) { $i } })
        $thing = & $add 'WindowsService' $key "$($x.Name)" '' "$(& $worst @($about | ForEach-Object { "$(Get-DataProperty $_ 'Severity')" }))"
        if (-not $thing) { continue }
        $thing.sl = "$(& $severityOf @($e | ForEach-Object { $events[$_] }))"
        & $detail $thing @() $e @($about | ForEach-Object { "$(Get-DataProperty $_ 'Id')" })
    }

    # Every Link as an edge, one per kind and pair of things, naming each Check that saw it.
    $edges = [ordered]@{}
    $join = {
        param([string]$Kind, [string]$From, [string]$To, [string]$Check)
        if (-not $From -or -not $To) { return }
        $key = '{0}|{1}|{2}' -f $Kind, $From, $To
        if (-not $edges.Contains($key)) { $edges[$key] = [ordered]@{ kind = $Kind; from = $From; to = $To; checks = (New-Object System.Collections.Generic.List[string]) } }
        if ($Check -and -not $edges[$key].checks.Contains($Check)) { $edges[$key].checks.Add($Check) }
    }
    $links = @((Get-DataCollection $Map 'Links') | Where-Object { $_ })
    # A share is offered through its Server's SMB Service (the Map's Share.Service): its Link
    # to the Server is drawn to that Service, which lies on the Server.
    $smbOf = @{}
    foreach ($x in @((Get-DataCollection $Map 'Shares') | Where-Object { $_ })) { if ("$($x.Service)") { $smbOf["$($x.Key)"] = "$($x.Service)" } }
    foreach ($link in $links) {
        # What an adapter uses, the machine uses: the adapter is not drawn. A Link between
        # things this view does not draw draws nothing.
        $kind = "$($link.Kind)"
        $from = $(if ("$($link.From.Thing)" -eq 'Adapter' -and $kind -in 'UsesGateway', 'UsesDns', 'UsesDhcp') { 'machine' } else { & $find $link.From })
        $to = & $find $link.To
        if ($kind -eq 'OnServer' -and "$($link.From.Thing)" -eq 'Share' -and $smbOf.ContainsKey("$($link.From.Key)")) {
            $kind = 'OfferedBy'
            $to = $idOf["Service|$($smbOf["$($link.From.Key)"])"]
        }
        # A DNS or DHCP server is used through its Service (the Map's, on 53 and 67), which
        # lies on the Server: the machine asks the service, not the Server.
        if ($script:NetworkMapUsedPorts.ContainsKey($kind) -and "$($link.To.Thing)" -eq 'Server') {
            $through = $idOf['Service|{0}' -f (Get-NetworkMapServiceKey -ServerKey "$($link.To.Key)" -Port $script:NetworkMapUsedPorts[$kind])]
            if ($through) { $to = $through }
        }
        & $join $kind $from $to "$($link.CheckName)"
    }
    # Each Service on its Server, where no Link says so: a record measured or logged it there.
    foreach ($x in @((Get-DataCollection $Map 'Services') | Where-Object { $_ })) {
        $service = $idOf["Service|$($x.Key)"]; $server = $idOf["Server|$($x.Server)"]
        if ($service -and $server -and -not @($edges.Keys | Where-Object { $_ -like "OnServer|$service|*" }).Count) { & $join 'OnServer' $service $server '' }
    }
    # Each App on the Target Machine: its Check ran there, as every Check of the Run does. And
    # each mapped drive: it is mapped there.
    foreach ($app in @($things | Where-Object { $_.kind -eq 'App' })) { & $join 'HasApp' 'machine' $app.id '' }
    foreach ($drive in @($things | Where-Object { $_.kind -eq 'Drive' })) { & $join 'HasDrive' 'machine' $drive.id '' }
    # A Service no App, share or other Link reaches the Target Machine reached itself: a Check
    # of this machine measured it or read it in this machine's logs - the domain controller's
    # LDAP and Kerberos, the SMB the logs held. The machine talks to the Service, never past
    # it to the Server.
    $reached = @{}
    foreach ($edge in $edges.Values) { if ($edge.kind -ne 'OnServer' -and $edge.kind -ne 'More') { $reached[$edge.to] = $true } }
    foreach ($service in @($things | Where-Object { $_.kind -eq 'Service' })) {
        if (-not $reached.ContainsKey($service.id)) { & $join 'MachineUses' 'machine' $service.id '' }
    }

    # At most so many Shares and Services of one Server; the rest behind a "+N weitere".
    $led = @{}
    foreach ($link in @($links | Where-Object { $_.Kind -eq 'LeadsTo' })) { $led["$($link.To.Thing)|$($link.To.Key)"] = $true }
    $byId = @{}
    foreach ($thing in $things) { $byId[$thing.id] = $thing }
    # A share hangs on the SMB Service it is offered through, a Service on its Server.
    foreach ($pair in @(@('Share', 'Shares', 'Service'), @('Service', 'Services', 'Server'))) {
        $onServer = [ordered]@{}
        foreach ($x in @((Get-DataCollection $Map $pair[1]) | Where-Object { $_ })) {
            $server = $(if ($pair[2] -eq 'Service' -and "$($x.Service)") { "$($x.Service)" } else { "$($x.Server)" })
            if (-not $onServer.Contains($server)) { $onServer[$server] = New-Object System.Collections.Generic.List[string] }
            $onServer[$server].Add("$($x.Key)")
        }
        foreach ($server in $onServer.Keys) {
            $keys = $onServer[$server].ToArray()
            $serverId = $idOf[('{0}|{1}' -f $(if ($pair[2] -eq 'Service' -and $idOf.ContainsKey("Service|$server")) { 'Service' } else { 'Server' }), $server)]
            if ($keys.Count -le $script:ReportNetworkAppsCrowd -or -not $serverId) { continue }
            $first = @($keys | Where-Object { $led.ContainsKey("$($pair[0])|$_") }) + @($keys | Where-Object { -not $led.ContainsKey("$($pair[0])|$_") })
            $rest = @($first[$script:ReportNetworkAppsCrowd..($first.Count - 1)] | ForEach-Object { $idOf["$($pair[0])|$_"] })
            $count.more++
            $moreId = 'more{0}' -f $count.more
            foreach ($id in $rest) { $byId[$id]['in'] = $moreId }
            $more = [ordered]@{ id = $moreId; kind = 'More'; key = ('{0}|{1}' -f $server, $pair[0]); label = ((Get-Text 'Report.Network.Apps.More') -f $rest.Count); s = ''; rest = [string[]]$rest }
            $things.Add($more)
            & $join 'More' $serverId $moreId ''
        }
    }

    # At most so many Services of one App; the rest behind a "+N weitere" on the App, those
    # drawn as not in order - now or in the logs - first. A Server all of whose things are
    # behind it goes behind it too: the forty servers a Remote Desktop client reached are
    # not forty boxes. A Service already behind one stays where it is.
    $rank = {
        param($Thing)
        $best = $script:SeverityValues.Count
        foreach ($s in @("$($Thing.s)", "$($Thing['sl'])")) { if ($s -and $s -ne 'OK' -and $order.ContainsKey($s) -and $order[$s] -lt $best) { $best = $order[$s] } }
        $best
    }
    $usedBy = [ordered]@{}
    foreach ($edge in @($edges.Values | Where-Object { $_.kind -eq 'Uses' })) {
        $user = $byId[$edge.from]
        $used = $byId[$edge.to]
        if (-not $user -or $user.kind -ne 'App' -or -not $used -or $used.kind -ne 'Service' -or $used['in']) { continue }
        if (-not $usedBy.Contains($edge.from)) { $usedBy[$edge.from] = New-Object System.Collections.Generic.List[string] }
        if (-not $usedBy[$edge.from].Contains($edge.to)) { $usedBy[$edge.from].Add($edge.to) }
    }
    foreach ($appId in $usedBy.Keys) {
        $ids = @($usedBy[$appId] | Where-Object { -not $byId[$_]['in'] })
        if ($ids.Count -le $script:ReportNetworkAppsCrowd) { continue }
        $first = @($ids | Sort-Object @{ Expression = { & $rank $byId[$_] } }, @{ Expression = { [array]::IndexOf($ids, $_) } })
        $rest = @($first[$script:ReportNetworkAppsCrowd..($first.Count - 1)])
        $count.more++
        $moreId = 'more{0}' -f $count.more
        foreach ($id in $rest) { $byId[$id]['in'] = $moreId }
        $servers = @(foreach ($id in $rest) { $edges.Values | Where-Object { $_.kind -eq 'OnServer' -and $_.from -eq $id } | ForEach-Object { $_.to } }) | Select-Object -Unique
        $behind = @(foreach ($serverId in @($servers | Where-Object { $byId[$_] -and -not $byId[$_]['in'] })) {
            $others = @($edges.Values | Where-Object { $_.from -eq $serverId -or $_.to -eq $serverId } |
                ForEach-Object { if ($_.from -eq $serverId) { $_.to } else { $_.from } } |
                Where-Object { -not $byId[$_] -or "$($byId[$_]['in'])" -ne $moreId })
            if ($others.Count) { continue }
            $byId[$serverId]['in'] = $moreId
            $serverId
        })
        $more = [ordered]@{ id = $moreId; kind = 'More'; key = ('{0}|Service' -f "$($byId[$appId].key)"); label = ((Get-Text 'Report.Network.Apps.More') -f $rest.Count); s = ''; rest = [string[]]@($rest + $behind) }
        $things.Add($more)
        & $join 'More' $appId $moreId ''
    }

    # Where nobody looked: a ghost where the part would be, joined to what it would hang on.
    $unknown = Get-ReportNetworkUnknown -Map $Map
    $ghosts = New-Object System.Collections.Generic.List[object]
    $haunt = {
        param([string]$Part, [string]$On)
        $count.u++
        $ghosts.Add([ordered]@{ id = 'u{0}' -f $count.u; part = $Part; label = $unknown[$Part]; on = $On })
    }
    # Only of what this view draws: the mapped drives and a Namespace's further targets. The
    # adapters, the Domain and a controller's site are the Map's; the applications' Servers
    # stand in the list under the chapter (Get-ReportNetworkGap), as the Checks they lack.
    if ($unknown['drives']) { & $haunt 'drives' 'machine' }
    if ($unknown['namespace']) { foreach ($thing in @($things | Where-Object { $_.kind -eq 'Namespace' })) { & $haunt 'namespace' $thing.id } }

    # ToArray and not @(): @() of a list of dictionaries throws "Argument types do not match".
    $edgeList = New-Object System.Collections.Generic.List[object]
    foreach ($edge in $edges.Values) { $edgeList.Add([ordered]@{ kind = $edge.kind; from = $edge.from; to = $edge.to; checks = [string[]]$edge.checks.ToArray() }) }
    [ordered]@{ things = $things.ToArray(); edges = $edgeList.ToArray(); ghosts = $ghosts.ToArray(); nodes = $details.ToArray() }
}

function ConvertTo-ReportNetworkAppsView {
    <#
    .SYNOPSIS
        The Apps & Services view: what it is drawn into, its legend and its words. Pure.
    .DESCRIPTION
        Nothing where the Network Map has no Link and no Server: the switch offers no empty
        view. The legend stands in HTML, so that it is read without the script, printed, and
        its words explained in "Begriffe".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)]$Model)

    $graph = $Model.apps
    if ($null -eq $graph) { return }
    if (-not @($graph.edges).Count -and -not @($graph.things | Where-Object { $_.kind -eq 'Server' }).Count) { return }

    # Each key written out, so that the text table's test finds it read.
    $edgeWord = [ordered]@{
        OfferedBy    = Get-Text 'Report.Network.Apps.Edge.OfferedBy'
        UsesGateway  = Get-Text 'Report.Network.Apps.Edge.UsesGateway'
        UsesDns      = Get-Text 'Report.Network.Apps.Edge.UsesDns'
        UsesDhcp     = Get-Text 'Report.Network.Apps.Edge.UsesDhcp'
        LeadsTo      = Get-Text 'Report.Network.Apps.Edge.LeadsTo'
        OnServer     = Get-Text 'Report.Network.Apps.Edge.OnServer'
        Uses         = Get-Text 'Report.Network.Apps.Edge.Uses'
        MachineUses  = Get-Text 'Report.Network.Apps.Edge.MachineUses'
        HasApp       = Get-Text 'Report.Network.Apps.Edge.HasApp'
        HasService   = Get-Text 'Report.Network.Apps.Edge.HasService'
        HasDrive     = Get-Text 'Report.Network.Apps.Edge.HasDrive'
        More         = Get-Text 'Report.Network.Apps.Edge.More'
        Ghost        = Get-Text 'Report.Network.Apps.Edge.Ghost'
    }
    $lookWord = [ordered]@{
        BelongsTo = Get-Text 'Report.Network.Apps.Look.BelongsTo'
        TalksTo   = Get-Text 'Report.Network.Apps.Look.TalksTo'
    }
    $legend = (@($script:ReportNetworkAppsEdgeLooks.Keys | ForEach-Object {
        $kinds = $script:ReportNetworkAppsEdgeLooks[$_]
        '<span><svg width="34" height="10" aria-hidden="true"><line class="tedge e-{0}" x1="1" y1="5" x2="33" y2="5"/></svg>{1}: {2}</span>' -f $kinds[0],
            (ConvertTo-HtmlText $lookWord[$_]), (ConvertTo-HtmlText (($kinds | ForEach-Object { $edgeWord[$_] }) -join ', '))
    }) -join '') +
        ('<span><svg width="14" height="14" aria-hidden="true"><rect class="stripe" x="5" y="1" width="4" height="12" rx="2"/></svg>{0}</span>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Legend.Live'))) +
        ('<span><svg width="14" height="14" aria-hidden="true"><line class="logmark" x1="7" y1="1" x2="7" y2="13"/></svg>{0}</span>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Legend.Logged'))) +
        ('<span><svg width="24" height="14" aria-hidden="true"><rect class="tbox linked" x="1" y="1" width="22" height="12" rx="3"/></svg>{0}</span>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Legend.Linked'))) +
        ('<span class="tghost">{0}</span>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Legend.Ghost')))

    $html = '<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p><div class="netlegend appslegend">{3}</div><button type="button" class="appslone" hidden></button><div class="netpanel"><svg class="appssvg" role="group" aria-label="{0}"></svg></div>' -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Note')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Apps.Hint')), $legend

    New-ReportNetworkView -Id 'apps' -Title (Get-Text 'Report.Network.Apps.Title') -Html $html `
        -Css $script:ReportNetworkAppsCss -Script $script:ReportNetworkAppsScript `
        -Word ([ordered]@{
            Outside  = Get-Text 'Report.Network.Apps.Outside'
            Unlinked = Get-Text 'Report.Network.Apps.Unlinked'
            ShowLone = Get-Text 'Report.Network.Apps.ShowLone'
            HideLone = Get-Text 'Report.Network.Apps.HideLone'
            Seen     = Get-Text 'Report.Network.Apps.Seen'
            edge     = $edgeWord
            thing    = [ordered]@{
                Namespace = Get-Text 'Report.Network.Apps.Thing.Namespace'
                Share     = Get-Text 'Report.Network.Apps.Thing.Share'
                Service   = Get-Text 'Report.Network.Apps.Thing.Service'
                Drive     = Get-Text 'Report.Network.Apps.Thing.Drive'
                App       = Get-Text 'Report.Network.Apps.Thing.App'
                WindowsService = Get-Text 'Report.Network.Apps.Thing.WindowsService'
            }
        })
}