Private/ReportNetwork.ps1

# "Netzwerk im Bild": the chapter of the Report that draws the network from the Run's
# Measurements (ADR-0009, spec #96). What every view shares stands here; each view is a
# file of its own beside this one.
#
# - The Measurements and the Timed Events, as they were handed in, go into the Report as
# one JSON block (id "netdata"), as the glossary does with its terms: with them the
# Findings they name - Severity, title, Value, the part of each Server it names, and
# where in the Report each stands - the nodes they were merged into, the names of the
# views and every word the script shows.
# - One node per Server: merged by address first and by name second (a short name and
# its FQDN are one), keeping every Role. Get-ReportNetworkNode does it, here and not in
# the script, so that every view draws the same Servers and a test can say so.
# - The Severity a view shows is the Finding's, looked up by the Id a Measurement names,
# or that of the Finding's part about the Measurement's Subject (net.severity). A view
# never judges: a value no Finding judges is drawn grey.
# - The script shared by the views ($script:ReportNetworkScript) reads the block once and
# gives each view what it needs: the data, the words, a value in words, the detail
# beside a node, the jump to a Finding, and the switch between the views.
# - Drawn with inline SVG by the Report's own script. Nothing is fetched: a Report is
# opened at a Customer Site with or without a network.
#
# - The Timed Events (ADR-0010) go into the same block, for the 30 days: each with the
# lane it is drawn in, a Server's lane the node of Get-ReportNetworkNode it was merged
# into, and how far back each log reaches.
#
# No chapter at all where the Run measured no network - no Measurement, no event of a Server
# or of the machine: a Scope without the network Checks is no broken picture, and the crashes
# and sign-ins Stability and Logon read are no network (Test-ReportNetworkMeasured).

# The views of the chapter, in the order of the switch. One line per view: the function
# that renders it. To add a view:
#
# 1. Write Private/ReportNetwork<Name>.ps1 with a function ConvertTo-ReportNetwork<Name>View
# that takes -Model (see Get-ReportNetworkModel) and returns what New-ReportNetworkView
# makes - its Id, its title on the switch, its HTML, its CSS and its script - or
# nothing where it has nothing to draw.
# 2. Its script calls GutcheckNet.view('<id>', function(root, net){ ... }): root is the
# view's own element, net the shared part (see $script:ReportNetworkScript).
# 3. Its words go into Text.ps1, in a block of its own under "# -------- network
# pictures", and reach its script through -Word of New-ReportNetworkView, as
# net.words.<id>.
# 4. Add its function's name below.
$script:ReportNetworkViews = @(
    'ConvertTo-ReportNetworkPathView'
    'ConvertTo-ReportNetworkTimelineView'
    'ConvertTo-ReportNetworkWaterfallView'
    'ConvertTo-ReportNetworkRankingView'
)

# The Lanes of the Timed Events that are the network, and so open the chapter: a Server's
# and the machine's. The markers and a log's reach are drawn beside them, and alone are none.
$script:ReportNetworkLanes = @('Server', 'Machine')

# The order a node's Roles are sorted by, and its column filled in: the domain first, the
# internet last. Every Role of a Measurement ($script:MeasurementRoleValues), as a test
# says.
$script:ReportNetworkRoleOrder = @(
    'Machine', 'Adapter', 'Gateway', 'DomainController', 'DnsServer', 'DhcpServer', 'FileServer', 'DfsTarget',
    'PrintServer', 'AppServer', 'TimeSource', 'InternetProbe', 'HttpsService', 'Website', 'WebsiteHost'
)

$script:ReportNetworkCss = @'
<style>
/* Netzwerk im Bild (ReportNetwork.ps1): what every view shares. */
.NONE{--c:var(--faint);--cb:transparent;--cl:var(--line)}
.netnote{color:var(--dim);margin:0 0 12px;max-width:920px}
/* What the picture cannot show, because the Run did not look: said before it is read. */
.netgap{margin:0 0 14px;padding:8px 14px;border:1px dashed var(--line);border-radius:var(--radius);color:var(--dim);font-size:12.5px;max-width:920px}
.netgap b{color:var(--text);font-weight:600}
.netgap ul{margin:4px 0 0;padding-left:18px}
.netbar{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;margin:0 0 14px}
.netswitch{display:none;gap:6px}
.js .netswitch{display:flex}
.netswitch button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:3px 13px;color:var(--dim);font-size:12.5px}
.netswitch button:hover{color:var(--text);border-color:var(--faint)}
.netswitch button[aria-pressed="true"]{color:var(--text);border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}
.netlegend{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;color:var(--dim);font-size:12.5px}
.netlegend span{display:inline-flex;gap:6px;align-items:center}
.netlegend .sw{display:inline-block;width:14px;height:10px;border-radius:2px;background:var(--c)}
.js .netview.off{display:none}
.netview{margin-bottom:24px;position:relative}
/* Over the whole window, as a table of the evidence is: the same sign, the same way back. */
.netview>.wide{display:none;position:absolute;top:0;right:0;align-items:center;justify-content:center;width:28px;height:24px;padding:0;background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--dim);cursor:pointer}
.js .netview>.wide{display:inline-flex}
.netview>.wide:hover{color:var(--text);border-color:var(--accent)}
.netview>h3{padding-right:40px}
.netview.fullview{position:fixed;inset:14px;z-index:60;margin:0;padding:14px 16px;overflow:auto;background:var(--bg);border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 12px 48px rgba(0,0,0,.6)}
.netview.fullview>.wide{top:14px;right:16px}
.netview.fullview svg{max-width:none!important}
.netview>h3{margin:0 0 4px;font:600 15px var(--sans)}
.netview .netsub{color:var(--dim);font-size:12.5px;margin:0 0 8px}
/* What a view says of the pointer and the click: not on paper. */
.nethint{color:var(--faint)}
.netpanel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:8px;overflow-x:auto}
.netpanel svg{display:block;width:100%;height:auto}
.netpanel svg text{font-family:var(--sans);fill:var(--text)}
.netpanel svg .dim{fill:var(--dim)} .netpanel svg .faint{fill:var(--faint)} .netpanel svg .miss{fill:var(--faint);font-style:italic}
.netpanel svg .sevt{fill:var(--c)}
.netpanel svg .halo{paint-order:stroke;stroke:var(--panel);stroke-width:4px;stroke-linejoin:round}
.netpanel svg .zonehead{font:600 11px var(--mono);letter-spacing:.1em;fill:var(--dim)}
.netdetail{position:fixed;z-index:70;width:380px;max-width:calc(100vw - 16px);max-height:72vh;overflow:auto;padding:12px 14px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius);box-shadow:0 10px 32px rgba(0,0,0,.55);font-size:12.5px}
.netdetail h4{margin:0 0 2px;font:600 15px var(--sans);overflow-wrap:anywhere}
.netdetail .sub{color:var(--dim);margin-bottom:8px;overflow-wrap:anywhere}
.netdetail table{width:100%;border-collapse:collapse;margin:4px 0 8px}
.netdetail td{padding:4px 6px 4px 0;border-top:1px solid var(--line);vertical-align:top}
.netdetail td.v{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;padding-right:0}
.netdetail td.v.miss{color:var(--faint);font-style:italic;white-space:normal}
.netdetail small{display:block;color:var(--faint);font-size:11.5px}
.netdetail .find{display:block;margin:6px 0 0;padding:7px 9px;border-radius:8px;background:var(--cb);border:1px solid var(--cl);color:var(--text);text-decoration:none}
.netdetail .find:hover{border-color:var(--c)}
.netdetail .find b{font-weight:600;margin-left:6px}
.netdetail .find small{margin-top:3px;color:var(--dim);overflow-wrap:anywhere}
.netdetail .none{color:var(--faint)}
.netdetail .go{margin-top:8px;color:var(--faint);font-size:11.5px}
@media print{
  .netswitch,.netdetail,.nethint,.netview>.wide{display:none!important}
  .netview.off{display:block!important}
  .netview{break-inside:avoid-page}
  .netpanel{overflow:visible;padding:0;border:0}
  .netpanel svg{-webkit-print-color-adjust:exact;print-color-adjust:exact}
}
</style>
'@


