// 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 — when the page
// asked for them — 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. 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 = 60, R = 70, T = 48, B = 22, HOUR = 12;
// The tree axis: time down the page, files across, a fixed height.
var TREE_T = 112, TREE_B = 14, TREE_PH = 520, TREE_BAR = 48;
var TREE_BAND = 46, TREE_MONTH = 900, TREE_DENSE = 120;
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, the axis or the merges state 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, the
// query and the merges state 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");
var merges = svg.dataset.merges === "1";
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 + " " + (merges ? "m " : "") + 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, merges, 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, merges, 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. 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 (!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.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 (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 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.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, labelled at 45°
// above it when the band is wide enough to read.
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;
});
ctx.fillStyle = theme.muted;
ctx.font = "9px system-ui, sans-serif";
ctx.textAlign = "start";
bands.forEach(function (bd) {
if (bd.n * colW <= TREE_BAND) return;
ctx.save();
ctx.translate(L + bd.from * colW + 4, top - 10);
ctx.rotate(-Math.PI / 4);
ctx.fillText(bandLabel(bd.label), 0, 0);
ctx.restore();
});
ctx.font = "10px system-ui, sans-serif";
// 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(), perRow = rowCounts();
var maxDay = 0, maxRow = 0;
perDay.forEach(function (n) { if (n > maxDay) maxDay = n; });
perRow.forEach(function (n) { if (n > maxRow) maxRow = n; });
ctx.globalAlpha = 0.45;
ctx.fillStyle = theme.muted;
if (tree) {
if (maxRow > 0) {
var ps = TREE_BAR / maxRow, pbw = Math.max(1, colW);
perRow.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++) {
var dn = perDay[dv];
if (dn) ctx.fillRect(W - R + 4, tEdge(dv), dn * ds, dbh);
}
}
} else {
if (maxDay > 0) {
var cs2 = (T - 10) / maxDay, cbw = Math.max(1, dStep());
for (var cd = z0; cd < z1; cd++) {
var cn = perDay[cd];
if (cn) ctx.fillRect(tEdge(cd), T - 4 - cn * cs2, cbw, cn * cs2);
}
}
if (maxRow > 0) {
var rs = (R - 10) / maxRow;
perRow.forEach(function (n, r) {
if (n) ctx.fillRect(W - R + 4, top + r * HOUR + 1, n * rs, HOUR - 2);
});
}
}
ctx.globalAlpha = 1;
// Axis labels: hours down the left and months along the bottom
// on the clock axis; a date rail down the left on the tree axis.
ctx.fillStyle = theme.muted;
ctx.textAlign = "end";
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.strokeStyle = theme.rule;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(L, ry);
ctx.lineTo(W - R, ry);
ctx.stroke();
ctx.fillText(lab, L - 6, ry + 3);
}
} else {
for (var hr = 0; hr < 24; hr += 3) ctx.fillText(pad(hr) + ":00", L - 6, top + hr * HOUR + 9);
ctx.textAlign = "center";
var mstep = 1;
[1, 2, 3, 6, 12].some(function (st) { mstep = st; return vis / 30.4 / st * 60 <= PW; });
var yb = top + ph + 14;
if (vis <= 45) {
// A short range: the ends, dated.
ctx.fillText(dateLabel(z0), tOf(z0), yb);
if (vis > 1) ctx.fillText(dateLabel(z1 - 1), tOf(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(), tOf(dd), yb);
}
}
}
// Ghosts first, then the dots, so authored work sits on top. The
// ghosts are grey and faint: they are shape, not anyone's work.
ctx.save();
ctx.beginPath();
ctx.rect(L, top, PW, ph);
ctx.clip();
if (merges) {
ctx.globalAlpha = 0.10;
for (var g = z0; g < z1; g++) {
ghostByDay[g].forEach(function (mk) { mark(xOf(g, mk.row), yOf(g, mk.row), null, false); });
}
}
ctx.globalAlpha = 0.85;
for (var e = z0; e < z1; e++) {
byDay[e].forEach(function (mk) {
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 a ghost 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). Inherited touches count only
// where the page asked for them.
function dayCounts() {
var out = [];
for (var i = 0; i < span; i++) out.push(0);
for (var d = z0; d < z1; d++) {
out[d] = byDay[d].length + (merges ? ghostByDay[d].length : 0);
}
return out;
}
function rowCounts() {
var out = [];
for (var i = 0; i < nRow; i++) out.push(0);
var add = function (mk) { out[mk.row]++; };
for (var d = z0; d < z1; d++) {
byDay[d].forEach(add);
if (merges) ghostByDay[d].forEach(add);
}
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. 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;
var scan = function (buckets, ghost) {
for (var d = lo; d <= hi; d++) {
buckets[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, ghost: ghost }; }
});
}
};
scan(byDay, false);
if (!best && merges) scan(ghostByDay, true);
return best;
}
function esc(s) {
return String(s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[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;
var s = esc(paths[t.path]) + " · <b>" + esc(name(t.author)) + "</b> · " + esc(dateLabel(t.day));
if (h.ghost) s += " " + muted("merge");
return s;
}
// 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()[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()[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()[hrow], "check-in", "check-ins");
}
var dday = dayAt(p.y);
if (dday < z0 || dday >= z1) return null;
return esc(dateLabel(dday)) + " · " + plural(dayCounts()[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();
}
})();