// 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. Only the clock axis is redrawn: the people and tree
// axes are SVG pictures of their own and are left alone.
(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 = 12, R = 70, T = 48, B = 22, HOUR = 12;
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");
// The people axis is cards and the tree axis directory strips, not
// this plot: their SVG is the picture.
if (axis !== "clock") return;
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);
// Plot geometry: 24 hours of fixed rows with the days across.
var top = T, ph = 24 * HOUR;
var H = top + ph + B, PW = W - L - R;
var nRow = 24;
var radius = 2.4;
// Marks, bucketed by day so a pointer move only looks at the days
// under it, a band of columns.
var byDay = [];
for (var d = 0; d < span; d++) byDay.push([]);
act.rows.forEach(function (r) {
if (r.day >= 0 && r.day < span) byDay[r.day].push({ row: r.hour, a: r.author, r: r });
});
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
// runs across the plot.
var z0 = 0, z1 = span;
var dStep = function () { return PW / (z1 - z0); };
// A day's centre and its leading edge along the time axis.
var tOf = function (day) { return L + (day - z0 + 0.5) * dStep(); };
var tEdge = function (day) { return L + (day - z0) * dStep(); };
var dayAt = function (c) { return z0 + Math.floor((c - L) / dStep()); };
// An hour's centre down the plot, and back again.
var rOf = function (row) { return top + (row + 0.5) * HOUR; };
var rowAt = function (c) { return Math.floor((c - top) / HOUR); };
// A mark's place on the canvas.
var xOf = function (day) { return tOf(day); };
var yOf = function (day, row) { return 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";
// Marginals: the days across the top and the hours 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 (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 hour ticks, and the tip carries
// the dates.
ctx.strokeStyle = theme.rule;
ctx.lineWidth = 1;
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), 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), 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);
ctx.fillRect(lo, top, len, ph);
ctx.globalAlpha = 1;
}
}
// A mark: a circle. 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;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
// Counts over the visible range: per day, and per hour. 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 a = dayAt(p.x - reach), b = dayAt(p.x + 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), 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;
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));
}
// Above the plot: the days.
if (p.y < top && across) {
var day = dayAt(p.x);
if (day < z0 || day >= z1) return null;
return esc(dateLabel(day)) + " · " + plural(dayCounts(-1)[day], "check-in", "check-ins");
}
// Right of the plot: the hours.
if (p.x > W - R && down) {
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");
}
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 the plot.
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(L + PW, Math.max(L, c)); };
canvas.addEventListener("pointerdown", function (ev) {
if (ev.button !== 0) return;
var p = at(ev);
if (!inPlot(p)) return;
drag = { c0: p.x, c1: p.x };
moved = false;
canvas.setPointerCapture(ev.pointerId);
});
canvas.addEventListener("pointermove", function (ev) {
var p = at(ev);
if (drag) {
drag.c1 = clampT(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: a dot is a check-in.
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();
}
})();