# Shared by every view. Each view's own script follows it and calls GutcheckNet.view.
$script:ReportNetworkScript = @'
<script>
// Netzwerk im Bild: what every view shares. Reads the Measurements once, and hands each
// view its element and this: the data, the words, a value in words, the Severity of the
// Finding a Measurement names, the detail beside a node, and the jump to a Finding.
var GutcheckNet = (function(){
  var box = document.getElementById('netdata'), data = null;
  try { data = box ? JSON.parse(box.textContent) : null; } catch (x) { data = null; }
  if (!data) return null;
  var w = data.w, levels = data.levels, dot = ' ' + String.fromCharCode(183) + ' ';
  function esc(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
  function num(v){ return Number(v).toLocaleString('de-DE', { maximumFractionDigits: 1 }); }
  // The Finding a Measurement or a Timed Event belongs to, or nothing: one no Finding
  // judges is drawn grey.
  function finding(m){ return m && m.Finding && data.f[m.Finding] ? data.f[m.Finding] : null; }
  // The Severity it is drawn in: that of the part of its Finding whose Subject is its own
  // (a Finding that names several Servers says each one's), else the Finding's. Every view
  // colours by this and by nothing else.
  function severity(m){
    var f = finding(m);
    if (!f) return '';
    var who = String(m.Subject || '').toLowerCase(), parts = f.subjects || [];
    for (var i = 0; who && i < parts.length; i++) { if (String(parts[i].subject).toLowerCase() === who) return parts[i].s; }
    return f.s;
  }
  function worst(list){
    var found = '';
    list.forEach(function(s){ if (s && levels.indexOf(s) >= 0 && (found === '' || levels.indexOf(s) < levels.indexOf(found))) found = s; });
    return found;
  }
  function metric(m){ return w.metric[m.Metric] || m.Metric; }
  function unit(m){ return m.Unit === 'count' ? '' : (m.Unit === '%' ? ' %' : ' ' + m.Unit); }
  // Whether a Measurement has no number to draw: not measured, cached, blocked, failed.
  function missing(m){ return m.State !== 'Measured' || m.Value == null; }
  // The value in words: a number with its unit, or what became of the measuring. Never a
  // 0 for what was not measured. A ping of 0 ms is one under a millisecond, which Windows
  // rounds down: said so, here for every view.
  function value(m){
    if (m.State === 'Measured' && (m.Metric === 'Ping' || m.Metric === 'PingMax') && m.Value === 0) return w.value.UnderOneMs;
    if (m.State === 'Measured' && m.Value != null) return num(m.Value) + unit(m);
    if (m.State === 'Cached') return w.state.Cached;
    if (m.State === 'NotMeasured') return w.state.NotMeasured;
    var said = m.Error || (m.State === 'Blocked' ? w.state.Blocked : w.state.Failed);
    if (m.Value != null) said = w.state.After.replace('{0}', said).replace('{1}', num(m.Value) + unit(m));
    return said;
  }
  // "Ping 1,3 ms", "SMB: nicht gemessen".
  function part(m){ return metric(m) + (missing(m) ? ': ' : ' ') + value(m); }
  function measurements(n){ return n.m.map(function(i){ return data.m[i]; }); }
  // The Timed Events of a node, and how many of them count: a Context event is shown and
  // not counted.
  function events(n){ return (n.e || []).map(function(i){ return data.t[i]; }); }
  function counted(n){ return events(n).filter(function(e){ return e.Mark !== 'Context'; }); }
  function roles(n){ return n.roles.map(function(r){ return w.role[r] || r; }); }
  function label(n){ return n.place === 'machine' ? data.machine : (n.label || roles(n).join(', ')); }
  // The Findings of a node, the worst first, each once.
  function findings(n){
    var seen = {}, list = [];
    measurements(n).concat(events(n)).forEach(function(m){ var f = finding(m); if (f && !seen[m.Finding]) { seen[m.Finding] = 1; list.push(f); } });
    return list.sort(function(a, b){ return levels.indexOf(a.s) - levels.indexOf(b.s); });
  }
  // A click leads to the Finding as a link in the Report does: the Report's own script
  // shows where it stands, opens it and marks it.
  function jump(anchor){
    if (!anchor) return;
    var a = document.createElement('a');
    a.className = 'jump'; a.href = '#' + anchor; a.hidden = true;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
  }
  // The detail beside a node: its values, each with the Finding it belongs to, and its
  // Findings as links. Stays while the pointer moves onto it. More: what a view adds to the
  // line under the name, as the waterfall a card's whole address.
  var panel = document.getElementById('netdetail'), timer = null;
  function detailHtml(n, more){
    var sub = [n.place === 'machine' ? w.role.Machine : roles(n).join(', ')].concat(n.names.filter(function(x){ return x !== n.label; }), n.addresses.filter(function(x){ return x !== n.label; }), more || []);
    var h = '<h4>' + esc(label(n)) + '</h4><div class="sub">' + esc(sub.filter(Boolean).join(dot)) + '</div>' + (n.m.length ? '<table>' : '');
    measurements(n).forEach(function(m){
      var f = finding(m), more = [];
      if (m.Subject && n.place === 'machine') more.push(m.Subject);
      if (m.Port) more.push(w.detail.Port.replace('{0}', m.Port));
      if (m.Samples > 1) more.push(w.detail.Samples.replace('{0}', m.Samples));
      if (m.Proxy) more.push(w.detail.Proxy.replace('{0}', m.Proxy));
      h += '<tr class="' + (severity(m) || 'NONE') + '"><td><span class="dot"></span> ' + esc(metric(m)) + (more.length ? '<small>' + esc(more.join(dot)) + '</small>' : '') +
        '<small>' + esc(f ? f.t : w.detail.NoFinding) + '</small></td><td class="v' + (missing(m) ? ' miss' : '') + '">' + esc(value(m)) + '</td></tr>';
    });
    if (n.m.length) h += '</table>';
    // What the logs say of it: how many events of each kind, in the colour of the Finding
    // that judged that kind.
    var kinds = {}, order = [];
    counted(n).forEach(function(e){
      var k = e.Detail || e.Mark;
      if (!kinds[k]) { kinds[k] = { n: 0, s: [] }; order.push(k); }
      kinds[k].n++; kinds[k].s.push(severity(e));
    });
    if (order.length) {
      h += '<table><tr><td colspan="2"><b>' + esc(w.detail.Events.replace('{0}', counted(n).length)) + '</b></td></tr>';
      order.forEach(function(k){ h += '<tr class="' + (worst(kinds[k].s) || 'NONE') + '"><td><span class="dot"></span> ' + esc(k) + '</td><td class="v">' + esc(w.detail.Times.replace('{0}', kinds[k].n)) + '</td></tr>'; });
      h += '</table>';
    }
    var list = findings(n);
    if (!list.length) h += '<div class="none">' + esc(w.detail.NoFindings) + '</div>';
    list.forEach(function(f){
      h += '<a class="jump find ' + f.s + '" href="#' + esc(f.a) + '"><span class="badge">' + esc(f.s) + '</span><b>' + esc(f.t) + '</b>' + (f.v ? '<small>' + esc(f.v) + '</small>' : '') + '</a>';
    });
    if (list.length) h += '<div class="go">' + esc(w.detail.Jump) + '</div>';
    return h;
  }
  function showDetail(el, n, more){ if (n) showHtml(el, detailHtml(n, more)); }
  // Any detail beside an element: what a view writes itself, the events of an hour.
  function showHtml(el, html){
    if (!panel || !html) return;
    clearTimeout(timer);
    panel.innerHTML = html;
    panel.hidden = false;
    var r = el.getBoundingClientRect(), pw = panel.offsetWidth, ph = panel.offsetHeight;
    var left = r.right + 10;
    if (left + pw > window.innerWidth - 8) left = Math.max(8, r.left - pw - 10);
    var top = Math.min(Math.max(8, r.top), Math.max(8, window.innerHeight - ph - 8));
    panel.style.left = left + 'px'; panel.style.top = top + 'px';
  }
  function hideDetail(){ clearTimeout(timer); timer = setTimeout(function(){ if (panel) panel.hidden = true; }, 250); }
  if (panel) {
    panel.addEventListener('mouseenter', function(){ clearTimeout(timer); });
    panel.addEventListener('mouseleave', hideDetail);
    window.addEventListener('scroll', function(){ panel.hidden = true; }, true);
  }
  // The switch between the views. Without the script every view stands under the other.
  var switches = document.querySelectorAll('#ch-network [data-netpick]'), views = document.querySelectorAll('#ch-network .netview');
  function pick(id){
    Array.prototype.forEach.call(switches, function(b){ b.setAttribute('aria-pressed', String(b.getAttribute('data-netpick') === id)); });
    Array.prototype.forEach.call(views, function(v){ v.classList.toggle('off', v.getAttribute('data-netview') !== id); });
    if (panel) panel.hidden = true;
  }
  Array.prototype.forEach.call(switches, function(b){ b.addEventListener('click', function(){ pick(b.getAttribute('data-netpick')); }); });
  if (data.views.length) pick(data.views[0]);
  // Each view over the whole window from a sign beside its heading, and back from the same
  // sign, Escape or a click beside it - as a table of the evidence.
  var fullIcon = {
    open: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>',
    shut: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg>'
  };
  var full = null;
  function sign(b, open){ b.innerHTML = open ? fullIcon.open : fullIcon.shut; b.title = open ? w.full.open : w.full.shut; b.setAttribute('aria-label', b.title); }
  function closeWide(){
    if (!full) return;
    var was = full; full = null;
    was.classList.remove('fullview'); document.body.classList.remove('has-full');
    sign(was.querySelector('.wide'), true);
    was.scrollIntoView({ block: 'nearest' });
  }
  Array.prototype.forEach.call(views, function(v){
    var b = document.createElement('button'); b.type = 'button'; b.className = 'wide'; sign(b, true);
    v.insertBefore(b, v.firstChild);
    b.addEventListener('click', function(e){
      e.stopPropagation();
      if (full === v) { closeWide(); return; }
      closeWide();
      full = v; v.classList.add('fullview'); document.body.classList.add('has-full'); sign(b, false);
      if (panel) panel.hidden = true;
    });
  });
  document.addEventListener('click', function(e){ if (full && e.target.closest && !e.target.closest('.netview.fullview,#netdetail')) closeWide(); });
  document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && full) { e.stopImmediatePropagation(); closeWide(); } }, true);
  window.addEventListener('beforeprint', closeWide);
  // A view draws itself into its own element, with this.
  var net = { data: data, words: w, levels: levels, dot: dot, esc: esc, num: num, finding: finding, severity: severity, worst: worst, metric: metric,
              missing: missing, value: value, part: part, measurements: measurements, events: events, counted: counted, roles: roles, label: label, findings: findings,
              jump: jump, showDetail: showDetail, showHtml: showHtml, hideDetail: hideDetail };
  net.view = function(id, draw){
    var root = document.querySelector('#ch-network [data-netview="' + id + '"]');
    if (root) { try { draw(root, net); } catch (x) { if (window.console) console.error(x); } }
  };
  return net;
})();
</script>
'@


