Private/ReportNetworkTimeline.ps1

# The timeline view of "Netzwerk im Bild" (ReportNetwork.ps1): the 30 days before the Run,
# drawn from the Timed Events (ADR-0010). One lane per Server - merged as the nodes of the
# path are, so that one Server is one lane however Windows logged it -, the machine's own
# lane, the crashes, hangs, starts and sign-ins as markers above them, and the logs below,
# each with how far back it reaches. Under it a day-by-hour strip with the totals per day
# and per hour, for every lane or for the one whose name was clicked.
#
# An hour with at least one event is one stroke, in the colour of the Finding that judged
# that kind of event, grey where none does: the timeline raises no alarm the Findings do
# not. Where none of the logs a lane's events come from reaches back over the 30 days, the
# stretch before the earliest of them is hatched: an empty stretch there is not a quiet one.
# From when on is the model's (Get-ReportNetworkCoverage), not the script's.
#
# The geometry is the script's and is looked at, not tested: a Report rendered in a
# headless browser from fixture Timed Events.

$script:ReportNetworkTimelineCss = @'
<style>
/* Netzwerk im Bild: the 30 days (ReportNetworkTimeline.ps1). */
.nettimelegend{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;color:var(--dim);font-size:12.5px;margin:0 0 8px}
.nettimelegend span{display:inline-flex;gap:6px;align-items:center}
.nettime .grid{stroke:var(--line);stroke-width:1}
.nettime .wkend{fill:var(--panel2)}
.nettime .run{stroke:var(--accent);stroke-width:1.5}
.nettime .head{font:600 10.5px var(--mono);letter-spacing:.1em;fill:var(--dim)}
.nettime .lab{cursor:pointer}
.nettime .lab:hover text,.nettime .lab.sel text{fill:var(--accent)}
.nettime .lab.sel text{font-weight:600}
.nettime .lab rect{fill:transparent}
.nettime .ev{fill:var(--c)}
.nettime .ev.NONE{fill:var(--faint);fill-opacity:.75}
.nettime .mk{fill:var(--c);stroke:var(--panel);stroke-width:1}
.nettime .mk.NONE{fill:var(--faint)}
.nettime .mk.ring{fill:none;stroke:var(--c);stroke-width:2}
.nettime .mk.ring.NONE{stroke:var(--faint)}
.nettime .hit{cursor:default}
.nettime .hit.go{cursor:pointer}
.nettime .hit:focus{outline:none}
.nettime .hit:hover .ev,.nettime .hit:focus .ev,.nettime .hit.sel .ev{stroke:var(--text);stroke-width:1}
.nettime .gap{fill:var(--faint);fill-opacity:.12;stroke:var(--faint);stroke-opacity:.5;stroke-dasharray:3 3}
.nettime .hatch{stroke:var(--faint);stroke-opacity:.35;stroke-width:1}
.nettime .cov{stroke:var(--dim);stroke-width:3}
.nettimeout{color:var(--faint);font-size:12px;margin:4px 2px 0}
.netheatbar{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;margin:14px 0 6px;color:var(--dim);font-size:12.5px}
.netheatbar b{color:var(--text);font-weight:600}
.netheatbar button{background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:2px 11px;color:var(--dim);font-size:12px}
.netheatbar button:hover{color:var(--text);border-color:var(--faint)}
.netheat .cell{fill:var(--accent)}
.netheat .zero{fill:var(--panel2)}
.netheat .tot{fill:var(--dim)}
@media print{
  .netheatbar button{display:none!important}
  .nettime .hit:hover .ev{stroke:none}
  .nettime,.netheat{-webkit-print-color-adjust:exact;print-color-adjust:exact}
}
</style>
'@


