// 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);
// 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();
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 { "&": "&", "<": "<", ">": ">", '"': """ }[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(); });
canvas.addEventListener("dblclick", function () { z0 = 0; z1 = span; redraw(); });
current = {
redraw: redraw,
reset: function () { if (z0 !== 0 || z1 !== span) { z0 = 0; z1 = span; redraw(); } }
};
draw();
}
})();