function Get-ReportNetworkName {
    # A host name as it is compared: lower case, without the dot a FQDN may end in. Empty
    # for an address and for nothing.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Name)

    $text = "$Name".Trim().TrimEnd('.').ToLowerInvariant()
    if (-not $text -or $null -ne (ConvertTo-MeasurementAddress -Address $text)) { return '' }
    $text
}

function Test-ReportNetworkNameMatch {
    # Whether two host names are one Server: the same name, or a short name and a FQDN that
    # begins with it. Two FQDNs are one only where they are the same. Pure.
    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowEmptyString()][string]$Name, [AllowEmptyString()][string]$Other)

    if (-not $Name -or -not $Other) { return $false }
    if ($Name -eq $Other) { return $true }
    if ($Name.Contains('.') -and $Other.Contains('.')) { return $false }
    ($Name -split '\.')[0] -eq ($Other -split '\.')[0]
}

function Get-ReportNetworkNode {
    <#
    .SYNOPSIS
        The Measurements as the nodes a view draws: one per Server, keeping every Role; and
        the Servers only the Timed Events know. Pure.
    .DESCRIPTION
        By address first and by name second. A Measurement whose address a node has is that
        node's; one whose name a node has - the same, or a short name and its FQDN - is that
        node's unless both have an address and they differ: two machines can share a short
        name, and an address says which is which. A Measurement that joins two nodes merges
        them. One with neither name nor address - a gateway that is not there - is a node of
        its Role.
 
        The machine's own end - its adapters, its Wi-Fi signal - is one node, "machine".
 
        A Timed Event of a Server's lane is placed by the same rule, after every
        Measurement: on the node of a Server that was measured, or on a node of its own for
        a Server only the events know (#106) - a Server that is gone is still on the way.
 
        Each node: Id, Label (a FQDN where one was measured or logged, else a name, else an
        address), Names, Addresses, Roles (in the order of $script:ReportNetworkRoleOrder),
        Zone, Place (machine, gateway, the Zone: lan, vpn, wan, none, or events for a Server
        no Measurement names), M, the indexes of its Measurements, and E, the indexes of its
        Timed Events, each in the order they were handed in. In each Place the nodes stand
        by their first Role, then by name.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$TimedEvent)

    $all = @($Measurement | Where-Object { $_ })
    $events = @($TimedEvent | Where-Object { $_ })
    $nodes = New-Object System.Collections.Generic.List[object]
    $newNode = {
        [pscustomobject]@{
            Names = New-Object System.Collections.Generic.List[string]; Addresses = New-Object System.Collections.Generic.List[string]
            Roles = New-Object System.Collections.Generic.List[string]; Zone = ''; Machine = $false
            M = New-Object System.Collections.Generic.List[int]; E = New-Object System.Collections.Generic.List[int]
        }
    }
    # The node a Server belongs to, by its address and its name: found, joined, or new.
    $nodeOf = {
        param($Subject, $AddressText, $Role)
        $address = ConvertTo-MeasurementAddress -Address $AddressText
        if ($null -eq $address) { $address = ConvertTo-MeasurementAddress -Address $Subject }
        $address = if ($null -ne $address) { $address.ToString().ToLowerInvariant() } else { '' }
        $name = Get-ReportNetworkName -Name $Subject

        $found = @($nodes | Where-Object {
            $candidate = $_
            if ($candidate.Machine) { return $false }
            if ($address -and $candidate.Addresses.Contains($address)) { return $true }
            if ($name -and @($candidate.Names | Where-Object { Test-ReportNetworkNameMatch -Name $name -Other $_ }).Count) {
                return -not ($address -and $candidate.Addresses.Count)
            }
            -not $address -and -not $name -and -not $candidate.Names.Count -and -not $candidate.Addresses.Count -and $Role -and $candidate.Roles.Contains($Role)
        })

        if ($found.Count) {
            $node = $found[0]
            foreach ($other in @($found | Select-Object -Skip 1)) {
                foreach ($x in $other.Names) { if (-not $node.Names.Contains($x)) { $node.Names.Add($x) } }
                foreach ($x in $other.Addresses) { if (-not $node.Addresses.Contains($x)) { $node.Addresses.Add($x) } }
                foreach ($x in $other.Roles) { if (-not $node.Roles.Contains($x)) { $node.Roles.Add($x) } }
                foreach ($x in $other.M) { $node.M.Add($x) }
                foreach ($x in $other.E) { $node.E.Add($x) }
                if (-not $node.Zone) { $node.Zone = $other.Zone }
                $null = $nodes.Remove($other)
            }
        }
        else { $node = & $newNode; $nodes.Add($node) }

        if ($name -and -not $node.Names.Contains($name)) { $node.Names.Add($name) }
        if ($address -and -not $node.Addresses.Contains($address)) { $node.Addresses.Add($address) }
        if ($Role -and -not $node.Roles.Contains($Role)) { $node.Roles.Add($Role) }
        $node
    }
    $machine = $null

    for ($i = 0; $i -lt $all.Count; $i++) {
        $m = $all[$i]
        $role = "$(Get-DataProperty $m 'Role')"
        if ($role -in 'Machine', 'Adapter') {
            if (-not $machine) { $machine = & $newNode; $machine.Machine = $true; $nodes.Add($machine) }
            $machine.M.Add($i)
            continue
        }
        $node = & $nodeOf "$(Get-DataProperty $m 'Subject')".Trim() "$(Get-DataProperty $m 'Address')" $role
        $node.M.Add($i)
        $zone = "$(Get-DataProperty $m 'Zone')"
        if (-not $node.Zone -and $zone) { $node.Zone = $zone }
    }
    for ($i = 0; $i -lt $events.Count; $i++) {
        if ("$(Get-DataProperty $events[$i] 'Lane')" -ne 'Server') { continue }
        $node = & $nodeOf "$(Get-DataProperty $events[$i] 'Subject')".Trim() "$(Get-DataProperty $events[$i] 'Address')" ''
        $node.E.Add($i)
    }

    $rank = { param($Role) $at = [array]::IndexOf($script:ReportNetworkRoleOrder, $Role); if ($at -lt 0) { 99 } else { $at } }
    $placed = foreach ($node in $nodes) {
        $roles = @($node.Roles | Sort-Object { & $rank $_ })
        # The name as it was written where it was measured or logged: the subject, not the
        # lower case it is compared in.
        $written = @{}
        $subjects = @(@($node.M | ForEach-Object { $all[$_] }) + @($node.E | ForEach-Object { $events[$_] }) | ForEach-Object { "$(Get-DataProperty $_ 'Subject')".Trim().TrimEnd('.') })
        foreach ($subject in $subjects) {
            $key = Get-ReportNetworkName -Name $subject
            if ($key -and -not $written.ContainsKey($key)) { $written[$key] = $subject }
        }
        $names = @($node.Names | ForEach-Object { $written[$_] })
        $label = @($names | Where-Object { $_.Contains('.') }) + @($names) + @($node.Addresses) | Select-Object -First 1
        $place = if ($node.Machine) { 'machine' }
                 elseif (-not $node.M.Count) { 'events' }
                 elseif ($roles -contains 'Gateway') { 'gateway' }
                 elseif ($node.Zone) { $node.Zone.ToLowerInvariant() }
                 else { 'none' }
        [pscustomobject]@{
            Label = "$label"; Names = [string[]]@($names); Addresses = [string[]]@($node.Addresses); Roles = [string[]]$roles
            Zone = $node.Zone; Place = $place; M = [int[]]@($node.M | Sort-Object); E = [int[]]@($node.E | Sort-Object)
            Rank = $(if ($roles.Count) { & $rank $roles[0] } else { 99 })
        }
    }
    $n = 0
    @($placed | Sort-Object @{ Expression = { $_.Place -ne 'machine' } }, Rank, Label) | ForEach-Object {
        $id = if ($_.Place -eq 'machine') { 'machine' } else { $n++; 'n{0}' -f $n }
        [pscustomobject]@{
            Id = $id; Label = $_.Label; Names = $_.Names; Addresses = $_.Addresses; Roles = $_.Roles
            Zone = $_.Zone; Place = $_.Place; M = $_.M; E = $_.E
        }
    }
}