$script:ReportNetworkTimelineScript = @'
<script>
// The 30 days: a lane per Server, the machine's lane, the markers and the logs, one stroke per
// hour with an event; and the day-by-hour strip below, for all lanes or the one picked. Widths
// are counted and not measured: the chapter may be hidden when it is drawn.
if (window.GutcheckNet) GutcheckNet.view('timeline', function(root, net){
  var svg = root.querySelector('svg.nettime'), heat = root.querySelector('svg.netheat'), d = net.data, w = net.words, r = w.timeline, esc = net.esc;
  var headBox = root.querySelector('.netheathead'), resetBox = root.querySelector('.netheatreset'), outBox = root.querySelector('.nettimeout');
  if (!svg || !heat || !d.t) return;
  var DAYS = 30, ell = String.fromCharCode(8230), levels = net.levels;
 
  // A time as the model writes it, local, to the second.
  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(n){ return (n < 10 ? '0' : '') + n; }
  function dayText(t){ return two(t.getDate()) + '.' + two(t.getMonth() + 1) + '.'; }
  function timeText(t){ return two(t.getHours()) + ':' + two(t.getMinutes()) + ':' + two(t.getSeconds()); }
  function cut(s, n){ s = String(s); return s.length > n ? s.slice(0, Math.max(1, n - 1)) + ell : s; }
  var asof = parse(d.asof) || new Date();
  var day0 = new Date(asof.getFullYear(), asof.getMonth(), asof.getDate() - (DAYS - 1));
  // Which of the 30 days a time is on, counted in calendar days: a day with a change of the
  // clock is a day all the same.
  function dayOf(t){ return Math.round((new Date(t.getFullYear(), t.getMonth(), t.getDate()) - day0) / 864e5); }
  function dayAt(k){ return new Date(day0.getFullYear(), day0.getMonth(), day0.getDate() + k); }
 
  var W = 1100, LX = 236, RX = 44, PW = W - LX - RX, RH = 17, HW = PW / DAYS / 24;
  function xAt(t){ return LX + (dayOf(t) + (t.getHours() + t.getMinutes() / 60) / 24) / DAYS * PW; }
 
  // The events within the 30 days, each with its time and its row.
  var events = [], outside = 0;
  d.t.forEach(function(e, i){
    var at = parse(e.Time);
    if (!at) return;
    var k = dayOf(at);
    if (k < 0 || k >= DAYS) { outside++; return; }
    var row = e.row === 'marker' ? (e.Mark === 'Crash' || e.Mark === 'Hang' ? 'crash' : 'start') : e.row;
    events.push({ e: e, i: i, at: at, row: row, day: k, hour: at.getHours() });
  });
  if (outBox) { outBox.hidden = !outside; outBox.textContent = outside ? r.Outside.replace('{0}', outside) : ''; }
 
  // Where a row's stretch that none of its logs reaches ends: from the model (tr), which
  // says from when on the logs of the row's events cover it together.
  // A log's own row: its own reach.
  function gapOf(row){
    var c = row.log || (d.tr || {})[row.id];
    if (!c) return null;
    if (c.Mark === 'Unread') return { until: null, log: c.Log, unread: true };
    var at = parse(c.Time);
    return at && at > day0 ? { until: at, log: c.Log } : null;
  }
 
  // The rows, in their groups: the markers, this machine, the Servers, the logs.
  var rows = [], byId = {};
  function add(group, id, label, extra){ var x = { group: group, id: id, label: label, ev: [], logs: [] }; for (var k in extra) x[k] = extra[k]; rows.push(x); byId[id] = x; return x; }
  var hasMark = function(ids){ return events.some(function(x){ return ids.indexOf(x.row) >= 0; }); };
  if (hasMark(['crash'])) add(r.Group.Markers, 'crash', r.Row.Crash, { marker: true });
  if (hasMark(['start'])) add(r.Group.Markers, 'start', r.Row.Start, { marker: true });
  if (hasMark(['machine'])) add(r.Group.Machine, 'machine', d.machine || w.role.Machine, {});
  (d.tl || []).forEach(function(l){ add(r.Group.Servers, l.id, l.label, { lane: l }); });
  events.forEach(function(x){ var row = byId[x.row]; if (row) { row.ev.push(x); if (x.e.Log && row.logs.indexOf(x.e.Log) < 0) row.logs.push(x.e.Log); } });
  var logRows = [];
  (d.tc || []).forEach(function(c){ if (!byId['log:' + c.Log]) logRows.push(add(r.Group.Logs, 'log:' + c.Log, c.Log.replace(/^Microsoft-Windows-/, ''), { log: c, logs: [c.Log] })); });
 
  // The colour of an hour: the worst its events are drawn in, each by the part of its
  // Finding about its own Server, as every view colours (net.severity).
  function sevOf(list){ return net.worst(list.map(function(x){ return net.severity(x.e); })); }
  // The Finding a click leads to: that of the event drawn in the worst colour.
  function worstFinding(list){
    var best = null, drawn = '';
    list.forEach(function(x){
      var f = net.finding(x.e), s = net.severity(x.e);
      if (f && (!best || levels.indexOf(s) < levels.indexOf(drawn))) { best = f; drawn = s; }
    });
    return best;
  }
 
  // The events of one hour of one row, in words, with the Findings they belong to.
  function hourHtml(row, list){
    var t = list[0].at, h = t.getHours();
    var out = '<h4>' + esc(row.label) + '</h4><div class="sub">' + esc(r.InHour.replace('{0}', list.length).replace('{1}', dayText(t) + ' ' + two(h)).replace('{2}', two((h + 1) % 24))) + '</div><table>';
    var seen = {}, finds = [];
    list.slice(0, 8).forEach(function(x){
      var f = net.finding(x.e), what = [r.Mark[x.e.Mark] || x.e.Mark];
      if (x.e.Subject && !row.lane) what.push(x.e.Subject);
      if (x.e.Detail && x.e.Detail !== what[0]) what.push(x.e.Detail);
      if (row.lane && x.e.Subject && x.e.Subject !== row.label) what.push(x.e.Subject);
      if (x.e.Address && row.lane && x.e.Address !== row.label) what.push(x.e.Address);
      out += '<tr class="' + (net.severity(x.e) || 'NONE') + '"><td><span class="dot"></span> ' + esc(what.join(net.dot)) + '<small>' + esc(f ? f.t : w.detail.NoFinding) + '</small></td><td class="v">' + esc(timeText(x.at)) + '</td></tr>';
    });
    out += '</table>';
    if (list.length > 8) out += '<div class="none">' + esc(r.More.replace('{0}', list.length - 8)) + '</div>';
    list.forEach(function(x){ var f = net.finding(x.e); if (f && !seen[x.e.Finding]) { seen[x.e.Finding] = 1; finds.push(f); } });
    finds.sort(function(a, b){ return levels.indexOf(a.s) - levels.indexOf(b.s); }).forEach(function(f){
      out += '<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>';
    });
    return out;
  }
 
  // The hatching of a stretch a row's log does not reach: lines, since a Report draws no
  // pattern it would have to fetch by a name.
  function gapSvg(row, y){
    var gap = gapOf(row);
    if (!gap) return '';
    var x2 = gap.unread ? LX + PW : Math.min(LX + PW, xAt(gap.until)), h = RH - 4, o = '';
    var tip = gap.unread ? r.Log.GapUnread.replace('{0}', gap.log) : r.Log.Gap.replace('{0}', dayText(gap.until) + ' ' + timeText(gap.until).slice(0, 5)).replace('{1}', gap.log);
    o += '<g><title>' + esc(tip) + '</title><rect class="gap" x="' + LX + '" y="' + (y + 2) + '" width="' + Math.max(1, x2 - LX).toFixed(1) + '" height="' + h + '"/>';
    for (var x = LX + 6; x + h <= x2; x += 8) o += '<line class="hatch" x1="' + x.toFixed(1) + '" y1="' + (y + 2 + h) + '" x2="' + (x + h).toFixed(1) + '" y2="' + (y + 2) + '"/>';
    return o + '</g>';
  }
 
  var selected = '';
  function draw(){
    var TOP = 26, y = TOP, out = '', lastGroup = null, placed = [];
    rows.forEach(function(row){
      if (row.group !== lastGroup) { y += 6; out += '<text class="head" x="6" y="' + (y + 11) + '">' + esc(row.group.toUpperCase()) + '</text>'; y += 17; lastGroup = row.group; }
      placed.push({ row: row, y: y });
      y += RH;
    });
    var H = y + 22;
    // The days: weekends set apart, a line at each day, a date every second one, the Run.
    for (var k = 0; k < DAYS; k++) {
      var day = dayAt(k), x = LX + k / DAYS * PW, dw = PW / DAYS;
      if (day.getDay() === 0 || day.getDay() === 6) out = '<rect class="wkend" x="' + x.toFixed(1) + '" y="' + (TOP - 4) + '" width="' + dw.toFixed(1) + '" height="' + (H - TOP - 14) + '"/>' + out;
      out += '<line class="grid" x1="' + x.toFixed(1) + '" y1="' + (TOP - 4) + '" x2="' + x.toFixed(1) + '" y2="' + (H - 18) + '"/>';
      if ((DAYS - 1 - k) % 2 === 0) out += '<text class="faint" x="' + (x + 2).toFixed(1) + '" y="' + (TOP - 10) + '" font-size="10">' + esc(dayText(day)) + '</text>';
    }
    // The Run, with its word under the lanes, where no date stands.
    var rx = xAt(asof);
    out += '<line class="run" x1="' + rx.toFixed(1) + '" y1="' + (TOP - 4) + '" x2="' + rx.toFixed(1) + '" y2="' + (H - 14) + '"/>' +
      '<text class="faint" x="' + (rx - 3).toFixed(1) + '" y="' + (H - 4) + '" font-size="10" text-anchor="end">' + esc(r.Run + ' ' + dayText(asof) + ' ' + timeText(asof).slice(0, 5)) + '</text>';
 
    placed.forEach(function(p){
      var row = p.row, y = p.y;
      out += gapSvg(row, y);
      var lab = row.lane ? row.lane.label : row.label, tip = row.lane ? row.lane.names.concat(row.lane.addresses).join(net.dot) : (row.log ? row.log.Log : '');
      out += '<g class="lab' + (selected === row.id ? ' sel' : '') + '" data-row="' + esc(row.id) + '"><rect x="0" y="' + y + '" width="' + (LX - 6) + '" height="' + RH + '"/>' +
        (tip ? '<title>' + esc(tip) + '</title>' : '') +
        '<text x="14" y="' + (y + 12) + '" font-size="11.5">' + esc(cut(lab, 34)) + '</text></g>';
      if (row.log) {
        var c = row.log, at = parse(c.Time), x1 = c.Mark === 'Unread' ? null : (at && at > day0 ? xAt(at) : LX);
        if (x1 != null) out += '<line class="cov" x1="' + x1.toFixed(1) + '" y1="' + (y + RH / 2) + '" x2="' + (LX + PW) + '" y2="' + (y + RH / 2) + '"/>';
        var said = c.Mark === 'Unread' ? r.Log.Unread : (at && at > day0 ? r.Log.Reaches.replace('{0}', dayText(at)) : r.Log.Whole);
        out += '<text class="dim" x="' + (LX + 4) + '" y="' + (y + 12) + '" font-size="10">' + esc(c.Mark === 'Unread' || (at && at > day0) ? said : '') + '</text>';
        return;
      }
      // One stroke, or one mark, per hour with an event.
      var hours = {};
      row.ev.forEach(function(x){ var key = x.day * 24 + x.hour; (hours[key] = hours[key] || []).push(x); });
      Object.keys(hours).forEach(function(key){
        var list = hours[key].sort(function(a, b){ return a.at - b.at; }), s = sevOf(list) || 'NONE', x = LX + Number(key) * HW;
        var go = worstFinding(list) ? ' go' : '';
        out += '<g class="hit ' + s + go + '" data-row="' + esc(row.id) + '" data-h="' + key + '" tabindex="0" role="button">';
        if (row.marker) {
          var cx = x + HW / 2, cy = y + RH / 2;
          if (row.id === 'crash') out += '<path class="mk ' + s + '" d="M' + cx.toFixed(1) + ' ' + (cy - 5) + 'l5 5l-5 5l-5 -5z"/>';
          else out += '<circle class="mk ring ' + s + '" cx="' + cx.toFixed(1) + '" cy="' + cy + '" r="4"/>';
        }
        else out += '<rect class="ev" x="' + x.toFixed(1) + '" y="' + (y + 3) + '" width="' + Math.max(3, HW).toFixed(1) + '" height="' + (RH - 6) + '" rx="1"/>';
        out += '</g>';
      });
      out += '<text class="faint" x="' + (W - RX + 6) + '" y="' + (y + 12) + '" font-size="10">' + row.ev.length + '</text>';
    });
 
    svg.setAttribute('viewBox', '0 0 ' + W + ' ' + Math.ceil(H));
    svg.style.maxWidth = W + 'px';
    svg.innerHTML = out;
 
    Array.prototype.forEach.call(svg.querySelectorAll('.hit'), function(el){
      var row = byId[el.getAttribute('data-row')], key = Number(el.getAttribute('data-h'));
      var list = row.ev.filter(function(x){ return x.day * 24 + x.hour === key; }).sort(function(a, b){ return a.at - b.at; });
      function on(){ el.classList.add('sel'); net.showHtml(el, hourHtml(row, list)); }
      function off(){ el.classList.remove('sel'); net.hideDetail(); }
      function go(){ var f = worstFinding(list); if (f) net.jump(f.a); }
      el.addEventListener('mouseenter', on); el.addEventListener('focus', on);
      el.addEventListener('mouseleave', off); el.addEventListener('blur', off);
      el.addEventListener('click', go);
      el.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
    });
    Array.prototype.forEach.call(svg.querySelectorAll('.lab'), function(el){
      var row = byId[el.getAttribute('data-row')];
      if (row.log) { el.style.cursor = 'default'; return; }
      el.addEventListener('click', function(){ selected = selected === row.id ? '' : row.id; draw(); });
      var node = row.lane && row.lane.node ? d.n.filter(function(n){ return n.id === row.lane.node; })[0] : null;
      if (node) { el.addEventListener('mouseenter', function(){ net.showDetail(el, node); }); el.addEventListener('mouseleave', net.hideDetail); }
    });
    strip();
  }
 
  // Day by hour: how many events of the lanes shown fell in each hour of each day, with the
  // totals per day below and per hour beside.
  function strip(){
    var shown = rows.filter(function(row){ return !row.log && (!selected || row.id === selected); });
    var grid = [], perDay = [], perHour = [], total = 0, max = 0, k, h;
    for (k = 0; k < DAYS; k++) { grid.push([]); perDay.push(0); for (h = 0; h < 24; h++) grid[k].push(0); }
    for (h = 0; h < 24; h++) perHour.push(0);
    shown.forEach(function(row){ row.ev.forEach(function(x){ grid[x.day][x.hour]++; perDay[x.day]++; perHour[x.hour]++; total++; }); });
    grid.forEach(function(c){ c.forEach(function(v){ if (v > max) max = v; }); });
    var name = selected ? byId[selected].label : r.HeatAll;
    if (headBox) headBox.innerHTML = esc(r.Heat.replace('{0}', '')) + '<b>' + esc(name) + '</b>' + net.dot + esc(r.Count.replace('{0}', total));
    if (resetBox) resetBox.hidden = !selected;
 
    var TOP = 24, CH = 9, cw = PW / DAYS, H = TOP + 24 * CH + 46, o = '';
    for (k = 0; k < DAYS; k++) if ((DAYS - 1 - k) % 2 === 0) o += '<text class="faint" x="' + (LX + k * cw + 2).toFixed(1) + '" y="' + (TOP - 8) + '" font-size="10">' + esc(dayText(dayAt(k))) + '</text>';
    for (h = 0; h < 24; h += 3) o += '<text class="faint" x="' + (LX - 8) + '" y="' + (TOP + h * CH + 8) + '" font-size="10" text-anchor="end">' + two(h) + ':00</text>';
    o += '<text class="dim" x="14" y="' + (TOP + 12 * CH) + '" font-size="11">' + esc(r.Hour) + '</text>';
    for (k = 0; k < DAYS; k++) for (h = 0; h < 24; h++) {
      var v = grid[k][h];
      o += '<rect class="' + (v ? 'cell' : 'zero') + '" x="' + (LX + k * cw + 0.5).toFixed(1) + '" y="' + (TOP + h * CH + 0.5) + '" width="' + (cw - 1).toFixed(1) + '" height="' + (CH - 1) + '"' +
        (v ? ' fill-opacity="' + (0.25 + 0.75 * v / max).toFixed(2) + '"><title>' + esc(dayText(dayAt(k)) + ' ' + two(h) + ':00' + net.dot + r.Count.replace('{0}', v)) + '</title></rect>' : '/>');
    }
    var dayMax = Math.max.apply(null, perDay.concat([1])), hourMax = Math.max.apply(null, perHour.concat([1])), by = TOP + 24 * CH + 6;
    for (k = 0; k < DAYS; k++) {
      var bh = perDay[k] / dayMax * 30;
      o += '<rect class="tot" x="' + (LX + k * cw + 1).toFixed(1) + '" y="' + (by + 30 - bh).toFixed(1) + '" width="' + (cw - 2).toFixed(1) + '" height="' + bh.toFixed(1) + '"><title>' + esc(dayText(dayAt(k)) + net.dot + r.Count.replace('{0}', perDay[k])) + '</title></rect>';
    }
    o += '<text class="dim" x="' + (LX - 8) + '" y="' + (by + 28) + '" font-size="10" text-anchor="end">' + esc(r.PerDay) + '</text>';
    for (h = 0; h < 24; h++) {
      var bw = perHour[h] / hourMax * (RX - 8);
      o += '<rect class="tot" x="' + (LX + PW + 4) + '" y="' + (TOP + h * CH + 1) + '" width="' + bw.toFixed(1) + '" height="' + (CH - 2) + '"><title>' + esc(two(h) + ':00' + net.dot + r.Count.replace('{0}', perHour[h])) + '</title></rect>';
    }
    o += '<text class="dim" x="' + (W - 1) + '" y="' + (by + 28) + '" font-size="10" text-anchor="end">' + esc(r.PerHour) + '</text>';
    heat.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
    heat.style.maxWidth = W + 'px';
    heat.innerHTML = o;
  }
  if (resetBox) resetBox.addEventListener('click', function(){ selected = ''; draw(); });
  draw();
});
</script>
'@


