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 are on, the names of the views and
# every word the script shows.
# - One node per Server of the Network Map (NetworkMap.ps1, ADR-0012), which alone decides
# which Server is which, by the identity of a Server (ServerIdentity.ps1): the chapter
# merges nothing. Get-ReportNetworkNode draws the Map's Servers as nodes, here and not in
# the script, so that every view draws the same Servers and a test can say so. Each node
# has the Map's key, and so has every record on it and every part of a Finding about its
# Server: the script compares keys as they are and never decides which Server a name is.
# - The Severity a view shows is the Finding's, looked up by the Id a Measurement names,
# or that of the Finding's part with the Measurement's key (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 the Server the Map put it on, 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 ul.rel{margin:0 0 8px;padding-left:16px;color:var(--dim)}
.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 about its own Server (a
  // Finding that names several Servers says each one's), else the Finding's. The part is
  // the one whose key is its own, compared as it is: which Server a name is, the model
  // decided. Every view colours by this and by nothing else.
  function severity(m){
    var f = finding(m);
    if (!f) return '';
    var key = m.key, parts = f.subjects || [];
    for (var i = 0; key && i < parts.length; i++) { if (parts[i].key === key) return parts[i].s; }
    return f.s;
  }
  // How the events of its own Server's part of its Finding were spread in time, in words,
  // or nothing: the Judge says it (PatternText), a view only shows it.
  function pattern(m){
    var f = finding(m), key = m && m.key, parts = f ? f.subjects || [] : [];
    for (var i = 0; key && i < parts.length; i++) { if (parts[i].key === key) return parts[i].pattern || ''; }
    return '';
  }
  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; }
  // A number with its sign, as a clock offset is written: +1,2 or -0,4. The sign is that of
  // what is written, so that -0,04 is 0 and not -0.
  function signed(v){ var t = num(Math.abs(v)); return t === '0' ? t : (v < 0 ? '-' : '+') + t; }
  // 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. A clock offset says which way it is off.
  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.Metric === 'ClockOffset' && m.Value != null) return signed(m.Value) + unit(m);
    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;
  }
  // The values of measurements of one Metric from the least to the most, "1,4 to 1,8 ms" with an en dash, the
  // unit once; one value where they agree.
  function range(list){
    var sorted = list.slice().sort(function(a, b){ return a.Value - b.Value; });
    var lo = value(sorted[0]), hi = value(sorted[sorted.length - 1]), u = unit(sorted[0]);
    if (lo === hi) return lo;
    if (u && lo.slice(-u.length) === u && hi.slice(-u.length) === u) lo = lo.slice(0, -u.length);
    return lo + String.fromCharCode(8211) + hi;
  }
  // "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(', ')); }
  // What else a node is called: its addresses first, so that a cut line keeps them, then its
  // other names.
  function aliases(n){ return n.addresses.concat(n.names).filter(function(x){ return x !== n.label; }); }
  // A time as the model writes it, local, to the second; and in words, by the day or the minute.
  function parse(s){
    var p = /^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)/.exec(s || '');
    return p ? new Date(+p[1], +p[2] - 1, +p[3], +p[4], +p[5], +p[6]) : null;
  }
  function two(k){ return (k < 10 ? '0' : '') + k; }
  function day(t){ return two(t.getDate()) + '.' + two(t.getMonth() + 1) + '.'; }
  function when(t){ return day(t) + ' ' + two(t.getHours()) + ':' + two(t.getMinutes()); }
  // When the counted events of a node began and ended, or nothing.
  function span(n){
    var first = null, last = null;
    counted(n).forEach(function(e){ var t = parse(e.Time); if (!t) return; if (!first || t < first) first = t; if (!last || t > last) last = t; });
    return first ? { first: first, last: last } : null;
  }
  // 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(aliases(n), more || []);
    var h = '<h4>' + esc(label(n)) + '</h4><div class="sub">' + esc(sub.filter(Boolean).join(dot)) + '</div>';
    // What the Map's Links say of it, in words: a controller of which Domain, the DNS server
    // of which adapter. Not judged: in no colour.
    if ((n.r || []).length) h += '<ul class="rel">' + n.r.map(function(x){ return '<li>' + esc(x) + '</li>'; }).join('') + '</ul>';
    if (n.m.length) h += '<table>';
    // One row per value and port - on this machine per adapter as well -, however many
    // Checks measured it: from the least to the most, in the colour of the worst Finding,
    // with each Finding it belongs to.
    var rows = {}, rowOrder = [];
    measurements(n).forEach(function(m){
      var key = m.Metric + '|' + (m.Port || '') + '|' + (n.place === 'machine' ? m.Subject : '');
      if (!rows[key]) { rows[key] = []; rowOrder.push(key); }
      rows[key].push(m);
    });
    rowOrder.forEach(function(key){
      var list = rows[key], m = list[0], more = [], finds = [], proxies = [];
      var measured = list.filter(function(x){ return !missing(x); });
      if (m.Subject && n.place === 'machine') more.push(m.Subject);
      if (m.Port) more.push(w.detail.Port.replace('{0}', m.Port));
      if (list.length > 1) more.push(w.detail.Measured.replace('{0}', list.length));
      else if (m.Samples > 1) more.push(w.detail.Samples.replace('{0}', m.Samples));
      list.forEach(function(x){
        var f = finding(x);
        if (f && finds.indexOf(f.t) < 0) finds.push(f.t);
        if (x.Proxy && proxies.indexOf(x.Proxy) < 0) proxies.push(x.Proxy);
      });
      proxies.forEach(function(p){ more.push(w.detail.Proxy.replace('{0}', p)); });
      if (measured.length && measured.length < list.length) more.push(w.detail.NotAll.replace('{0}', list.length - measured.length));
      // Nothing measured: what became of each measuring, each once.
      var shown = measured.length ? range(measured) : list.map(value).filter(function(v, i, a){ return a.indexOf(v) === i; }).join(dot);
      h += '<tr class="' + (worst(list.map(severity)) || 'NONE') + '"><td><span class="dot"></span> ' + esc(metric(m)) + (more.length ? '<small>' + esc(more.join(dot)) + '</small>' : '') +
        '<small>' + esc(finds.length ? finds.join(dot) : w.detail.NoFinding) + '</small></td><td class="v' + (measured.length ? '' : ' miss') + '">' + esc(shown) + '</td></tr>';
    });
    if (n.m.length) h += '</table>';
    // What the logs say of it: how many events, from when to when, and of each kind, in the
    // colour of the Finding that judged that kind, and how its Finding says they were spread.
    var kinds = {}, order = [];
    counted(n).forEach(function(e){
      var k = e.Detail || e.Mark;
      if (!kinds[k]) { kinds[k] = { n: 0, s: [], p: '' }; order.push(k); }
      kinds[k].n++; kinds[k].s.push(severity(e));
      if (!kinds[k].p) kinds[k].p = pattern(e);
    });
    if (order.length) {
      var sp = span(n), at = '';
      if (sp) at = when(sp.first) === when(sp.last) ? w.detail.Once.replace('{0}', when(sp.first)) : w.detail.Span.replace('{0}', when(sp.first)).replace('{1}', when(sp.last));
      h += '<table><tr><td colspan="2"><b>' + esc(w.detail.Events.replace('{0}', counted(n).length)) + '</b>' + (at ? '<small>' + esc(at) + '</small>' : '') + '</td></tr>';
      order.forEach(function(k){ h += '<tr class="' + (worst(kinds[k].s) || 'NONE') + '"><td><span class="dot"></span> ' + esc(k) + (kinds[k].p ? '<small>' + esc(kinds[k].p) + '</small>' : '') + '</td><td class="v">' + esc(w.detail.Times.replace('{0}', kinds[k].n)) + '</td></tr>'; });
      h += '</table>';
    }
    var list = findings(n);
    if (n.place === 'linked') h += '<div class="none">' + esc(w.detail.Linked) + '</div>';
    else 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, pattern: pattern, worst: worst, metric: metric,
              missing: missing, value: value, part: part, measurements: measurements, events: events, counted: counted, roles: roles, label: label, findings: findings,
              aliases: aliases, parse: parse, day: day, when: when, span: span,
              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-ReportNetworkNode {
    <#
    .SYNOPSIS
        The Servers of the Network Map as the nodes a view draws, and the machine's own end
        as one more. Pure; nothing is merged here.
    .DESCRIPTION
        Which Server is which the Map decided (New-NetworkMap, ADR-0012), by the identity of a
        Server: a node is one Server of the Map, with the Map's Key, and holds the
        Measurements and Timed Events the Map put on it. A Server only a Link names, which
        no Measurement and no Timed Event does - a controller DNS named and nobody asked, a
        DFS target that is gone - is a node too, without numbers: it is there, and it was
        not measured.
 
        The machine's own end - the Map's Machine and its adapters, their link speed and the
        Wi-Fi signal - is one node, "machine".
 
        Each node: Id, Key (the Map's, "machine" for the machine's own end), Label (a FQDN
        where one was measured or logged, else a name, else an address; as it was written,
        without its port, UNC path or trailing dot), Names (as they were written), Addresses
        (the one Select-ServerAddress chooses first, then the rest as they came), Roles (in
        the order of $script:ReportNetworkRoleOrder), Zone (that of its first Measurement
        that has one), Place (machine, gateway, the Zone: lan, vpn, wan, none; events for
        a Server no Measurement names, linked for one only Links name), M, the indexes of
        its Measurements in -Measurement, and E, the indexes of its Timed Events in -TimedEvent, each in the order they were
        handed in. In each Place the nodes stand by their first Role, then by name.
    .PARAMETER Map
        The Network Map of the Run. Where none is handed in it is made here from the
        Measurements and the Timed Events, by the same New-NetworkMap: there is one rule of
        which Server is which. A Map handed in is made of these records, or of copies of
        them: a record is found on its Server by what it says, by the rule the Map placed it
        by (Find-NetworkMapServer), never as the same object.
    #>

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

    $all = @($Measurement | Where-Object { $_ })
    $events = @($TimedEvent | Where-Object { $_ })
    if ($null -eq $Map) { $Map = New-NetworkMap -Measurement $all -TimedEvent $events }

    # The Server of each record, by what the record says and by the Map's own rule
    # (Find-NetworkMapServer): the Server the Map put it on, a copy of it too. A Measurement
    # of the machine or one of its adapters is the machine's; a Timed Event of another lane
    # than a Server's is no Server's. Asked once for each way a record names a Server.
    $serverKeyOf = @{}
    $keyOf = {
        param([string]$Name, [string]$Address, [string]$Role)
        $asked = '{0}|{1}|{2}' -f $Name, $Address, $Role
        if (-not $serverKeyOf.ContainsKey($asked)) {
            $server = Find-NetworkMapServer -Map $Map -Name $Name -Address $Address -Role $Role
            $serverKeyOf[$asked] = $(if ($server) { "$($server.Key)" } else { '' })
        }
        $serverKeyOf[$asked]
    }
    $indexesOf = @{}
    $place = {
        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)
    }
    $machineIndexes = New-Object System.Collections.Generic.List[int]
    for ($i = 0; $i -lt $all.Count; $i++) {
        $role = "$(Get-DataProperty $all[$i] 'Role')"
        if ($role -in 'Machine', 'Adapter') { $machineIndexes.Add($i); continue }
        & $place (& $keyOf "$(Get-DataProperty $all[$i] 'Subject')" "$(Get-DataProperty $all[$i] 'Address')" $role) 'M' $i
    }
    for ($i = 0; $i -lt $events.Count; $i++) {
        if ("$(Get-DataProperty $events[$i] 'Lane')" -ne 'Server') { continue }
        & $place (& $keyOf "$(Get-DataProperty $events[$i] 'Subject')" "$(Get-DataProperty $events[$i] 'Address')" '') 'E' $i
    }
    $indexes = { param([string]$Key, [string]$Which) [int[]]@($(if ($indexesOf.ContainsKey($Key)) { $indexesOf[$Key][$Which].ToArray() })) }

    $rank = { param($Role) $at = [array]::IndexOf($script:ReportNetworkRoleOrder, $Role); if ($at -lt 0) { 99 } else { $at } }
    $placed = New-Object System.Collections.Generic.List[object]
    $mine = [int[]]$machineIndexes.ToArray()
    if ($mine.Count) {
        $placed.Add([pscustomobject]@{ Key = 'machine'; Label = ''; Names = [string[]]@(); Addresses = [string[]]@(); Roles = [string[]]@(); Zone = ''; Place = 'machine'; M = $mine; E = [int[]]@(); Rank = 0 })
    }

    foreach ($server in @((Get-DataCollection $Map 'Servers') | Where-Object { $_ })) {
        $m = & $indexes "$($server.Key)" 'M'
        $e = & $indexes "$($server.Key)" 'E'
        $roles = @(@($server.Roles) | Where-Object { $_ } | Sort-Object { & $rank $_ })
        # The name as it was written where it was measured or logged: the subject, not the
        # lower case the Map keeps, and without the port, UNC path, trailing dot or web
        # address around it.
        $written = @{}
        $subjects = @(@($m | ForEach-Object { $all[$_] }) + @($e | ForEach-Object { $events[$_] }) | ForEach-Object { "$(Get-DataProperty $_ 'Subject')".Trim() })
        foreach ($subject in $subjects) {
            $name = (ConvertTo-NetworkMapSighting -Name $subject).Name
            if (-not $name -or $written.ContainsKey($name)) { continue }
            $at = $subject.IndexOf($name, [StringComparison]::OrdinalIgnoreCase)
            $written[$name] = if ($at -ge 0) { $subject.Substring($at, $name.Length) } else { $name }
        }
        $names = @(@($server.Names) | Where-Object { $_ } | ForEach-Object { if ($written.ContainsKey($_)) { $written[$_] } else { $_ } })
        # Shown at the address every Gatherer would choose, the others said after it.
        $first = Select-ServerAddress -Address @($server.Addresses)
        $addresses = @(@($server.Addresses | Where-Object { $_ -eq $first }) + @($server.Addresses | Where-Object { $_ -and $_ -ne $first }))
        $label = @($names | Where-Object { $_.Contains('.') }) + @($names) + $addresses | Select-Object -First 1
        $zone = @($m | ForEach-Object { "$(Get-DataProperty $all[$_] 'Zone')" } | Where-Object { $_ }) | Select-Object -First 1
        $place = if (-not $m.Count -and -not $e.Count) { 'linked' }
                 elseif (-not $m.Count) { 'events' }
                 elseif ($roles -contains 'Gateway') { 'gateway' }
                 elseif ($zone) { $zone.ToLowerInvariant() }
                 else { 'none' }
        $placed.Add([pscustomobject]@{
            Key = "$($server.Key)"; Label = "$label"; Names = [string[]]$names; Addresses = [string[]]$addresses; Roles = [string[]]$roles
            Zone = "$zone"; Place = $place; M = $m; E = $e; Rank = $(if ($roles.Count) { & $rank $roles[0] } else { 99 })
        })
    }

    $n = 0
    foreach ($node in @($placed | Sort-Object @{ Expression = { $_.Place -ne 'machine' } }, Rank, Label)) {
        $id = if ($node.Place -eq 'machine') { 'machine' } else { $n++; 'n{0}' -f $n }
        [pscustomobject]@{
            Id = $id; Key = $node.Key; Label = $node.Label; Names = $node.Names; Addresses = $node.Addresses; Roles = $node.Roles
            Zone = $node.Zone; Place = $node.Place; M = $node.M; E = $node.E
        }
    }
}