function Get-ReportNetworkWord {
    <#
    .SYNOPSIS
        The words the shared script shows: the Metrics, the Roles, the Zones, what became of
        a measuring, and the detail beside a node. Pure.
    .DESCRIPTION
        One word for every Metric and every Role of Measurement.ps1, as a test says; each
        key written out, so that the text table's test finds it read.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param()

    [ordered]@{
        metric = [ordered]@{
            Ping            = Get-Text 'Report.Network.Metric.Ping'
            PingMax         = Get-Text 'Report.Network.Metric.PingMax'
            Loss            = Get-Text 'Report.Network.Metric.Loss'
            TcpConnect      = Get-Text 'Report.Network.Metric.TcpConnect'
            Dns             = Get-Text 'Report.Network.Metric.Dns'
            Ldap            = Get-Text 'Report.Network.Metric.Ldap'
            Kerberos        = Get-Text 'Report.Network.Metric.Kerberos'
            Smb             = Get-Text 'Report.Network.Metric.Smb'
            Tls             = Get-Text 'Report.Network.Metric.Tls'
            Response        = Get-Text 'Report.Network.Metric.Response'
            FirstByte       = Get-Text 'Report.Network.Metric.FirstByte'
            LinkSpeed       = Get-Text 'Report.Network.Metric.LinkSpeed'
            WifiSignal      = Get-Text 'Report.Network.Metric.WifiSignal'
            ClockOffset     = Get-Text 'Report.Network.Metric.ClockOffset'
            ConnectFailures = Get-Text 'Report.Network.Metric.ConnectFailures'
        }
        role = [ordered]@{
            DomainController = Get-Text 'Report.Network.Role.DomainController'
            DnsServer        = Get-Text 'Report.Network.Role.DnsServer'
            DhcpServer       = Get-Text 'Report.Network.Role.DhcpServer'
            FileServer       = Get-Text 'Report.Network.Role.FileServer'
            DfsTarget        = Get-Text 'Report.Network.Role.DfsTarget'
            PrintServer      = Get-Text 'Report.Network.Role.PrintServer'
            AppServer        = Get-Text 'Report.Network.Role.AppServer'
            Gateway          = Get-Text 'Report.Network.Role.Gateway'
            InternetProbe    = Get-Text 'Report.Network.Role.InternetProbe'
            HttpsService     = Get-Text 'Report.Network.Role.HttpsService'
            Website          = Get-Text 'Report.Network.Role.Website'
            WebsiteHost      = Get-Text 'Report.Network.Role.WebsiteHost'
            TimeSource       = Get-Text 'Report.Network.Role.TimeSource'
            Machine          = Get-Text 'Report.Network.Role.Machine'
            Adapter          = Get-Text 'Report.Network.Role.Adapter'
        }
        zone = [ordered]@{
            services = Get-Text 'Report.Network.Zone.Services'
            events = Get-Text 'Report.Network.Zone.Events'
            lan  = Get-Text 'Report.Network.Zone.Lan'
            vpn  = Get-Text 'Report.Network.Zone.Vpn'
            wan  = Get-Text 'Report.Network.Zone.Wan'
            none = Get-Text 'Report.Network.Zone.None'
        }
        state = [ordered]@{
            NotMeasured = Get-Text 'Report.Network.State.NotMeasured'
            Cached      = Get-Text 'Report.Network.State.Cached'
            Blocked     = Get-Text 'Report.Network.State.Blocked'
            Failed      = Get-Text 'Report.Network.State.Failed'
            After       = Get-Text 'Report.Network.State.After'
        }
        value = [ordered]@{
            UnderOneMs = Get-Text 'Report.Network.Value.UnderOneMs'
        }
        detail = [ordered]@{
            NoFinding  = Get-Text 'Report.Network.Detail.NoFinding'
            NoFindings = Get-Text 'Report.Network.Detail.NoFindings'
            Port       = Get-Text 'Report.Network.Detail.Port'
            Samples    = Get-Text 'Report.Network.Detail.Samples'
            Proxy      = Get-Text 'Report.Network.Detail.Proxy'
            Jump       = Get-Text 'Report.Network.Detail.Jump'
            Events     = Get-Text 'Report.Network.Detail.Events'
            Times      = Get-Text 'Report.Network.Detail.Times'
        }
        # The sign that shows a view over the whole window: the words of the evidence's.
        full = [ordered]@{
            open = Get-Text 'Report.Table.Wide'
            shut = Get-Text 'Report.Table.Narrow'
        }
    }
}