function ConvertTo-ReportNetworkTimelineView {
    <#
    .SYNOPSIS
        The timeline view: what it is drawn into, and its words. Pure.
    .DESCRIPTION
        Nothing where the Run read no event: a log's reach alone is no timeline. Nor where
        it measured no network (Test-ReportNetworkMeasured): crashes and sign-ins alone are
        no 30 days of the network; beside a network they are drawn.
    #>

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

    if (-not @($Model.t).Count -or -not (Test-ReportNetworkMeasured -Measurement $Model.m -TimedEvent $Model.t)) { return }

    $legend = ('<span><svg width="12" height="12" aria-hidden="true"><path d="M6 1l5 5l-5 5l-5 -5z" fill="currentColor"/></svg>{0}</span>' +
        '<span><svg width="12" height="12" aria-hidden="true"><circle cx="6" cy="6" r="4" fill="none" stroke="currentColor" stroke-width="2"/></svg>{1}</span>' +
        '<span><svg width="22" height="12" aria-hidden="true"><rect x="0.5" y="0.5" width="21" height="11" fill="none" stroke="currentColor" stroke-dasharray="3 3"/><line x1="4" y1="11" x2="14" y2="1" stroke="currentColor"/></svg>{2}</span>') -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Crash')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Start')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend.Gap'))

    $html = ('<h3>{0}</h3><p class="netsub">{1} <span class="nethint">{2}</span></p><div class="nettimelegend">{3}</div>' +
        '<div class="netpanel"><svg class="nettime" role="img" aria-label="{0}"></svg></div><p class="nettimeout" hidden></p>' +
        '<div class="netheatbar"><span class="netheathead"></span><button type="button" class="netheatreset" hidden>{4}</button></div>' +
        '<div class="netpanel"><svg class="netheat" role="img" aria-label="{0}"></svg></div>') -f `
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Heading')), (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Legend')),
        (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.Hint')), $legend, (ConvertTo-HtmlText (Get-Text 'Report.Network.Timeline.HeatReset'))

    New-ReportNetworkView -Id 'timeline' -Title (Get-Text 'Report.Network.Timeline.Title') -Html $html `
        -Css $script:ReportNetworkTimelineCss -Script $script:ReportNetworkTimelineScript `
        -Word ([ordered]@{
            Group   = [ordered]@{
                Markers = Get-Text 'Report.Network.Timeline.Group.Markers'
                Machine = Get-Text 'Report.Network.Timeline.Group.Machine'
                Servers = Get-Text 'Report.Network.Timeline.Group.Servers'
                Logs    = Get-Text 'Report.Network.Timeline.Group.Logs'
            }
            Row     = [ordered]@{
                Crash = Get-Text 'Report.Network.Timeline.Row.Crash'
                Start = Get-Text 'Report.Network.Timeline.Row.Start'
            }
            Mark    = [ordered]@{
                Lost          = Get-Text 'Report.Network.Timeline.Mark.Lost'
                Failed        = Get-Text 'Report.Network.Timeline.Mark.Failed'
                ShareNotFound = Get-Text 'Report.Network.Timeline.Mark.ShareNotFound'
                Context       = Get-Text 'Report.Network.Timeline.Mark.Context'
                Crash         = Get-Text 'Report.Network.Timeline.Mark.Crash'
                Hang          = Get-Text 'Report.Network.Timeline.Mark.Hang'
                Start         = Get-Text 'Report.Network.Timeline.Mark.Start'
                SignIn        = Get-Text 'Report.Network.Timeline.Mark.SignIn'
            }
            Log     = [ordered]@{
                Reaches   = Get-Text 'Report.Network.Timeline.Log.Reaches'
                Whole     = Get-Text 'Report.Network.Timeline.Log.Whole'
                Unread    = Get-Text 'Report.Network.Timeline.Log.Unread'
                Gap       = Get-Text 'Report.Network.Timeline.Log.Gap'
                GapUnread = Get-Text 'Report.Network.Timeline.Log.GapUnread'
            }
            Run     = Get-Text 'Report.Network.Timeline.Run'
            Heat    = Get-Text 'Report.Network.Timeline.Heat'
            HeatAll = Get-Text 'Report.Network.Timeline.HeatAll'
            Count   = Get-Text 'Report.Network.Timeline.Count'
            PerDay  = Get-Text 'Report.Network.Timeline.PerDay'
            PerHour = Get-Text 'Report.Network.Timeline.PerHour'
            Hour    = Get-Text 'Report.Network.Timeline.Hour'
            InHour  = Get-Text 'Report.Network.Timeline.InHour'
            More    = Get-Text 'Report.Network.Timeline.More'
            Outside = Get-Text 'Report.Network.Timeline.Outside'
        })
}