Private/ReportNetworkTimeline.ps1

# The Timeline view of "Big Picture" (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; on the pointer, what a
# cell, a day or an hour holds, summed up by lane and kind, however many events it is.
#
# 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>
/* Big Picture: the Timeline (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)}
.netheat .on{stroke:var(--text);stroke-width:1}
.netheat .go{cursor:pointer}
.netheat:focus{outline:none}
.netheat:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.netheat .sdot{fill:var(--c);stroke:var(--panel);stroke-width:.8;pointer-events:none}
.netdetail tr[data-netrow]{cursor:pointer}
.netdetail tr[data-netrow]:hover td{background:var(--panel)}
@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 Timeline: 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, PH = 13, 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: [], said: [] }; 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); } });
  // How the events of a Server's lane were spread in time, as each Finding that counts them
  // says it of that Server: a sentence per Finding, behind the kind of event it counts. The
  // Judge decided it (ADR-0002); the view only shows it.
  rows.forEach(function(row){
    var seen = {};
    if (!row.lane) return;
    row.ev.forEach(function(x){
      var text = x.e.Mark === 'Context' ? '' : net.pattern(x.e);
      if (!text || seen[x.e.Finding]) return;
      seen[x.e.Finding] = 1;
      row.said.push(r.Pattern.replace('{0}', x.e.Detail || r.Mark[x.e.Mark] || x.e.Mark).replace('{1}', text));
    });
  });
  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>' +
      (row.said.length ? '<div class="sub">' + row.said.map(esc).join('<br>') + '</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') + '" data-netrow="' + esc(row.id) + '"><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>';
    out += '<div class="go">' + esc(r.PickRow) + '</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 + (row.said.length ? PH : 0);
    });
    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 : '');
      if (row.said.length) tip += '\n' + row.said.join('\n');
      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;
      }
      // How the lane's events were spread in time, on a line of its own under its strokes.
      if (row.said.length) out += '<text class="dim" x="' + (LX + 4) + '" y="' + (y + RH + 9) + '" font-size="10">' + esc(cut(row.said.join(net.dot), 160)) + '</text>';
      // 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.
  // The rows the strip is drawn for and their name, which its summaries are of.
  var stripRows = [], stripName = '', busiest = null;
  function strip(){
    stripRows = rows.filter(function(row){ return !row.log && (!selected || row.id === selected); });
    var grid = [], severities = [], perDay = [], perHour = [], judgedDay = [], judgedHour = [], total = 0, max = 0, k, h;
    for (k = 0; k < DAYS; k++) { grid.push([]); severities.push([]); perDay.push(0); judgedDay.push(false); for (h = 0; h < 24; h++) { grid[k].push(0); severities[k].push([]); } }
    for (h = 0; h < 24; h++) { perHour.push(0); judgedHour.push(false); }
    stripRows.forEach(function(row){ row.ev.forEach(function(x){
      var s = net.severity(x.e);
      grid[x.day][x.hour]++; severities[x.day][x.hour].push(s); perDay[x.day]++; perHour[x.hour]++; total++;
      if (s) { judgedDay[x.day] = true; judgedHour[x.hour] = true; }
    }); });
    busiest = null;
    grid.forEach(function(c, day){ c.forEach(function(v, hour){ if (v > max) { max = v; busiest = { k: day, h: hour }; } }); });
    stripName = selected ? byId[selected].label : r.HeatAll;
    if (headBox) headBox.innerHTML = esc(r.Heat.replace('{0}', '')) + '<b>' + esc(stripName) + '</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], worstHere = v ? net.worst(severities[k][h]) : '';
      o += '<rect class="' + (v ? 'cell sum' + (worstHere ? ' go' : '') : '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) + '"' : '') + ' data-k="' + k + '" data-h="' + h + '"/>';
      // A dot where a FAIL or a WARN fell in that hour, in its colour: how many is the
      // cell's shade, how bad its dot.
      if (worstHere === 'FAIL' || worstHere === 'WARN') o += '<circle class="sdot ' + worstHere + '" cx="' + (LX + k * cw + cw / 2).toFixed(1) + '" cy="' + (TOP + h * CH + CH / 2) + '" r="2.6"/>';
    }
    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;
      if (perDay[k]) o += '<rect class="tot sum' + (judgedDay[k] ? ' go' : '') + '" x="' + (LX + k * cw + 1).toFixed(1) + '" y="' + (by + 30 - bh).toFixed(1) + '" width="' + (cw - 2).toFixed(1) + '" height="' + bh.toFixed(1) + '" data-k="' + k + '"/>';
    }
    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);
      if (perHour[h]) o += '<rect class="tot sum' + (judgedHour[h] ? ' go' : '') + '" x="' + (LX + PW + 4) + '" y="' + (TOP + h * CH + 1) + '" width="' + bw.toFixed(1) + '" height="' + (CH - 2) + '" data-h="' + h + '"/>';
    }
    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;
 
    // On the pointer: what a cell, a day or an hour holds, summed up; a click leads to the
    // Finding of its worst event, as a stroke's does.
    Array.prototype.forEach.call(heat.querySelectorAll('.sum'), function(el){
      el.addEventListener('mouseenter', function(){ mark(el); showSum(el); });
      el.addEventListener('mouseleave', function(){ mark(null); net.hideDetail(); });
      el.addEventListener('click', function(){ jumpFrom(el); });
    });
  }
 
  // The events of a day, an hour of every day, or one hour of one day, of the rows shown:
  // looked up when asked for, and not for each of some 800 places at every drawing.
  function where(el){
    var k = el.getAttribute('data-k'), h = el.getAttribute('data-h');
    return { day: k == null ? null : +k, hour: h == null ? null : +h };
  }
  function eventsAt(at){
    var list = [];
    stripRows.forEach(function(row){ row.ev.forEach(function(x){ if ((at.day == null || x.day === at.day) && (at.hour == null || x.hour === at.hour)) list.push({ row: row, x: x }); }); });
    return list.sort(function(a, b){ return a.x.at - b.x.at; });
  }
  function dayCount(list){ var seen = {}, n = 0; list.forEach(function(p){ if (!seen[p.x.day]) { seen[p.x.day] = 1; n++; } }); return n; }
  function showSum(el){
    var at = where(el), list = eventsAt(at), said;
    if (!list.length) { net.hideDetail(); return; }
    if (at.day != null && at.hour != null) said = r.InHour.replace('{0}', list.length).replace('{1}', dayText(dayAt(at.day)) + ' ' + two(at.hour)).replace('{2}', two((at.hour + 1) % 24));
    else if (at.day != null) said = r.OnDay.replace('{0}', list.length).replace('{1}', dayText(dayAt(at.day)));
    else said = r.AtHour.replace('{0}', list.length).replace('{1}', two(at.hour)).replace('{2}', two((at.hour + 1) % 24)).replace('{3}', dayCount(list));
    net.showHtml(el, sumHtml(stripName, said, list, at.day != null && at.hour != null));
  }
  function jumpFrom(el){ var f = worstFinding(eventsAt(where(el)).map(function(p){ return p.x; })); if (f) net.jump(f.a); }
  var marked = null;
  function mark(el){ if (marked) marked.classList.remove('on'); marked = el; if (el) el.classList.add('on'); }
 
  // From the keyboard: the strip is one stop of the tab key, the arrows go from hour to
  // hour and from day to day, Enter to the Finding - some 700 stops would be none.
  var cursor = null;
  function moveTo(c){
    cursor = c;
    var el = heat.querySelector('rect[data-k="' + c.k + '"][data-h="' + c.h + '"]');
    mark(el);
    if (el) showSum(el);
  }
  // A click focuses the strip as well, and leaves the summary where the pointer is.
  var byPointer = false;
  heat.addEventListener('mousedown', function(){ byPointer = true; });
  heat.addEventListener('focus', function(){
    if (byPointer) { byPointer = false; return; }
    moveTo(cursor || busiest || { k: DAYS - 1, h: 0 });
  });
  heat.addEventListener('blur', function(){ mark(null); net.hideDetail(); });
  heat.addEventListener('keydown', function(e){
    if (!cursor) cursor = busiest || { k: DAYS - 1, h: 0 };
    var step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
    if (step) {
      e.preventDefault();
      moveTo({ k: Math.min(DAYS - 1, Math.max(0, cursor.k + step[0])), h: Math.min(23, Math.max(0, cursor.h + step[1])) });
    }
    else if (e.key === 'Enter') { e.preventDefault(); if (marked) jumpFrom(marked); }
    else if (e.key === 'Escape') net.hideDetail();
  });
 
  // Many events in words without a list of each: from when to when, the rows they came from,
  // the worst first, each with how many of which kind; the Findings they belong to. A busy
  // hour of hundreds is as long as a quiet one of three.
  var SUMROWS = 6, SUMFINDS = 4, times = String.fromCharCode(215) + ' ';
  function sumHtml(name, said, list, inHour){
    var first = list[0].x.at, last = list[list.length - 1].x.at;
    var at = function(t){ return inHour ? timeText(t).slice(0, 5) : net.when(t); };
    var out = '<h4>' + esc(name) + '</h4><div class="sub">' + esc(said) +
      (list.length > 1 ? '<br>' + esc(w.detail.Span.replace('{0}', at(first)).replace('{1}', at(last))) : '') + '</div><table>';
    var byRow = {}, order = [];
    list.forEach(function(p){
      var g = byRow[p.row.id];
      if (!g) { g = byRow[p.row.id] = { row: p.row, n: 0, s: [], kinds: {}, kindOrder: [] }; order.push(g); }
      // What happened, as the log says it; an event shown for the context says so beside.
      var markWord = r.Mark[p.x.e.Mark] || p.x.e.Mark, kind = p.x.e.Detail || markWord;
      if (p.x.e.Detail && p.x.e.Mark === 'Context') kind += ' ' + String.fromCharCode(8211) + ' ' + markWord;
      if (!g.kinds[kind]) { g.kinds[kind] = 0; g.kindOrder.push(kind); }
      g.kinds[kind]++; g.n++; g.s.push(net.severity(p.x.e));
    });
    order.forEach(function(g){ g.sev = net.worst(g.s); });
    order.sort(function(a, b){
      var sa = a.sev ? levels.indexOf(a.sev) : levels.length, sb = b.sev ? levels.indexOf(b.sev) : levels.length;
      return sa - sb || b.n - a.n;
    });
    order.slice(0, SUMROWS).forEach(function(g){
      var kinds = g.kindOrder.sort(function(a, b){ return g.kinds[b] - g.kinds[a]; }).map(function(k){ return g.kinds[k] + times + k; });
      out += '<tr class="' + (g.sev || 'NONE') + '" data-netrow="' + esc(g.row.id) + '"><td><span class="dot"></span> ' + esc(g.row.label) + '<small>' + esc(kinds.slice(0, 3).join(net.dot) + (kinds.length > 3 ? net.dot + ell : '')) + '</small>' +
        (g.row.said.length ? '<small>' + g.row.said.map(esc).join('<br>') + '</small>' : '') + '</td><td class="v">' + esc(String(g.n)) + '</td></tr>';
    });
    out += '</table>';
    if (order.length > SUMROWS) {
      var rest = order.slice(SUMROWS).reduce(function(s, g){ return s + g.n; }, 0);
      out += '<div class="none">' + esc(r.MoreRows.replace('{0}', order.length - SUMROWS).replace('{1}', rest)) + '</div>';
    }
    out += '<div class="go">' + esc(r.PickRow) + '</div>';
    var seen = {}, finds = [];
    list.forEach(function(p){ var f = net.finding(p.x.e); if (f && !seen[p.x.e.Finding]) { seen[p.x.e.Finding] = 1; finds.push(f); } });
    // Each Finding by its name alone: its value names every Server, the rows above say which.
    finds.sort(function(a, b){ return levels.indexOf(a.s) - levels.indexOf(b.s); }).slice(0, SUMFINDS).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></a>';
    });
    if (finds.length > SUMFINDS) out += '<div class="none">' + esc(r.More.replace('{0}', finds.length - SUMFINDS)) + '</div>';
    return out;
  }
  if (resetBox) resetBox.addEventListener('click', function(){ selected = ''; draw(); });
  // A row of a detail the timeline wrote: the grid for that row alone, as its name's click
  // gives, and the grid brought into view.
  var panel = document.getElementById('netdetail');
  if (panel) panel.addEventListener('click', function(e){
    var tr = e.target.closest && e.target.closest('tr[data-netrow]');
    if (!tr || !byId[tr.getAttribute('data-netrow')]) return;
    selected = tr.getAttribute('data-netrow');
    panel.hidden = true;
    draw();
    heat.scrollIntoView({ block: 'nearest' });
  });
  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 Timeline 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}" tabindex="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'
            OnDay   = Get-Text 'Report.Network.Timeline.OnDay'
            AtHour  = Get-Text 'Report.Network.Timeline.AtHour'
            MoreRows = Get-Text 'Report.Network.Timeline.MoreRows'
            PickRow = Get-Text 'Report.Network.Timeline.PickRow'
            More    = Get-Text 'Report.Network.Timeline.More'
            Outside = Get-Text 'Report.Network.Timeline.Outside'
            Pattern = Get-Text 'Report.Network.Timeline.Pattern'
        })
}