function Get-ReportNetworkSeverity {
    <#
    .SYNOPSIS
        The Severity a Measurement or a Timed Event is drawn in: that of the part of its
        Finding whose Subject is its own, else the Finding's; nothing without a Finding. Pure.
    .DESCRIPTION
        As the shared script's net.severity does it: a Finding that names several Servers
        says the Severity of each (Subjects, ADR-0009), so that one unreachable DFS target
        does not colour the reachable file Server beside it. Subjects are compared without
        regard to case.
    .PARAMETER Record
        A row of the model: Subject and Finding.
    .PARAMETER Named
        The Findings of the model by their Id, as Get-ReportNetworkModel makes them.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Record, [AllowNull()]$Named)

    # A row of the model is a dictionary, whose keys are no properties.
    $id = "$($Record['Finding'])"
    if (-not $id -or $null -eq $Named -or -not $Named.Contains($id)) { return '' }
    $f = $Named[$id]
    $subject = "$($Record['Subject'])".Trim()
    if ($subject) {
        foreach ($part in @($f.subjects)) {
            if ("$($part.subject)" -ieq $subject) { return "$($part.s)" }
        }
    }
    "$($f.s)"
}

function ConvertTo-ReportNetworkRow {
    # A record as the model holds it: every column, a number as a number, a time as ISO
    # 8601 without a zone, as the machine's clock read it, and nothing as an empty text.
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param([AllowNull()]$Record, [string[]]$Column, [string[]]$Number = @())

    $row = [ordered]@{}
    foreach ($name in $Column) {
        $value = Get-DataProperty $Record $name
        if ($name -in $Number) { $value = ConvertTo-Number $value }
        elseif ($value -is [datetime]) { $value = $value.ToString('yyyy-MM-ddTHH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) }
        elseif ($null -eq $value) { $value = '' }
        else { $value = "$value" }
        $row[$name] = $value
    }
    $row
}

function Get-ReportNetworkCoverage {
    <#
    .SYNOPSIS
        From when on a row of the 30 days is covered by its logs: the earliest any of them
        reaches back to. Nothing where one of them reaches back over everything or is not
        known to stop. Pure.
    .DESCRIPTION
        A row is hatched only before the time none of the logs its events come from reaches
        back - one log that stops early under another that reaches further leaves no stretch
        unread. A log read as Unread covers nothing. Returns Log (the one that reaches back
        furthest), Mark (Reaches, or Unread where every one was refused) and Time (as the
        model writes it, empty for Unread); or nothing.
    .PARAMETER Log
        The logs the row's events come from.
    .PARAMETER Reach
        How far back each log reaches, as the model holds it (tc): Log, Mark and Time.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Log, [AllowNull()][AllowEmptyCollection()]$Reach)

    $logs = @($Log | Where-Object { $_ } | Select-Object -Unique)
    if (-not $logs.Count) { return }
    $earliest = $null
    $refused = $null
    foreach ($name in $logs) {
        $known = @($Reach | Where-Object { $_ -and "$($_.Log)" -eq $name }) | Select-Object -First 1
        # A log not known to stop may reach back over everything.
        if (-not $known) { return }
        if ("$($known.Mark)" -eq 'Unread') { if (-not $refused) { $refused = $name }; continue }
        $at = ConvertTo-DataTime $known.Time
        if ($null -eq $at) { return }
        if ($null -eq $earliest -or $at -lt $earliest.At) { $earliest = @{ At = $at; Log = $name; Time = "$($known.Time)" } }
    }
    if ($earliest) { return [ordered]@{ Log = $earliest.Log; Mark = 'Reaches'; Time = $earliest.Time } }
    [ordered]@{ Log = $refused; Mark = 'Unread'; Time = '' }
}

function Get-ReportNetworkModel {
    <#
    .SYNOPSIS
        What the chapter's script is handed: the Measurements and the Timed Events as they
        came, the Findings they name, the nodes and the lanes, and the words. Pure.
    .DESCRIPTION
        machine: the Target Machine's name. m: every Measurement, with every field of
        measurements.csv. f: each Finding a Measurement or a Timed Event names, by its Id -
        s its Severity, t its title, v its Value, a where in the Report it stands, subjects
        each Server it names with the Severity of its part ({subject, s}). One naming a
        Finding that is not in the Report names none here, and is drawn grey. n: the nodes
        of Get-ReportNetworkNode, each with s, the worst Severity its values are drawn in
        (for a Server only the events know, its events), m the indexes of its Measurements
        and e those of its Timed Events in t. levels: the Severities, the worst first, as a
        Finding has them. w: the words. views: the Ids of the views, filled by
        ConvertTo-ReportNetwork.
 
        The 30 days (ADR-0010): asof, when the Run was, which the axis ends at. t: every
        Timed Event but a log's reach - every field of timeline.csv, Time local, to the
        second - and row, the lane it is drawn in: 'machine', 'marker',
        or the id of a Server's lane. tl: the Servers' lanes - id, label, names, addresses,
        node (the id of the node of the path it is), e, the indexes of its events in t, and
        cover, from when on its logs cover it (Get-ReportNetworkCoverage), or nothing.
        tc: how far back each log reaches - Log, Mark (Reaches or Unread) and Time. tr: the
        cover of each row the script draws with events - a Server's lane, machine, crash
        (crashes and hangs) and start (the other markers) - where one is known; the script
        hatches a row before it and nowhere else.
 
        A Server is one node and one lane: both come out of the one merge of
        Get-ReportNetworkNode.
    .PARAMETER Word
        The Report's own, for where each Finding stands (Get-ReportFindingAnchor).
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Measurement,
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [AllowNull()][hashtable]$Word,
        [string]$ComputerName = '',
        [AllowNull()][AllowEmptyCollection()]$TimedEvent,
        [datetime]$AsOf = (Get-Date)
    )

    $all = @($Measurement | Where-Object { $_ })
    $byId = @{}
    foreach ($f in @($Finding | Where-Object { $_ })) {
        $id = "$(Get-DataProperty $f 'Id')".Trim()
        if ($id -and -not $byId.ContainsKey($id)) { $byId[$id] = $f }
    }

    $rows = New-Object System.Collections.Generic.List[object]
    foreach ($m in $all) { $rows.Add((ConvertTo-ReportNetworkRow -Record $m -Column $script:MeasurementCsvColumns -Number 'Value', 'StartMs', 'Samples', 'Port')) }

    # The 30 days: every event but a log's reach, with the lane it is drawn in, and the
    # reach of each log on its own.
    $events = @($TimedEvent | Where-Object { $_ -and "$(Get-DataProperty $_ 'Lane')" -ne 'Log' })
    $stamp  = { param($Time) $at = ConvertTo-DataTime $Time; if ($null -eq $at) { '' } else { $at.ToString('yyyy-MM-ddTHH:mm:ss', [Globalization.CultureInfo]::InvariantCulture) } }
    $timed  = New-Object System.Collections.Generic.List[object]
    foreach ($e in $events) {
        $row = [ordered]@{}
        foreach ($column in $script:TimedEventCsvColumns) {
            $row[$column] = if ($column -eq 'Time') { & $stamp (Get-DataProperty $e 'Time') } else { "$(Get-DataProperty $e $column)" }
        }
        $row.row = "$($row.Lane)".ToLowerInvariant()
        $timed.Add($row)
    }
    $reach = @($TimedEvent | Where-Object { $_ -and "$(Get-DataProperty $_ 'Lane')" -eq 'Log' } | ForEach-Object {
        [ordered]@{ Log = "$(Get-DataProperty $_ 'Log')"; Mark = "$(Get-DataProperty $_ 'Mark')"; Time = & $stamp (Get-DataProperty $_ 'Time') }
    })

    $named = [ordered]@{}
    foreach ($row in @($rows.ToArray()) + @($timed.ToArray())) {
        $id = $row.Finding
        if (-not $id -or -not $byId.ContainsKey($id) -or $named.Contains($id)) { continue }
        $f = $byId[$id]
        $named[$id] = [ordered]@{
            s = "$($f.Severity)"; t = "$($f.Check)"; v = "$($f.Value)"
            a = Get-ReportFindingAnchor -Finding $f -Word $Word
            subjects = @((Get-DataCollection $f 'Subjects') | ForEach-Object {
                [ordered]@{ subject = "$(Get-DataProperty $_ 'Subject')"; s = "$(Get-DataProperty $_ 'Severity')" } })
        }
    }

    # One merge for the path and the 30 days: the nodes, and of them those with events
    # are the Servers' lanes.
    $order = @{}
    for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i }
    $merged = @(Get-ReportNetworkNode -Measurement $all -TimedEvent $events)
    $nodes = @($merged | ForEach-Object {
        $worst = ''
        $drawn = if ($_.M.Count) { @($_.M | ForEach-Object { $rows[$_] }) } else { @($_.E | ForEach-Object { $timed[$_] }) }
        foreach ($row in $drawn) {
            $severity = Get-ReportNetworkSeverity -Record $row -Named $named
            if ($severity -and (-not $worst -or $order[$severity] -lt $order[$worst])) { $worst = $severity }
        }
        [ordered]@{
            id = $_.Id; label = $_.Label; names = @($_.Names); addresses = @($_.Addresses); roles = @($_.Roles)
            zone = $_.Zone; place = $_.Place; s = $worst; m = @($_.M); e = @($_.E)
        }
    })
    $lanes = @()
    $k = 0
    foreach ($node in @($merged | Where-Object { @($_.E).Count } | Sort-Object @{ Expression = { @($_.E).Count }; Descending = $true }, Label)) {
        $k++
        $id = 's{0}' -f $k
        foreach ($index in $node.E) { $timed[$index].row = $id }
        $lanes += [ordered]@{ id = $id; label = $node.Label; names = @($node.Names); addresses = @($node.Addresses); node = $node.Id; e = @($node.E) }
    }

    # From when on each row is covered by the logs its events come from, by the row the
    # script draws: a Server's lane, the machine's, and the markers as two rows - the crashes
    # and hangs, and the rest - as the script splits them.
    $logsOf = [ordered]@{}
    foreach ($row in $timed) {
        $key = if ($row.row -ne 'marker') { $row.row } elseif ($row.Mark -in 'Crash', 'Hang') { 'crash' } else { 'start' }
        if (-not $logsOf.Contains($key)) { $logsOf[$key] = New-Object System.Collections.Generic.List[string] }
        if ($row.Log) { $logsOf[$key].Add($row.Log) }
    }
    $covered = [ordered]@{}
    foreach ($key in $logsOf.Keys) {
        $cover = Get-ReportNetworkCoverage -Log $logsOf[$key].ToArray() -Reach $reach
        if ($cover) { $covered[$key] = $cover }
    }
    foreach ($lane in $lanes) { $lane.cover = $(if ($covered.Contains($lane.id)) { $covered[$lane.id] } else { $null }) }

    [ordered]@{
        machine = $ComputerName
        asof    = $AsOf.ToString('yyyy-MM-ddTHH:mm:ss', [Globalization.CultureInfo]::InvariantCulture)
        levels  = @($script:SeverityValues)
        views   = @()
        # ToArray and not @(): @() of a list of dictionaries throws "Argument types do not match".
        m       = $rows.ToArray()
        f       = $named
        n       = $nodes
        t       = $timed.ToArray()
        tl      = $lanes
        tc      = $reach
        tr      = $covered
        w       = Get-ReportNetworkWord
    }
}

