Skip to content
quarry Artifact [0aec603a05]
Login

Artifact [0aec603a05]

Artifact 0aec603a057b385875f2d011a726ed2f0f8e6017e278948b546c74fdba902b70:


// activity.js — the core sample, drawn on a canvas when scripts run.
//
// The server's SVG is the picture; this file redraws the same picture
// so it can answer the pointer: a tooltip on every dot, hit-tests on
// the marginals, a drag to zoom the day range, and the person filter —
// everyone stays drawn, the chosen one in colour and the rest grey
// (RFC 0013 §7). The SVG stays in the document, hidden, as the
// no-script state and as what a screen reader gets; if the fetch fails
// nothing here happens and it is shown again.
//
// Zoom lives only here. A drag is exploration, not a place, so it never
// touches the URL. The two axes are one drawing: time is the axis the
// drag zooms, and on the tree axis it runs down the page instead of
// across, so everything below is written in terms of "time" and "row"
// rather than x and y.
(function () {
  "use strict";

  // Mirrors the layout in activity.go (coreSampleSVG and
  // treeSampleSVG); the canvas is drawn in the SVG's own units so the
  // two pictures line up.
  var W = 960, L = 12, R = 70, T = 48, B = 22, HOUR = 12;
  // The tree axis: time down the page, files across, a fixed height.
  var TREE_T = 56, TREE_B = 14, TREE_PH = 520, TREE_BAR = 48;
  var TREE_MONTH = 900, TREE_DENSE = 120;

  if (!window.fetch || !document.createElement("canvas").getContext) return;
  var m = /^\/r\/([^/]+)\/activity\/?$/.exec(location.pathname);
  if (!m) return;
  var repo = m[1];

  // The mounted picture, if any. Page-wide listeners (resize, theme,
  // Escape) are installed once and talk to whichever mount is current,
  // so a view morphed in place does not stack a listener per mount.
  var current = null;

  // enhance mounts the canvas over the SVG in the document, unless it
  // already has one. The view region (#act-view) is morphed in place
  // when a person, the axis or the window is chosen — the morph drops
  // the canvas and shows the new SVG — so this runs again on every
  // change under it. The fetch is keyed on the SVG's window and the
  // chosen person so a reply for a picture that has since been
  // replaced is dropped. The query is NOT narrowed by the person: the
  // filter singles out, it never subtracts, so everyone's rows are
  // needed to draw the grey ones.
  function enhance() {
    var svg = document.querySelector(".act-sample > svg.act");
    if (!svg || svg.hasAttribute("hidden") || svg.parentNode.querySelector(".act-canvas")) return;
    var axis = svg.getAttribute("data-axis");
    var since = svg.getAttribute("data-since");
    var until = svg.getAttribute("data-until");
    var user = svg.dataset.user === undefined ? -1 : +svg.dataset.user;
    if (!since || !until) return;

    var qs = new URLSearchParams();
    qs.set("since", since);
    qs.set("until", until);
    var key = axis + " " + user + " " + qs.toString();
    if (svg.actKey === key) return;
    svg.actKey = key;

    fetch("/api/v1/r/" + repo + "/activity?" + qs.toString(), { headers: { Accept: "application/json" } })
      .then(function (res) { return res.ok ? res.json() : null; })
      .then(function (act) {
        if (!act || !act.authors || !act.rows) return;
        if (svg.actKey !== key || !svg.isConnected || svg.parentNode.querySelector(".act-canvas")) return;
        mount(svg, axis, user, act);
      })
      .catch(function () {});
  }

  enhance();
  new MutationObserver(enhance).observe(document.body, { childList: true, subtree: true });
  window.addEventListener("resize", function () { if (current) current.redraw(); });
  if (window.matchMedia) {
    var mq = matchMedia("(prefers-color-scheme: dark)");
    if (mq.addEventListener) mq.addEventListener("change", function () { if (current) current.redraw(); });
  }
  new MutationObserver(function () { if (current) current.redraw(); })
    .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
  document.addEventListener("keydown", function (ev) {
    if (ev.key === "Escape" && current) current.reset();
  });

  function dayOf(s) {
    var p = s.split("-");
    return Date.UTC(+p[0], +p[1] - 1, +p[2]) / 86400000;
  }
  function dateLabel(day) {
    var d = new Date((day0 + day) * 86400000);
    return d.toISOString().slice(0, 10);
  }
  var day0;

  function mount(svg, axis, user, act) {
    day0 = dayOf(act.since);
    var span = Math.max(1, dayOf(act.until) - day0 + 1);
    var paths = act.paths || [];
    var touches = act.touches || [];
    var tree = axis === "tree";

    // Plot geometry. The clock axis is 24 hours of fixed rows with the
    // days across; the tree axis is a column per path with the days
    // down, newest at the top, in a block of fixed height.
    var top = tree ? TREE_T : T, ph = tree ? TREE_PH : 24 * HOUR, bot = tree ? TREE_B : B;
    var H = top + ph + bot, PW = W - L - R;
    var nRow = tree ? paths.length : 24;
    var colW = tree ? PW / Math.max(1, paths.length) : 0;
    var radius = !tree || paths.length <= TREE_DENSE ? 2.4 : Math.max(0.9, colW * 0.45);
    var square = radius < 1.4;

    // Marks, bucketed by day so a pointer move only looks at the days
    // under it — on the tree axis that is a band of rows, on the clock
    // axis a band of columns. Inherited touches are not marks: they are
    // what a merge carried in, not anyone's work.
    var byDay = [];
    for (var d = 0; d < span; d++) byDay.push([]);
    if (!tree) {
      act.rows.forEach(function (r) {
        if (r.day >= 0 && r.day < span) byDay[r.day].push({ row: r.hour, a: r.author, r: r });
      });
    } else {
      touches.forEach(function (t) {
        if (t.inherited || t.day < 0 || t.day >= span || t.path < 0 || t.path >= paths.length) return;
        byDay[t.day].push({ row: t.path, a: t.author, t: t });
      });
    }

    // Directory bands: a run of five or more paths in one directory.
    var bands = [];
    if (tree) {
      var dirOf = function (p) { var i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i); };
      for (var start = 0, i = 1; i <= paths.length; i++) {
        if (i < paths.length && dirOf(paths[i]) === dirOf(paths[start])) continue;
        if (i - start >= 5) bands.push({ from: start, n: i - start, label: dirOf(paths[start]) || "/" });
        start = i;
      }
    }

    var canvas = document.createElement("canvas");
    canvas.className = "act-canvas";
    canvas.setAttribute("data-axis", axis);
    canvas.setAttribute("aria-hidden", "true");
    canvas.style.aspectRatio = W + " / " + H;
    var tip = document.createElement("div");
    tip.className = "act-tip";
    tip.setAttribute("role", "status");
    tip.setAttribute("aria-live", "polite");
    tip.hidden = true;
    var box = svg.parentNode;
    box.insertBefore(canvas, svg);
    box.appendChild(tip);
    // A visible way out of a zoom; Escape and double-click are the others.
    var reset = document.createElement("button");
    reset.type = "button";
    reset.className = "act-reset";
    reset.textContent = "reset zoom";
    reset.hidden = true;
    box.appendChild(reset);
    // An attribute, not the property: SVGElement has no hidden IDL.
    svg.setAttribute("hidden", "");
    var ctx = canvas.getContext("2d");

    // The visible day range; a drag narrows it, Escape resets it. Time
    // is measured along the plot's length: across on the clock axis,
    // down on the tree axis.
    var z0 = 0, z1 = span;
    var tSpan = tree ? ph : PW, tHome = tree ? top : L;
    var dStep = function () { return tSpan / (z1 - z0); };
    // A day's centre and its leading edge along the time axis.
    var tOf = function (day) {
      return tree ? tHome + (z1 - 1 - day + 0.5) * dStep() : tHome + (day - z0 + 0.5) * dStep();
    };
    var tEdge = function (day) {
      return tree ? tHome + (z1 - 1 - day) * dStep() : tHome + (day - z0) * dStep();
    };
    var dayAt = function (c) {
      var k = Math.floor((c - tHome) / dStep());
      return tree ? z1 - 1 - k : z0 + k;
    };
    // A row's centre across the plot, and back again.
    var rStep = tree ? colW : HOUR, rHome = tree ? L : top;
    var rOf = function (row) { return rHome + (row + 0.5) * rStep; };
    var rowAt = function (c) { return Math.floor((c - rHome) / rStep); };
    // A mark's place on the canvas.
    var xOf = function (day, row) { return tree ? rOf(row) : tOf(day); };
    var yOf = function (day, row) { return tree ? tOf(day) : rOf(row); };

    var colours = [], theme = {};
    function readTheme() {
      var cs = getComputedStyle(document.documentElement);
      var l = parseFloat(cs.getPropertyValue("--idn-l")) || 56;
      var c = [cs.getPropertyValue("--idn-c0").trim() || "0.17", cs.getPropertyValue("--idn-c1").trim() || "0.10"];
      colours = act.authors.map(function (a) {
        return "oklch(" + (l + (a.shade || 0) * 8) + "% " + c[a.chroma || 0] + " " + a.hue + ")";
      });
      theme.muted = cs.getPropertyValue("--muted").trim() || "#888";
      theme.surface = cs.getPropertyValue("--surface").trim() || "rgba(128,128,128,0.1)";
      theme.rule = cs.getPropertyValue("--rule").trim() || "rgba(128,128,128,0.3)";
      theme.fg = cs.getPropertyValue("--fg").trim() || "#000";
    }

    var pending = false;
    function redraw() {
      if (pending) return;
      pending = true;
      requestAnimationFrame(function () { pending = false; draw(); });
    }

    function draw() {
      readTheme();
      reset.hidden = z0 === 0 && z1 === span;
      var cssW = canvas.clientWidth || W;
      var ratio = window.devicePixelRatio || 1;
      var pw = Math.round(cssW * ratio), phh = Math.round(cssW * H / W * ratio);
      if (canvas.width !== pw || canvas.height !== phh) { canvas.width = pw; canvas.height = phh; }
      ctx.setTransform(pw / W, 0, 0, pw / W, 0, 0);
      ctx.clearRect(0, 0, W, H);
      ctx.font = "10px system-ui, sans-serif";

      // Directory bands run the height of the plot. They are not
      // labelled: nothing is written on the plot, the tip says which
      // file the pointer is over (§3).
      ctx.fillStyle = theme.surface;
      var shade = false;
      bands.forEach(function (bd) {
        if (shade) ctx.fillRect(L + bd.from * colW, top, bd.n * colW, ph);
        shade = !shade;
      });

      // Marginals. On the clock axis the days are across the top and
      // the hours down the right; on the tree axis they swap — the
      // files across the top, the days down the right.
      var perDay = dayCounts(-1), perRow = rowCounts(-1);
      var maxDay = 0, maxRow = 0;
      perDay.forEach(function (n) { if (n > maxDay) maxDay = n; });
      perRow.forEach(function (n) { if (n > maxRow) maxRow = n; });
      // The project's bars in grey, and the chosen person's own over
      // them in their colour on the SAME scale, so the pair reads as a
      // share rather than as two unrelated charts.
      marginals(perDay, perRow, theme.muted, 0.45);
      if (user >= 0) marginals(dayCounts(user), rowCounts(user), colours[user] || theme.muted, 0.9);
      ctx.globalAlpha = 1;

      function marginals(pd, pr, fill, alpha) {
        ctx.globalAlpha = alpha;
        ctx.fillStyle = fill;
        if (tree) {
          if (maxRow > 0) {
            var ps = TREE_BAR / maxRow, pbw = Math.max(1, colW);
            pr.forEach(function (n, r) {
              if (n) ctx.fillRect(L + r * colW, TREE_BAR - n * ps, pbw, n * ps);
            });
          }
          if (maxDay > 0) {
            var ds = (R - 10) / maxDay, dbh = Math.max(1, dStep());
            for (var dv = z0; dv < z1; dv++) {
              if (pd[dv]) ctx.fillRect(W - R + 4, tEdge(dv), pd[dv] * ds, dbh);
            }
          }
          return;
        }
        if (maxDay > 0) {
          var cs2 = (T - 10) / maxDay, cbw = Math.max(1, dStep());
          for (var cd = z0; cd < z1; cd++) {
            if (pd[cd]) ctx.fillRect(tEdge(cd), T - 4 - pd[cd] * cs2, cbw, pd[cd] * cs2);
          }
        }
        if (maxRow > 0) {
          var rs = (R - 10) / maxRow;
          pr.forEach(function (n, r) {
            if (n) ctx.fillRect(W - R + 4, top + r * HOUR + 1, n * rs, HOUR - 2);
          });
        }
      }

      // No text on the plot (§3): the tree axis keeps its month rules,
      // the clock axis its hour ticks, and the tip carries the dates.
      ctx.strokeStyle = theme.rule;
      ctx.lineWidth = 1;
      var vis = z1 - z0;
      if (tree) {
        var prev = "", cut = vis > TREE_MONTH ? 4 : 7;
        for (var rd = z1 - 1; rd >= z0; rd--) {
          var lab = dateLabel(rd).slice(0, cut);
          if (lab === prev) continue;
          prev = lab;
          var ry = Math.round(tOf(rd)) + 0.5;
          ctx.beginPath();
          ctx.moveTo(L, ry);
          ctx.lineTo(W - R, ry);
          ctx.stroke();
        }
      } else {
        for (var hr = 0; hr < 24; hr += 3) {
          var hy = Math.round(top + hr * HOUR) + 0.5;
          ctx.beginPath();
          ctx.moveTo(L - 5, hy);
          ctx.lineTo(L, hy);
          ctx.stroke();
        }
      }

      // Everyone else first and grey, then the chosen person over them
      // in colour: the filter singles out, it never subtracts (§7).
      ctx.save();
      ctx.beginPath();
      ctx.rect(L, top, PW, ph);
      ctx.clip();
      if (user >= 0) {
        ctx.globalAlpha = 0.15;
        for (var g = z0; g < z1; g++) {
          byDay[g].forEach(function (mk) {
            if (mk.a !== user) mark(xOf(g, mk.row), yOf(g, mk.row), null, mk.r && mk.r.merge);
          });
        }
      }
      ctx.globalAlpha = 0.85;
      for (var e = z0; e < z1; e++) {
        byDay[e].forEach(function (mk) {
          if (user >= 0 && mk.a !== user) return;
          mark(xOf(e, mk.row), yOf(e, mk.row), mk.a, mk.r && mk.r.merge);
        });
      }
      ctx.restore();
      ctx.globalAlpha = 1;

      // The drag's selection: a band across the time axis.
      if (drag && Math.abs(drag.c1 - drag.c0) >= 4) {
        ctx.fillStyle = theme.fg;
        ctx.globalAlpha = 0.12;
        var lo = Math.min(drag.c0, drag.c1), len = Math.abs(drag.c1 - drag.c0);
        if (tree) ctx.fillRect(L, lo, PW, len);
        else ctx.fillRect(lo, top, len, ph);
        ctx.globalAlpha = 1;
      }
    }

    // A mark: a circle, or a square once a circle would be mush. A null
    // author is someone the filter did not choose and takes the muted
    // colour, never a person's.
    function mark(x, y, a, hollow) {
      var c = a === null ? theme.muted : colours[a] || theme.muted;
      if (hollow) {
        ctx.strokeStyle = c;
        ctx.lineWidth = 1.2;
        ctx.beginPath();
        ctx.arc(x, y, radius, 0, Math.PI * 2);
        ctx.stroke();
        return;
      }
      ctx.fillStyle = c;
      if (square) { ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2); return; }
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      ctx.fill();
    }

    // Counts over the visible range: per day, and per row (hours on the
    // clock axis, paths on the tree axis). only >= 0 counts just that
    // person, which is what the marginal overlay is drawn from.
    function dayCounts(only) {
      var out = [];
      for (var i = 0; i < span; i++) out.push(0);
      for (var d = z0; d < z1; d++) {
        byDay[d].forEach(function (mk) { if (only < 0 || mk.a === only) out[d]++; });
      }
      return out;
    }
    function rowCounts(only) {
      var out = [];
      for (var i = 0; i < nRow; i++) out.push(0);
      for (var d = z0; d < z1; d++) {
        byDay[d].forEach(function (mk) { if (only < 0 || mk.a === only) out[mk.row]++; });
      }
      return out;
    }

    function pad(n) { return (n < 10 ? "0" : "") + n; }

    // Pointer position in SVG units.
    function at(ev) {
      var r = canvas.getBoundingClientRect();
      return { x: (ev.clientX - r.left) * W / r.width, y: (ev.clientY - r.top) * W / r.width, cx: ev.clientX - r.left, cy: ev.clientY - r.top };
    }

    // Nearest mark within six CSS pixels. Only the day buckets the
    // reach touches are scanned.
    function hit(p) {
      var reach = 6 * W / (canvas.getBoundingClientRect().width || W);
      var c = tree ? p.y : p.x;
      var a = dayAt(c - reach), b = dayAt(c + reach);
      var lo = Math.max(z0, Math.min(a, b)), hi = Math.min(z1 - 1, Math.max(a, b));
      var best = null, bd = reach * reach;
      for (var d = lo; d <= hi; d++) {
        byDay[d].forEach(function (mk) {
          var x = xOf(d, mk.row), y = yOf(d, mk.row);
          var dd = (x - p.x) * (x - p.x) + (y - p.y) * (y - p.y);
          if (dd < bd) { bd = dd; best = { mk: mk, day: d }; }
        });
      }
      return best;
    }

    function esc(s) {
      return String(s).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; });
    }
    function muted(s) { return '<span class="m">' + esc(s) + "</span>"; }
    function name(a) { return act.authors[a] ? act.authors[a].name : "?"; }
    function plural(n, one, many) { return n + " " + (n === 1 ? one : many); }

    function describe(p) {
      var across = p.x >= L && p.x <= W - R, down = p.y >= top && p.y <= top + ph;
      if (across && down) {
        var h = hit(p);
        if (!h) return null;
        if (!tree) {
          var r = h.mk.r;
          var d = esc(dateLabel(r.day) + " " + pad(r.hour) + ":00") + " · <b>" + esc(name(r.author)) + "</b>";
          d += " · " + (r.merge ? "merge" : plural(r.files, "file", "files"));
          if (r.branch) d += " · " + esc(r.branch);
          return d + " " + muted(r.hash.slice(0, 10));
        }
        var t = h.mk.t;
        return esc(paths[t.path]) + " · <b>" + esc(name(t.author)) + "</b> · " + esc(dateLabel(t.day));
      }
      // Above the plot: the days on the clock axis, the files on the tree.
      if (p.y < top && across) {
        if (!tree) {
          var day = dayAt(p.x);
          if (day < z0 || day >= z1) return null;
          return esc(dateLabel(day)) + " · " + plural(dayCounts(-1)[day], "check-in", "check-ins");
        }
        if (p.y > TREE_BAR) return null;
        var col = rowAt(p.x);
        if (col < 0 || col >= paths.length) return null;
        return esc(paths[col]) + " · " + plural(rowCounts(-1)[col], "change", "changes");
      }
      // Right of the plot: the hours on the clock axis, the days on the tree.
      if (p.x > W - R && down) {
        if (!tree) {
          var hrow = rowAt(p.y);
          if (hrow < 0 || hrow >= 24) return null;
          return esc(pad(hrow) + ":00") + " · " + plural(rowCounts(-1)[hrow], "check-in", "check-ins");
        }
        var dday = dayAt(p.y);
        if (dday < z0 || dday >= z1) return null;
        return esc(dateLabel(dday)) + " · " + plural(dayCounts(-1)[dday], "file change", "file changes");
      }
      return null;
    }

    function showTip(p, html) {
      tip.innerHTML = html;
      tip.hidden = false;
      var bw = box.clientWidth, tw = tip.offsetWidth;
      var x = p.cx + 12, y = p.cy + 12;
      if (x + tw > bw) x = Math.max(0, p.cx - tw - 12);
      tip.style.left = x + "px";
      tip.style.top = y + "px";
    }
    function hideTip() { tip.hidden = true; }

    // The drag runs along the time axis: across on the clock axis, down
    // on the tree axis. One path, one flag.
    var drag = null, moved = false;
    var inPlot = function (p) { return p.x >= L && p.x <= W - R && p.y >= top && p.y <= top + ph; };
    var clampT = function (c) { return Math.min(tHome + tSpan, Math.max(tHome, c)); };
    canvas.addEventListener("pointerdown", function (ev) {
      if (ev.button !== 0) return;
      var p = at(ev);
      if (!inPlot(p)) return;
      var c = tree ? p.y : p.x;
      drag = { c0: c, c1: c };
      moved = false;
      canvas.setPointerCapture(ev.pointerId);
    });
    canvas.addEventListener("pointermove", function (ev) {
      var p = at(ev);
      if (drag) {
        drag.c1 = clampT(tree ? p.y : p.x);
        if (Math.abs(drag.c1 - drag.c0) >= 4) { moved = true; hideTip(); redraw(); }
        return;
      }
      var d = describe(p);
      if (d) showTip(p, d); else hideTip();
    });
    canvas.addEventListener("pointerup", function (ev) {
      if (!drag) return;
      var sel = drag;
      drag = null;
      if (moved) {
        var d0 = dayAt(sel.c0), d1 = dayAt(sel.c1);
        var a = Math.max(z0, Math.min(d0, d1)), b = Math.min(z1, Math.max(d0, d1) + 1);
        // At least three days; at the far end grow backwards.
        if (b - a < 3) b = Math.min(z1, a + 3);
        if (b - a < 3) a = Math.max(z0, b - 3);
        if (b - a >= 3) { z0 = a; z1 = b; }
        redraw();
        return;
      }
      // A click: on the clock axis a dot is a check-in.
      if (tree) return;
      var h = hit(at(ev));
      if (h && h.mk.r) location.href = "/r/" + repo + "/checkin/" + encodeURIComponent(h.mk.r.hash);
    });
    canvas.addEventListener("pointercancel", function () { drag = null; redraw(); });
    canvas.addEventListener("pointerleave", function () { if (!drag) hideTip(); });
    function resetZoom() { if (z0 !== 0 || z1 !== span) { z0 = 0; z1 = span; redraw(); } }
    canvas.addEventListener("dblclick", resetZoom);
    reset.addEventListener("click", resetZoom);
    current = { redraw: redraw, reset: resetZoom };
    draw();
  }
})();