Skip to content
quarry Artifact [23b78d841d]
Login

Artifact [23b78d841d]

Artifact 23b78d841d73b828ed6f0365daea2843195f1d7a1180c446bc2a5ddf834451d4:


// 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 ghost dots for the
// touches a merge carried in (RFC 0013 §3, §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.
(function () {
  "use strict";

  // Mirrors the layout in activity.go (coreSampleSVG); the canvas is
  // drawn in the SVG's own units so the two pictures line up.
  var W = 960, L = 60, R = 70, T = 48, B = 22, HOUR = 12;
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

  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 or the axis 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 query so a
  // reply for a picture that has since been replaced is dropped.
  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");
    if (!since || !until) return;

    var q = new URLSearchParams(location.search);
    var qs = new URLSearchParams();
    qs.set("since", since);
    qs.set("until", until);
    if (q.get("branch")) qs.set("branch", q.get("branch"));
    if (q.get("user")) qs.set("user", q.get("user"));
    var key = axis + " " + 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, 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, act) {
    day0 = dayOf(act.since);
    var span = Math.max(1, dayOf(act.until) - day0 + 1);
    var paths = act.paths || [];
    var touches = act.touches || [];

    // Plot geometry. On the tree axis rows shrink as paths grow, as the
    // SVG's do.
    var rowH = HOUR, ph;
    if (axis === "clock") {
      ph = 24 * HOUR;
    } else {
      rowH = paths.length <= 40 ? 12 : paths.length <= 200 ? 6 : 3;
      ph = Math.max(HOUR, rowH * paths.length);
    }
    var H = T + ph + B, PW = W - L - R;
    var radius = rowH < 6 ? 1.4 : 2.4;

    // Marks, bucketed by day so a pointer move only looks at the days
    // under it. Ghosts (inherited touches) are a separate bucket set so
    // an authored dot always wins the hit-test.
    var byDay = [], ghostByDay = [];
    for (var d = 0; d < span; d++) { byDay.push([]); ghostByDay.push([]); }
    if (axis === "clock") {
      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.day < 0 || t.day >= span || t.path < 0 || t.path >= paths.length) return;
        (t.inherited ? ghostByDay : 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 (axis === "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.
    var z0 = 0, z1 = span;
    var dx = function () { return PW / (z1 - z0); };
    var xOf = function (day) { return L + (day - z0 + 0.5) * dx(); };
    var dayAt = function (x) { return Math.floor(z0 + (x - L) / dx()); };
    var yOf = function (row) { return T + (row + 0.5) * rowH; };

    var colours = [], theme = {};
    function readTheme() {
      var cs = getComputedStyle(document.documentElement);
      var s = cs.getPropertyValue("--dot-s").trim() || "58%";
      var l = parseFloat(cs.getPropertyValue("--dot-l")) || 44;
      colours = act.authors.map(function (a) {
        return "hsl(" + a.hue + " " + s + " " + (l + (a.shade || 0) * 8) + "%)";
      });
      theme.muted = cs.getPropertyValue("--muted").trim() || "#888";
      theme.surface = cs.getPropertyValue("--surface").trim() || "rgba(128,128,128,0.1)";
      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";
      var step = dx();

      // Bands and their labels.
      ctx.fillStyle = theme.surface;
      var shade = false;
      bands.forEach(function (b) {
        if (shade) ctx.fillRect(L, T + b.from * rowH, PW, b.n * rowH);
        shade = !shade;
      });
      ctx.fillStyle = theme.muted;
      ctx.textAlign = "end";
      ctx.font = "9px system-ui, sans-serif";
      bands.forEach(function (b) { ctx.fillText(bandLabel(b.label), L - 6, T + b.from * rowH + 9); });
      ctx.font = "10px system-ui, sans-serif";

      // Marginals over the visible range.
      var perDay = [], maxDay = 0;
      for (var d = z0; d < z1; d++) {
        var n = byDay[d].length;
        perDay.push(n);
        if (n > maxDay) maxDay = n;
      }
      ctx.globalAlpha = 0.45;
      if (maxDay > 0) {
        var sc = (T - 10) / maxDay, bw = Math.max(1, step);
        for (var k = 0; k < perDay.length; k++) {
          if (!perDay[k]) continue;
          ctx.fillRect(L + k * step, T - 4 - perDay[k] * sc, bw, perDay[k] * sc);
        }
      }
      var perRow = rowCounts();
      var maxRow = 0;
      perRow.forEach(function (n) { if (n > maxRow) maxRow = n; });
      if (maxRow > 0) {
        var rs = (R - 10) / maxRow;
        perRow.forEach(function (n, r) {
          if (!n) return;
          if (axis === "clock") ctx.fillRect(W - R + 4, T + r * HOUR + 1, n * rs, HOUR - 2);
          else ctx.fillRect(W - R + 4, T + r * rowH, n * rs, rowH);
        });
      }
      ctx.globalAlpha = 1;

      // Axis labels.
      ctx.textAlign = "end";
      if (axis === "clock") {
        for (var hr = 0; hr < 24; hr += 3) ctx.fillText(pad(hr) + ":00", L - 6, T + hr * HOUR + 9);
      }
      ctx.textAlign = "center";
      var vis = z1 - z0, mstep = 1;
      [1, 2, 3, 6, 12].some(function (st) { mstep = st; return vis / 30.4 / st * 60 <= PW; });
      var yb = T + ph + 14;
      if (vis <= 45) {
        // A short range: the ends, dated.
        ctx.fillText(dateLabel(z0), xOf(z0), yb);
        if (vis > 1) ctx.fillText(dateLabel(z1 - 1), xOf(z1 - 1), yb);
      } else {
        for (var dd = z0; dd < z1; dd++) {
          var t = new Date((day0 + dd) * 86400000);
          if (t.getUTCDate() !== 1 || t.getUTCMonth() % mstep !== 0) continue;
          ctx.fillText(MONTHS[t.getUTCMonth()] + " " + t.getUTCFullYear(), xOf(dd), yb);
        }
      }

      // Ghosts first, then the dots, so authored work sits on top.
      ctx.save();
      ctx.beginPath();
      ctx.rect(L, T, PW, ph);
      ctx.clip();
      ctx.globalAlpha = 0.18;
      for (var g = z0; g < z1; g++) {
        var gx = xOf(g);
        ghostByDay[g].forEach(function (mk) { dot(gx, yOf(mk.row), mk.a, false); });
      }
      ctx.globalAlpha = 0.85;
      for (var e = z0; e < z1; e++) {
        var ex = xOf(e);
        byDay[e].forEach(function (mk) { dot(ex, yOf(mk.row), mk.a, mk.r && mk.r.merge); });
      }
      ctx.restore();
      ctx.globalAlpha = 1;

      if (drag && Math.abs(drag.x1 - drag.x0) >= 4) {
        ctx.fillStyle = theme.fg;
        ctx.globalAlpha = 0.12;
        ctx.fillRect(Math.min(drag.x0, drag.x1), T, Math.abs(drag.x1 - drag.x0), ph);
        ctx.globalAlpha = 1;
      }
    }

    function dot(x, y, a, hollow) {
      var c = colours[a] || theme.muted;
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      if (hollow) { ctx.strokeStyle = c; ctx.lineWidth = 1.2; ctx.stroke(); }
      else { ctx.fillStyle = c; ctx.fill(); }
    }

    // Counts down the right marginal: hours on the clock axis, paths
    // on the tree axis; authored only, like the SVG.
    function rowCounts() {
      var out = [];
      var n = axis === "clock" ? 24 : paths.length;
      for (var i = 0; i < n; i++) out.push(0);
      for (var d = z0; d < z1; d++) byDay[d].forEach(function (mk) { out[mk.row]++; });
      return out;
    }

    function pad(n) { return (n < 10 ? "0" : "") + n; }
    function bandLabel(dir) {
      var room = 14;
      if (dir.length <= room) return dir;
      for (var i = dir.indexOf("/"); i >= 0; i = dir.indexOf("/")) {
        dir = dir.slice(i + 1);
        if (dir.length + 2 <= room) return "…/" + dir;
      }
      return "…" + dir.slice(dir.length - room + 1);
    }

    // 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; authored beats ghost.
    function hit(p) {
      var reach = 6 * W / (canvas.getBoundingClientRect().width || W);
      var lo = Math.max(z0, dayAt(p.x - reach)), hi = Math.min(z1 - 1, dayAt(p.x + reach));
      var best = null, bd = reach * reach;
      var scan = function (buckets, ghost) {
        for (var d = lo; d <= hi; d++) {
          var x = xOf(d);
          buckets[d].forEach(function (mk) {
            var y = yOf(mk.row), dd = (x - p.x) * (x - p.x) + (y - p.y) * (y - p.y);
            if (dd < bd) { bd = dd; best = { mk: mk, ghost: ghost, x: x, y: y }; }
          });
        }
      };
      scan(byDay, false);
      if (!best) scan(ghostByDay, true);
      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 describe(p) {
      var inPlot = p.x >= L && p.x <= W - R && p.y >= T && p.y <= T + ph;
      if (inPlot) {
        var h = hit(p);
        if (!h) return null;
        var d;
        if (axis === "clock") {
          var r = h.mk.r;
          d = esc(dateLabel(r.day) + " " + pad(r.hour) + ":00") + " · <b>" + esc(name(r.author)) + "</b>";
          d += " · " + (r.merge ? "merge" : r.files === 1 ? "1 file" : r.files + " files");
          if (r.branch) d += " · " + esc(r.branch);
          d += " " + muted(r.hash.slice(0, 10));
        } else {
          var t = h.mk.t;
          d = esc(paths[t.path]) + " · <b>" + esc(name(t.author)) + "</b> · " + esc(dateLabel(t.day));
          if (h.ghost) d += " " + muted("inherited via merge");
        }
        return d;
      }
      if (p.y < T && p.x >= L && p.x <= W - R) {
        var day = dayAt(p.x);
        if (day < z0 || day >= z1) return null;
        var n = byDay[day].length;
        return esc(dateLabel(day)) + " · " + n + (axis === "clock" ? (n === 1 ? " check-in" : " check-ins") : (n === 1 ? " file change" : " file changes"));
      }
      if (p.x > W - R && p.y >= T && p.y <= T + ph) {
        var row = Math.floor((p.y - T) / rowH), counts = rowCounts();
        if (row < 0 || row >= counts.length) return null;
        if (axis === "clock") return esc(pad(row) + ":00") + " · " + counts[row] + (counts[row] === 1 ? " check-in" : " check-ins");
        return esc(paths[row]) + " · " + counts[row] + (counts[row] === 1 ? " change" : " 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; }

    var drag = null, moved = false;
    canvas.addEventListener("pointerdown", function (ev) {
      if (ev.button !== 0) return;
      var p = at(ev);
      if (p.x < L || p.x > W - R || p.y < T || p.y > T + ph) return;
      drag = { x0: p.x, x1: p.x };
      moved = false;
      canvas.setPointerCapture(ev.pointerId);
    });
    canvas.addEventListener("pointermove", function (ev) {
      var p = at(ev);
      if (drag) {
        drag.x1 = Math.min(W - R, Math.max(L, p.x));
        if (Math.abs(drag.x1 - drag.x0) >= 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 a = dayAt(Math.min(sel.x0, sel.x1)), b = dayAt(Math.max(sel.x0, sel.x1)) + 1;
        a = Math.max(z0, a);
        b = Math.min(z1, b);
        // At least three days; at the right edge 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 (axis !== "clock") 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();
  }
})();