function New-ReportNetworkView {
    <#
    .SYNOPSIS
        One view of the chapter, as a view's function returns it. Pure.
    .PARAMETER Id
        What the view is called in the markup and in its script: path, waterfall.
    .PARAMETER Title
        Its name on the switch.
    .PARAMETER Html
        What it is drawn into: its heading, its legend, an svg. The script draws the rest.
    .PARAMETER Css
        Its own <style> block.
    .PARAMETER Script
        Its own <script> block, calling GutcheckNet.view(Id, ...).
    .PARAMETER Word
        The words its script shows, there as net.words.<Id>.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][string]$Title, [Parameter(Mandatory)][string]$Html,
        [string]$Css = '', [string]$Script = '', [AllowNull()]$Word
    )

    [pscustomobject]@{ Id = $Id; Title = $Title; Html = $Html; Css = $Css; Script = $Script; Word = $Word }
}

function ConvertTo-ReportNetworkData {
    # The model as the JSON block the script reads. Inside a script element nothing may
    # close it.
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)]$Model)

    $json = (ConvertTo-Json -InputObject $Model -Compress -Depth 8) -replace '<', ('{0}u003c' -f [char]92)
    '<script type="application/json" id="netdata">{0}</script>' -f $json
}

function Get-ReportNetworkGap {
    <#
    .SYNOPSIS
        What the picture cannot show because the Run did not perform the Checks that measure
        it: one line each, "the domain controllers: not checked in this Scope". Pure.
    .DESCRIPTION
        A picture without domain controllers reads as a network without them. Decided from
        the Kinds the Run performed: a group is missing where none of its Kinds was. Said of
        the Scope where a Scope narrowed the Run, and of the Run otherwise.
        Nothing where the Run did not say what it performed.
    .PARAMETER Coverage
        What the Run performed: Kinds, the Kinds of its Checks that were not skipped, and
        Scope, the title of the Scope it was narrowed to, or empty.
    #>

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

    if ($null -eq $Coverage) { return }
    $kinds = @((Get-DataCollection $Coverage 'Kinds') | ForEach-Object { "$_" })
    $form = if ("$(Get-DataProperty $Coverage 'Scope')".Trim()) { Get-Text 'Report.Network.Gap.Scope' } else { Get-Text 'Report.Network.Gap.Run' }
    # What each group of the picture is measured by, in the order the picture reads.
    $groups = @(
        , @((Get-Text 'Report.Network.Gap.Network'), @('Network'))
        , @((Get-Text 'Report.Network.Gap.Domain'), @('Domain'))
        , @((Get-Text 'Report.Network.Gap.Server'), @('Server', 'NetworkDependency', 'AppConnection'))
        , @((Get-Text 'Report.Network.Gap.Printing'), @('Printing'))
        , @((Get-Text 'Report.Network.Gap.Web'), @('HttpsEndpoint', 'Website'))
        , @((Get-Text 'Report.Network.Gap.Disruption'), @('NetworkDisruption'))
    )
    foreach ($group in $groups) {
        if (-not @($group[1] | Where-Object { $kinds -contains $_ }).Count) { $form -f $group[0] }
    }
}

