// 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 { "&": "&", "<": "<", ">": ">", '"': """ }[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();
}
})();