function Get-ReportNetworkGroup {
    <#
    .SYNOPSIS
        The groups the Weg stands its Servers in: by subnet, and the internet. Pure.
    .DESCRIPTION
        A Server goes, by the first of these that holds:
 
        1. own: into a subnet of the machine's own, by the address and prefix length of an
           adapter (its LinkSpeed Measurement) - as exactly as Windows gave it - or by a
           Subnet the Network Map's Links put an adapter in (-Map).
        2. vpn, wan: behind a VPN or on the internet, by its Zone.
        3. assumed: in the Zone Lan, or an address of a private range, yet in no own subnet -
           behind a router - into the /24 (IPv4) or /64 (IPv6) around it. The prefix is not
           known there, and the Weg says it was assumed.
        4. wan: any other address, as for a Server only the events know, which has no Zone.
        5. none: a Server without an address.
 
        On the left what the machine reaches by itself: its own subnets, in the order of the
        adapters, then those without an address. On the right what lies beyond the gateway:
        the assumed subnets by address - another subnet is reached through a router, and the
        gateway is the one the Run knows - then vpn and wan. Within a group the gateway
        first, then the DNS and DHCP servers, then the rest, each in the order the nodes
        came in.
 
        Returns each group as id, kind, net (the subnet as 192.0.2.0/24, or empty), adapter
        (the adapters of an own subnet), side (left or right) and n (the ids of its nodes).
    .PARAMETER Node
        As Get-ReportNetworkNode makes them.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param([AllowNull()][AllowEmptyCollection()]$Node, [AllowNull()][AllowEmptyCollection()]$Measurement, [AllowNull()]$Map)

    $cidr = { param($Address, $Length) '{0}/{1}' -f (Get-SubnetAddress -Address $Address -PrefixLength $Length), $Length }
    # The machine's own subnets, each once, with every adapter that is in it: by its Key, as
    # New-LinkEnd keys one, called as Windows writes its alias.
    $own = [ordered]@{}
    $addAdapter = {
        param($Net, [string]$Name)
        if (-not $Name) { return }
        $key = (New-LinkEnd -Thing Adapter -Name $Name).Key
        if (-not $own[$Net].Adapters.Contains($key)) { $own[$Net].Adapters[$key] = $Name.Trim() }
    }
    foreach ($m in @($Measurement | Where-Object { $_ -and "$(Get-DataProperty $_ 'Role')" -eq 'Adapter' })) {
        $at = ConvertTo-NetworkAddress -Address "$(Get-DataProperty $m 'Address')"
        $length = ConvertTo-Number (Get-DataProperty $m 'PrefixLength')
        if ($null -eq $at -or $null -eq $length) { continue }
        $net = & $cidr $at ([int]$length)
        if (-not $own.Contains($net)) { $own[$net] = @{ At = $at; Length = [int]$length; Adapters = [ordered]@{} } }
        & $addAdapter $net "$(Get-DataProperty $m 'Subject')"
    }
    # And each Subnet the Network Map's Links put an adapter in (InSubnet): the Map's
    # observed Subnets, so that the Weg and what hangs together under it say the same.
    $subnets = @{}
    foreach ($subnet in (Get-DataCollection $Map 'Subnets')) { if ($subnet) { $subnets["$($subnet.Key)"] = $subnet } }
    $adapterName = @{}
    foreach ($a in (Get-DataCollection $Map 'Adapters')) { if ($a) { $adapterName["$($a.Key)"] = "$($a.Name)" } }
    foreach ($link in @((Get-DataCollection $Map 'Links') | Where-Object { $_ -and $_.Kind -eq 'InSubnet' })) {
        $subnet = $subnets["$($link.To.Key)"]
        $at = $(if ($subnet) { ConvertTo-NetworkAddress -Address "$($subnet.Address)" })
        if ($null -eq $at) { continue }
        $net = & $cidr $at ([int]$subnet.PrefixLength)
        if (-not $own.Contains($net)) { $own[$net] = @{ At = $at; Length = [int]$subnet.PrefixLength; Adapters = [ordered]@{} } }
        $key = "$($link.From.Key)"
        & $addAdapter $net $(if ($adapterName.ContainsKey($key)) { $adapterName[$key] } else { $key })
    }

    $groups = [ordered]@{}
    $put = {
        param($Key, $Kind, $Net, $Adapter, $Side, $Sort, $Id)
        if (-not $groups.Contains($Key)) {
            $groups[$Key] = @{ kind = $Kind; net = $Net; adapter = $Adapter; side = $Side; sort = $Sort; n = New-Object System.Collections.Generic.List[object] }
        }
        $groups[$Key].n.Add($Id)
    }
    foreach ($n in @($Node | Where-Object { $_ -and $_.Place -ne 'machine' })) {
        $addresses = @($n.Addresses | ForEach-Object { ConvertTo-NetworkAddress -Address $_ } | Where-Object { $_ })
        $mine = $null
        foreach ($net in $own.Keys) {
            if (@($addresses | Where-Object { Test-AddressInPrefix -Address $_ -Network $own[$net].At -PrefixLength $own[$net].Length }).Count) { $mine = $net; break }
        }
        if ($mine) { & $put "own|$mine" 'own' $mine (@($own[$mine].Adapters.Values) -join ', ') 'left' ('0|{0:D3}' -f @($own.Keys).IndexOf($mine)) $n.Id; continue }
        if ($n.Zone -in 'Vpn', 'Wan') { & $put $n.Zone.ToLowerInvariant() $n.Zone.ToLowerInvariant() '' '' 'right' $(if ($n.Zone -eq 'Vpn') { '1' } else { '2' }) $n.Id; continue }
        if (-not $addresses.Count) { & $put 'none' 'none' '' '' 'left' '1' $n.Id; continue }
        # Lan as the Judge said it, or as the address says it of a Server only the events know.
        $first = $addresses[0]
        if ($n.Zone -eq 'Lan' -or (Get-MeasurementZone -Address $first.ToString()) -eq 'Lan') {
            $length = if ($first.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { 24 } else { 64 }
            $net = & $cidr $first $length
            $order = (@((Get-SubnetAddress -Address $first -PrefixLength $length).GetAddressBytes() | ForEach-Object { '{0:D3}' -f $_ })) -join '.'
            & $put "assumed|$net" 'assumed' $net '' 'right' ('0|{0}' -f $order) $n.Id
            continue
        }
        & $put 'wan' 'wan' '' '' 'right' '2' $n.Id
    }

    # In a group the gateway first, then the network's services, then the rest, each as it came.
    $rank = @{}
    foreach ($n in @($Node | Where-Object { $_ })) {
        $rank[$n.Id] = if ($n.Roles -contains 'Gateway') { 0 } elseif ($n.Roles -contains 'DnsServer' -or $n.Roles -contains 'DhcpServer') { 1 } else { 2 }
    }
    $k = 0
    foreach ($side in 'left', 'right') {
        foreach ($g in @($groups.Values | Where-Object { $_.side -eq $side } | Sort-Object { $_.sort })) {
            $k++
            $ids = $g.n.ToArray()
            $ordered = @(0..($ids.Count - 1) | Sort-Object { $rank[$ids[$_]] }, { $_ } | ForEach-Object { $ids[$_] })
            [ordered]@{ id = 'g{0}' -f $k; kind = $g.kind; net = $g.net; adapter = $g.adapter; side = $g.side; n = [string[]]$ordered }
        }
    }
}

function Get-ReportNetworkLink {
    <#
    .SYNOPSIS
        The Links of the Network Map as the Weg draws them: each Link with the nodes it
        joins, each adapter with its servers, each Domain with its controllers by site, each
        Namespace with where it leads, and each node's Links in words. Pure.
    .DESCRIPTION
        Read off the Map as it is (ADR-0012): nothing is merged or assumed here, and a Link
        colours nothing - it says how things are connected, not whether they work. Where a
        Server stands in the picture and in what colour stays the nodes' business.
 
        Returns:
        - l: every Link of the Map, as it was observed - kind, from and to (thing, key, and
          n, the id of the node it is, or empty for what is no Server), and check.
        - adapters: each adapter of the Map - key, name, subnets (the Subnets' keys), gateway,
          dns, dhcp (the ids of the nodes it uses as each) - and last the Target Machine,
          key "machine", for what it uses where no adapter is known (DHCP, as the Network
          Judge says it), where it uses anything.
        - domains: each Domain - key; sites, each with key, name and n, the nodes of the
          Servers in it; and unsited, its controllers no Link puts in a site.
        - namespaces: each Namespace - key, domain (the one it belongs to, or empty) and
          targets, each share (the share's key, empty where it leads to the Server itself)
          and n, the node of the Server it lies on. A target no Check measured is a node all
          the same, without numbers.
        - r: the Links of each node in words, by its id.
    .PARAMETER Node
        As Get-ReportNetworkNode made them of this Map.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param([AllowNull()]$Map, [AllowNull()][AllowEmptyCollection()]$Node)

    $servers = @{}
    $machine = ''
    foreach ($n in @($Node | Where-Object { $_ })) {
        if ($n.Place -eq 'machine') { $machine = $n.Id } else { $servers[$n.Key] = $n.Id }
    }
    $nodeOf = {
        param($End)
        $key = "$($End.Key)"
        if ($End.Thing -eq 'Server' -and $servers.ContainsKey($key)) { return $servers[$key] }
        if ($End.Thing -eq 'Machine') { return $machine }
        ''
    }
    $shareOn = @{}
    foreach ($share in (Get-DataCollection $Map 'Shares')) { if ($share) { $shareOn["$($share.Key)"] = "$($share.Server)" } }
    $nodeOfShare = { param([string]$Key) $(if ($shareOn.ContainsKey($Key) -and $servers.ContainsKey($shareOn[$Key])) { $servers[$shareOn[$Key]] } else { '' }) }
    # Each adapter by its Key, called as Windows writes its alias (New-LinkEnd).
    $adapterName = @{}
    foreach ($a in (Get-DataCollection $Map 'Adapters')) { if ($a) { $adapterName["$($a.Key)"] = "$($a.Name)" } }
    $siteName = @{}
    foreach ($s in (Get-DataCollection $Map 'Sites')) { if ($s) { $siteName["$($s.Key)"] = "$($s.Name)" } }

    # Each in a list, once: the same Link from two Checks is two Links, and one target.
    $add = { param($List, $Item) if ("$Item" -and -not $List.Contains($Item)) { $List.Add($Item) } }
    $newList = { , (New-Object System.Collections.Generic.List[string]) }
    $links = @((Get-DataCollection $Map 'Links') | Where-Object { $_ })

    $l = New-Object System.Collections.Generic.List[object]
    foreach ($link in $links) {
        $l.Add([ordered]@{
            kind  = "$($link.Kind)"
            from  = [ordered]@{ thing = "$($link.From.Thing)"; key = "$($link.From.Key)"; n = (& $nodeOf $link.From) }
            to    = [ordered]@{ thing = "$($link.To.Thing)"; key = "$($link.To.Key)"; n = (& $nodeOf $link.To) }
            check = "$($link.CheckName)"
        })
    }

    # The adapters, in the Map's order, and the Target Machine for what no adapter is known for.
    $adapters = [ordered]@{}
    $entry = {
        param([string]$Key, [string]$Name)
        if (-not $adapters.Contains($Key)) {
            $adapters[$Key] = [ordered]@{ key = $Key; name = $Name; subnets = (& $newList); gateway = (& $newList); dns = (& $newList); dhcp = (& $newList) }
        }
        $adapters[$Key]
    }
    foreach ($a in (Get-DataCollection $Map 'Adapters')) { if ($a) { $null = & $entry "$($a.Key)" "$($a.Name)" } }
    $own = $null
    $uses = @{ UsesGateway = 'gateway'; UsesDns = 'dns'; UsesDhcp = 'dhcp' }
    foreach ($link in $links) {
        if ($link.Kind -eq 'InSubnet' -and $link.From.Thing -eq 'Adapter') { & $add (& $entry "$($link.From.Key)" '').subnets "$($link.To.Key)" }
        if (-not $uses.ContainsKey("$($link.Kind)")) { continue }
        if ($link.From.Thing -eq 'Adapter') { $user = & $entry "$($link.From.Key)" '' }
        else {
            if (-not $own) { $own = [ordered]@{ key = 'machine'; name = ''; subnets = (& $newList); gateway = (& $newList); dns = (& $newList); dhcp = (& $newList) } }
            $user = $own
        }
        & $add $user[$uses["$($link.Kind)"]] (& $nodeOf $link.To)
    }
    $adapterList = @(@($adapters.Values) + @($own | Where-Object { $_ }) | ForEach-Object {
        [ordered]@{ key = $_.key; name = $_.name; subnets = $_.subnets.ToArray(); gateway = $_.gateway.ToArray(); dns = $_.dns.ToArray(); dhcp = $_.dhcp.ToArray() }
    })

    # The Domains: their controllers, each in the site a Link puts it in, and the rest together.
    $domainList = @(foreach ($domain in @((Get-DataCollection $Map 'Domains') | Where-Object { $_ })) {
        $key = "$($domain.Key)"
        $controllers = & $newList
        foreach ($link in @($links | Where-Object { $_.Kind -eq 'ControllerOf' -and "$($_.To.Key)" -eq $key })) { & $add $controllers (& $nodeOf $link.From) }
        $sited = & $newList
        $sites = @(foreach ($site in @((Get-DataCollection $Map 'Sites') | Where-Object { $_ -and "$($_.Domain)" -eq $key })) {
            $in = & $newList
            foreach ($link in @($links | Where-Object { $_.Kind -eq 'InSite' -and "$($_.To.Key)" -eq "$($site.Key)" })) {
                $id = & $nodeOf $link.From
                & $add $in $id
                & $add $sited $id
            }
            [ordered]@{ key = "$($site.Key)"; name = "$($site.Name)"; n = $in.ToArray() }
        })
        [ordered]@{ key = $key; sites = $sites; unsited = @($controllers | Where-Object { -not $sited.Contains($_) }) }
    })

    # The Namespaces, each with where it leads: a share and the Server it lies on, or a Server.
    $namespaceList = @(foreach ($namespace in @((Get-DataCollection $Map 'Namespaces') | Where-Object { $_ })) {
        $key = "$($namespace.Key)"
        $of = @($links | Where-Object { $_.Kind -eq 'NamespaceOf' -and "$($_.From.Key)" -eq $key } | ForEach-Object { "$($_.To.Key)" }) | Select-Object -First 1
        $seen = @{}
        $targets = @(foreach ($link in @($links | Where-Object { $_.Kind -eq 'LeadsTo' -and $_.From.Thing -eq 'Namespace' -and "$($_.From.Key)" -eq $key })) {
            $share = $(if ($link.To.Thing -eq 'Share') { "$($link.To.Key)" } else { '' })
            $id = $(if ($share) { & $nodeOfShare $share } else { & $nodeOf $link.To })
            if ($seen.ContainsKey("$share|$id")) { continue }
            $seen["$share|$id"] = $true
            [ordered]@{ share = $share; n = $id }
        })
        [ordered]@{ key = $key; domain = "$of"; targets = $targets }
    })

    # Each node's Links in words, in the order they were observed, each once.
    $role = @{
        UsesGateway = Get-Text 'Report.Network.Role.Gateway'
        UsesDns     = Get-Text 'Report.Network.Role.DnsServer'
        UsesDhcp    = Get-Text 'Report.Network.Role.DhcpServer'
    }
    $leading = @{}
    foreach ($link in @($links | Where-Object { $_.Kind -eq 'LeadsTo' -and $_.To.Thing -eq 'Share' })) {
        $share = "$($link.To.Key)"
        if (-not $leading.ContainsKey($share)) { $leading[$share] = & $newList }
        & $add $leading[$share] $(if ($link.From.Thing -eq 'Drive') { (Get-Text 'Report.Network.Link.Drive') -f $link.From.Key } else { "$($link.From.Key)" })
    }
    $r = [ordered]@{}
    $say = { param([string]$Id, [string]$Text) if (-not $Id) { return }; if (-not $r.Contains($Id)) { $r[$Id] = & $newList }; & $add $r[$Id] $Text }
    foreach ($link in $links) {
        $kind = "$($link.Kind)"
        if ($kind -eq 'ControllerOf') { & $say (& $nodeOf $link.From) ((Get-Text 'Report.Network.Link.ControllerOf') -f $link.To.Key) }
        elseif ($kind -eq 'InSite') {
            $name = $(if ($siteName.ContainsKey("$($link.To.Key)")) { $siteName["$($link.To.Key)"] } else { "$($link.To.Key)" })
            & $say (& $nodeOf $link.From) ((Get-Text 'Report.Network.Link.InSite') -f $name)
        }
        elseif ($uses.ContainsKey($kind)) {
            $text = if ($link.From.Thing -eq 'Adapter') {
                $alias = "$($link.From.Key)"
                if ($adapterName.ContainsKey($alias)) { $alias = $adapterName[$alias] }
                (Get-Text 'Report.Network.Link.Uses') -f $role[$kind], $alias
            }
            else { (Get-Text 'Report.Network.Link.UsesMachine') -f $role[$kind] }
            & $say (& $nodeOf $link.To) $text
        }
        elseif ($kind -eq 'LeadsTo' -and $link.To.Thing -eq 'Server') { & $say (& $nodeOf $link.To) ((Get-Text 'Report.Network.Link.LeadsTo') -f $link.From.Key) }
        elseif ($kind -eq 'OnServer' -and $link.From.Thing -eq 'Share') {
            $share = "$($link.From.Key)"
            $text = if ($leading.ContainsKey($share)) { (Get-Text 'Report.Network.Link.OnServerFrom') -f $share, ($leading[$share] -join ', ') } else { (Get-Text 'Report.Network.Link.OnServer') -f $share }
            & $say (& $nodeOf $link.To) $text
        }
        elseif ($kind -eq 'OnServer') { & $say (& $nodeOf $link.To) ((Get-Text 'Report.Network.Link.Service') -f $link.From.Key) }
    }
    $words = [ordered]@{}
    foreach ($id in $r.Keys) { $words[$id] = $r[$id].ToArray() }

    [ordered]@{ l = $l.ToArray(); adapters = $adapterList; domains = $domainList; namespaces = $namespaceList; r = $words }
}

function Get-ReportNetworkUnknown {
    <#
    .SYNOPSIS
        What the Weg's Links cannot show because no Check that ran could have observed it,
        in words: "the Domain: not checked in this Run". Pure.
    .DESCRIPTION
        Read off the Map's coverage (ADR-0012): a part is unknown where the kind of Link it
        is drawn from, by the thing it starts from, is in Coverage.Unknown - the adapters by
        HasAdapter:Machine, the Domain by ControllerOf:Server, a controller's site by
        InSite:Server, where a Namespace leads by LeadsTo:Namespace, and the mapped drives and
        the shares they lead to by LeadsTo:Drive or OnServer:Share: a part of several kinds is
        unknown where any of them is. Where no Kind of this module observes it
        (Coverage.Unobservable), that Gutcheck does not read it, whatever ran: a controller's
        site. Else of the Scope where
        a Check that would have observed it was left out by the Scope, of the rights where
        one lacked them, and of the Run otherwise, as Get-ReportNetworkGap says it. Empty
        where the part is known - and for every part where the Map knows of no Check at
        all: a Report made without a Run does not say what it performed.
 
        Returns adapters, domain, site, namespace and drives, each the words or empty.
    #>

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

    $said = [ordered]@{ adapters = ''; domain = ''; site = ''; namespace = ''; drives = '' }
    $coverage = Get-DataProperty $Map 'Coverage'
    $checks = @((Get-DataCollection $coverage 'Checks') | Where-Object { $_ })
    if (-not $checks.Count) { return $said }
    $unknown = @((Get-DataCollection $coverage 'Unknown') | ForEach-Object { "$_" })
    $unobservable = @((Get-DataCollection $coverage 'Unobservable') | ForEach-Object { "$_" })
    $parts = @(
        , @('adapters', @('HasAdapter:Machine'), (Get-Text 'Report.Network.Map.Part.Adapters'))
        , @('domain', @('ControllerOf:Server'), (Get-Text 'Report.Network.Map.Part.Domain'))
        , @('site', @('InSite:Server'), (Get-Text 'Report.Network.Map.Part.Site'))
        , @('namespace', @('LeadsTo:Namespace'), (Get-Text 'Report.Network.Map.Part.Namespace'))
        , @('drives', @('LeadsTo:Drive', 'OnServer:Share'), (Get-Text 'Report.Network.Map.Part.Drives'))
    )
    foreach ($part in $parts) {
        $missing = @($part[1] | Where-Object { $unknown -contains $_ })
        if (-not $missing.Count) { continue }
        $would = @($checks | Where-Object { $observes = @($_.Links); @($missing | Where-Object { $observes -contains $_ }).Count })
        $form = if (-not @($missing | Where-Object { $unobservable -notcontains $_ }).Count) { Get-Text 'Report.Network.Map.NotRead' }
                elseif (@($would | Where-Object { "$($_.Status)" -eq 'LeftOutByScope' }).Count) { Get-Text 'Report.Network.Gap.Scope' }
                elseif (@($would | Where-Object { "$($_.Status)" -eq 'LackedRights' }).Count) { Get-Text 'Report.Network.Map.Rights' }
                else { Get-Text 'Report.Network.Gap.Run' }
        $said[$part[0]] = $form -f $part[2]
    }
    $said
}

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'
        }
        # The groups of the Weg that are subnets, not Zones (Get-ReportNetworkGroup).
        group = [ordered]@{
            own     = Get-Text 'Report.Network.Group.Own'
            assumed = Get-Text 'Report.Network.Group.Assumed'
        }
        zone = [ordered]@{
            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'
            Measured   = Get-Text 'Report.Network.Detail.Measured'
            NotAll     = Get-Text 'Report.Network.Detail.NotAll'
            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'
            Span       = Get-Text 'Report.Network.Detail.Span'
            Once       = Get-Text 'Report.Network.Detail.Once'
            Linked     = Get-Text 'Report.Network.Detail.Linked'
        }
        # How the Map's things hang together, under the Weg (Get-ReportNetworkLink).
        map = [ordered]@{
            Heading     = Get-Text 'Report.Network.Map.Heading'
            Note        = Get-Text 'Report.Network.Map.Note'
            Adapter     = Get-Text 'Report.Network.Map.Adapter'
            MachineUses = Get-Text 'Report.Network.Map.MachineUses'
            Domain      = Get-Text 'Report.Network.Map.Domain'
            Site        = Get-Text 'Report.Network.Map.Site'
            NoSite      = Get-Text 'Report.Network.Map.NoSite'
            Namespace   = Get-Text 'Report.Network.Map.Namespace'
            Nothing     = Get-Text 'Report.Network.Map.Nothing'
        }
        # 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. The part is the one whose key
        is the record's, as they are: the keys are those of the Network Map, which knows that
        "FS01", "fs01.contoso.example." and fs01 with a port behind it are one Server.
    .PARAMETER Record
        A row of the model: key 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]
    $key = "$($Record['key'])"
    if ($key) {
        foreach ($part in @($f.subjects)) {
            if ("$($part.key)" -ceq $key) { return "$($part.s)" }
        }
    }
    "$($f.s)"
}