function Test-ReportNetworkMeasured {
    <#
    .SYNOPSIS
        Whether the Run measured the network: a Measurement, or a Timed Event of a Server's
        or the machine's lane. Pure.
    .DESCRIPTION
        The markers - crashes, hangs, starts, sign-ins - come from Stability and Logon, which
        a Scope without the network Checks performs as well; and a log's reach is no event.
        Neither alone is a network to draw (#96, story 27). Where the chapter is there, the
        30 days draw them.
    .PARAMETER TimedEvent
        As a Judge made them, or as the model holds them: each with its Lane.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()][AllowEmptyCollection()]$TimedEvent)

    if (@($Measurement | Where-Object { $_ }).Count) { return $true }
    [bool]@($TimedEvent | Where-Object { $_ -and "$($_.Lane)" -in $script:ReportNetworkLanes }).Count
}

function ConvertTo-ReportNetwork {
    <#
    .SYNOPSIS
        The chapter "Netzwerk im Bild", or nothing where the Run measured no network
        (Test-ReportNetworkMeasured), or where no view has anything to draw. Pure.
    .DESCRIPTION
        Returns Html (the chapter), Css (for the head), Script (the JSON block and the
        scripts, after the Report's own), Nav (its entry in the tree, in the group of the
        network) and Model (what the script is handed).
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Measurement,
        [AllowNull()][AllowEmptyCollection()]$Finding,
        [AllowNull()][hashtable]$Word,
        [string]$ComputerName = '',
        [AllowNull()][AllowEmptyCollection()]$TimedEvent,
        [datetime]$AsOf = (Get-Date),

        # What the Run performed, for what the picture says it cannot show
        # (Get-ReportNetworkGap). Nothing said where it is not given.
        [AllowNull()]$Coverage
    )

    $all = @($Measurement | Where-Object { $_ })
    $timed = @($TimedEvent | Where-Object { $_ })
    if (-not (Test-ReportNetworkMeasured -Measurement $all -TimedEvent $timed)) { return }

    $model = Get-ReportNetworkModel -Measurement $all -Finding $Finding -Word $Word -ComputerName $ComputerName -TimedEvent $timed -AsOf $AsOf
    $views = @(foreach ($name in $script:ReportNetworkViews) { & $name -Model $model })
    $views = @($views | Where-Object { $_ })
    # No empty chapter: where no view has anything to draw, there is none.
    if (-not $views.Count) { return }
    $model.views = @($views | ForEach-Object { $_.Id })
    foreach ($view in $views) { if ($null -ne $view.Word) { $model.w[$view.Id] = $view.Word } }

    $switch = @($views | ForEach-Object {
        '<button type="button" data-netpick="{0}" aria-pressed="false">{1}</button>' -f (ConvertTo-HtmlText $_.Id), (ConvertTo-HtmlText $_.Title)
    }) -join ''
    $legend = (@($script:SeverityValues | ForEach-Object { '<span class="{0}"><i class="sw"></i>{0}</span>' -f $_ }) -join '') +
        ('<span><svg width="34" height="10" aria-hidden="true"><line x1="0" y1="5" x2="34" y2="5" stroke="currentColor" stroke-width="2" stroke-dasharray="4 3"/></svg>{0}</span>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Legend.NoFinding')))
    $body = @($views | ForEach-Object {
        '<div class="netview" data-netview="{0}" id="nv-{0}">{1}</div>' -f (ConvertTo-HtmlText $_.Id), $_.Html
    }) -join "`n"

    $gaps = @(Get-ReportNetworkGap -Coverage $Coverage)
    $gapHtml = ''
    if ($gaps.Count) {
        $gapHtml = '<div class="netgap"><b>{0}</b><ul>{1}</ul></div>' -f (ConvertTo-HtmlText (Get-Text 'Report.Network.Gap.Heading')),
            (($gaps | ForEach-Object { '<li>{0}</li>' -f (ConvertTo-HtmlText $_) }) -join '')
    }

    $html = ("<section data-chapter=`"network`" id=`"ch-network`">`n<h2>{0}</h2>`n<p class=`"netnote`">{1}</p>`n{6}" +
        "<div class=`"netbar`"><div class=`"netswitch`" role=`"group`" aria-label=`"{2}`">{3}</div><div class=`"netlegend`">{4}</div></div>`n{5}`n" +
        "<div class=`"netdetail`" id=`"netdetail`" hidden></div>`n</section>") -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Intro')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Views')), $switch, $legend, $body, $gapHtml

    [pscustomobject]@{
        Html   = $html
        Css    = $script:ReportNetworkCss + (@($views | ForEach-Object { $_.Css }) -join '')
        Script = (ConvertTo-ReportNetworkData -Model $model) + "`n" + $script:ReportNetworkScript + (@($views | ForEach-Object { $_.Script }) -join '')
        Nav    = [pscustomobject]@{ Pick = 'network'; Label = (Get-Text 'Report.Network.Heading'); Severity = 'plain'; Count = -1; Icon = 'Network'; Tip = (Get-Text 'Report.Network.NavTip') }
        Model  = $model
    }
}