function Get-ReportNetworkPartKey {
    <#
    .SYNOPSIS
        The key of the Server a Finding's part is about, however the Judge spelled its
        Subject: another case, a short name, a trailing dot, a port, a UNC path. Pure.
    .DESCRIPTION
        The Server of the Network Map the Subject is, as the Map finds it
        (Find-NetworkMapServer): where it is several, the one that holds a record of the
        Finding - "FS01" of a Finding about fs01.contoso.example is that Server, not
        fs01.fabrikam.example. A Subject no Server is - a program, an adapter - is its own
        key, its name or address as the Map reads it (ConvertTo-NetworkMapSighting), and so
        is the key of a record on no Server.
    .PARAMETER Map
        The Network Map the nodes are the Servers of.
    .PARAMETER Holding
        The keys of the nodes that hold a record of the part's Finding.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Subject, [AllowNull()]$Map, [AllowNull()][AllowEmptyCollection()][string[]]$Holding)

    $server = $(if ($null -ne $Map) { Find-NetworkMapServer -Map $Map -Name $Subject -Prefer @($Holding) })
    if ($server) { return "$($server.Key)" }
    $seen = ConvertTo-NetworkMapSighting -Name $Subject
    $(if ($seen.Name) { $seen.Name } else { $seen.Address })
}

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, and key, the key of its Server in the Network Map. 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 key
        of its Server (Get-ReportNetworkPartKey), the Severity of its part and how the events
        counted for it were spread in time, in words ({subject, key, s, pattern}). One naming
        a Finding that is not in the Report names none here, and is drawn grey. The script
        finds the part of a record by its key as it is, and decides no identity itself. n:
        the nodes of Get-ReportNetworkNode, the Map's Servers - id, key (the Map's), label,
        names, addresses, roles, zone, place - 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, and r its Links in words. g: the groups the Weg
        stands the nodes in, by subnet (Get-ReportNetworkGroup). l, adapters, domains and
        namespaces: the Map's Links, each end with its node, and what they say of each
        adapter, Domain and Namespace (Get-ReportNetworkLink). unknown: what no Check that ran
        could have observed, in words (Get-ReportNetworkUnknown). 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 - key, as a Measurement's, 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 are the one Server of the Network Map, and no
        part of the chapter decides which Server is which.
    .PARAMETER Word
        The Report's own, for where each Finding stands (Get-ReportFindingAnchor).
    .PARAMETER NetworkMap
        The Run's Network Map, made of these Measurements and Timed Events (New-NetworkMap).
        Where none is handed in - a Report made without a Run - it is made here of them.
    #>

    [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),
        [AllowNull()]$NetworkMap
    )

    $all = @($Measurement | Where-Object { $_ })
    if ($null -eq $NetworkMap) { $NetworkMap = New-NetworkMap -Measurement $all -TimedEvent @($TimedEvent | 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', 'PrefixLength')) }

    # 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') }
    })

    # The Map's Servers for the path and the 30 days: the nodes, and of them those with
    # events are the Servers' lanes.
    $mapped = @(Get-ReportNetworkNode -Measurement $all -TimedEvent $events -Map $NetworkMap)

    # The key of each record: that of its Server. A record on no Server - the machine's
    # adapters, its own events, the markers - is keyed by its Subject, as a part about no
    # Server is (Get-ReportNetworkPartKey).
    foreach ($row in @($rows.ToArray()) + @($timed.ToArray())) { $row.key = Get-ReportNetworkPartKey -Subject $row.Subject }
    $holding = @{}
    foreach ($node in $mapped) {
        if ($node.Place -eq 'machine') { continue }
        foreach ($index in $node.M) { $rows[$index].key = $node.Key }
        foreach ($index in $node.E) { $timed[$index].key = $node.Key }
        foreach ($row in @($node.M | ForEach-Object { $rows[$_] }) + @($node.E | ForEach-Object { $timed[$_] })) {
            if (-not $row.Finding) { continue }
            if (-not $holding.ContainsKey($row.Finding)) { $holding[$row.Finding] = New-Object System.Collections.Generic.List[string] }
            $holding[$row.Finding].Add($node.Key)
        }
    }

    $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]
        $held = if ($holding.ContainsKey($id)) { $holding[$id].ToArray() } else { @() }
        $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 {
                $subject = "$(Get-DataProperty $_ 'Subject')"
                [ordered]@{
                    subject = $subject; key = Get-ReportNetworkPartKey -Subject $subject -Map $NetworkMap -Holding $held
                    s = "$(Get-DataProperty $_ 'Severity')"; pattern = "$(Get-DataProperty $_ 'PatternText')"
                } })
        }
    }

    # How the Map's things hang together, as its Links say: for the Weg and each node's detail.
    $linked = Get-ReportNetworkLink -Map $NetworkMap -Node $mapped

    $order = @{}
    for ($i = 0; $i -lt $script:SeverityValues.Count; $i++) { $order[$script:SeverityValues[$i]] = $i }
    $nodes = @($mapped | 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; key = $_.Key; label = $_.Label; names = @($_.Names); addresses = @($_.Addresses); roles = @($_.Roles)
            zone = $_.Zone; place = $_.Place; s = $worst; m = @($_.M); e = @($_.E)
            r = @($(if ($linked.r.Contains($_.Id)) { $linked.r[$_.Id] }))
        }
    })
    $lanes = @()
    $k = 0
    foreach ($node in @($mapped | 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 }) }

    # The groups of the Weg. Into a list and not @(), for the reason m is.
    $grouped = New-Object System.Collections.Generic.List[object]
    foreach ($g in (Get-ReportNetworkGroup -Node $mapped -Measurement $all -Map $NetworkMap)) { $grouped.Add($g) }

    [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
        # The groups of the Weg, by subnet (Get-ReportNetworkGroup).
        g       = $grouped.ToArray()
        # The Map's Links, and what they say of the adapters, Domains and Namespaces
        # (Get-ReportNetworkLink); what no Check that ran could have observed (Get-ReportNetworkUnknown).
        l          = $linked.l
        adapters   = $linked.adapters
        domains    = $linked.domains
        namespaces = $linked.namespaces
        unknown    = Get-ReportNetworkUnknown -Map $NetworkMap
        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,

        # The Run's Network Map, whose Servers the nodes are (New-NetworkMap). Made here of
        # the Measurements and Timed Events where it is not given.
        [AllowNull()]$NetworkMap
    )

    $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 -NetworkMap $NetworkMap
    $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
    }
}