| 1 |
{# The year as a signal (oxygenhub#217): changes pushed each day of the |
| 2 |
last year, coloured by repository, the share pushed via agent tokens as a |
| 3 |
violet trace, releases on the peaks. Drawn by the script below from the |
| 4 |
page's JSON; nothing is fetched. #} |
| 5 |
<section class="card-box pulse-box" aria-label="Changes over the last year"> |
| 6 |
<div class="card-head"><h2>The year</h2><span class="pulse-total" id="pulse-total" data-total="{{ pulse_total }}">{{ pulse_total }}</span><span class="small mute">changes pushed</span> |
| 7 |
<div class="pulse-legend more" id="pulse-legend"></div></div> |
| 8 |
{% if pulse_total == 0 %}<p class="empty small">No changes pushed in the last year.</p>{% else %} |
| 9 |
<div class="pulse-stage" id="pulse-stage"><div class="pulse-card" id="pulse-card" aria-live="polite"></div></div> |
| 10 |
<script type="application/json" id="pulse-data">{{ pulse|safe }}</script> |
| 11 |
<script> |
| 12 |
(() => { |
| 13 |
"use strict"; |
| 14 |
const data = JSON.parse(document.getElementById("pulse-data").textContent); |
| 15 |
const stage = document.getElementById("pulse-stage"), card = document.getElementById("pulse-card"); |
| 16 |
const NS = "http://www.w3.org/2000/svg", reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 17 |
const el = (t, a, p) => { const e = document.createElementNS(NS, t); for (const k in a) e.setAttribute(k, a[k]); if (p) p.appendChild(e); return e; }; |
| 18 |
const PAL = 7, col = r => r < PAL ? `var(--pu${r + 1})` : "var(--pu8)"; |
| 19 |
const days = data.days.map((d, i) => ({ t: d[0], via: d[1], by: d[2], date: new Date(data.start + i * 864e5) })); |
| 20 |
const N = days.length, LAST = N - 1; |
| 21 |
const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / LAST); |
| 22 |
const s = el("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Changes pushed each day over the last year" }); |
| 23 |
stage.prepend(s); |
| 24 |
const defs = el("defs", {}, s); |
| 25 |
const grad = el("linearGradient", { id: "pu-grad", x1: L, x2: W - R, y1: 0, y2: 0, gradientUnits: "userSpaceOnUse" }, defs); |
| 26 |
const fade = el("linearGradient", { id: "pu-fade", x1: 0, x2: 0, y1: 0, y2: 1 }, defs); |
| 27 |
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": ".5" }, fade); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": "0" }, fade); |
| 28 |
const fm = el("mask", { id: "pu-fm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs); el("rect", { width: W, height: H, fill: "url(#pu-fade)" }, fm); |
| 29 |
const glow = el("filter", { id: "pu-glow", x: "-5%", y: "-30%", width: "110%", height: "160%" }, defs); |
| 30 |
el("feGaussianBlur", { stdDeviation: "2.6", result: "b" }, glow); const mg = el("feMerge", {}, glow); el("feMergeNode", { in: "b" }, mg); el("feMergeNode", { in: "SourceGraphic" }, mg); |
| 31 |
const spot = el("radialGradient", { id: "pu-spot", cx: "0", cy: "0", r: "1", gradientUnits: "userSpaceOnUse" }, defs); |
| 32 |
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": "1" }, spot); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": ".4" }, spot); |
| 33 |
const sm = el("mask", { id: "pu-sm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs); |
| 34 |
const spotRect = el("rect", { width: W, height: H, fill: "#fff" }, sm); |
| 35 |
const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip); |
| 36 |
const grid = el("g", {}, s), ylab = []; |
| 37 |
for (let k = 0; k <= 4; k++) { const y = T + (base - T) * k / 4; el("line", { x1: L, x2: W - R, y1: y, y2: y, stroke: "var(--line)", "stroke-opacity": ".5" }, grid); ylab.push(el("text", { x: L - 6, y: y + 4, "text-anchor": "end", class: "pu-axis" }, grid)); } |
| 38 |
days.forEach((d, i) => { if (N > 62 ? d.date.getUTCDate() !== 1 : d.date.getUTCDay() !== 1) return; el("line", { x1: X(i), x2: X(i), y1: base, y2: base + 5, stroke: "var(--line)" }, grid); const t = el("text", { x: X(i) + 3, y: base + 18, class: "pu-axis" }, grid); t.textContent = d.date.toLocaleDateString("en-GB", N > 62 ? { month: "short", timeZone: "UTC" } : { day: "numeric", month: "short", timeZone: "UTC" }); }); |
| 39 |
const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s); |
| 40 |
const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot); |
| 41 |
const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot); |
| 42 |
const viaLine = el("path", { fill: "none", stroke: "var(--pu-agent)", "stroke-width": 1.3, "stroke-dasharray": "1 3", "stroke-linecap": "round" }, plot); |
| 43 |
const strip = el("g", {}, plot), peakG = el("g", {}, s); |
| 44 |
const beat = el("circle", { r: 4.5, fill: "var(--panel)", stroke: "var(--acc)", "stroke-width": 2, filter: "url(#pu-glow)", opacity: 0 }, s); |
| 45 |
const scan = el("line", { y1: T - 4, y2: base, stroke: "var(--ink)", "stroke-dasharray": "2 3", opacity: 0 }, s); |
| 46 |
const dot = el("circle", { r: 5, fill: "var(--panel)", "stroke-width": 2.5, opacity: 0 }, s); |
| 47 |
let only = -1, ys = []; |
| 48 |
const val = d => only < 0 ? d.t : (d.by.find(p => p[0] === only) || [0, 0])[1]; |
| 49 |
const top = d => d.by.length ? d.by.reduce((a, b) => b[1] > a[1] ? b : a)[0] : 0; |
| 50 |
const smooth = pts => pts.reduce((acc, [x, y], i) => { if (!i) return `M${x},${y}`; const [x0, y0] = pts[i - 1], cx = (x0 + x) / 2; return acc + `C${cx},${y0} ${cx},${y} ${x},${y}`; }, ""); |
| 51 |
function build() { |
| 52 |
const v = days.map(val), max = Math.max(1, ...v); |
| 53 |
ys = v.map(n => base - (base - T) * n / max); |
| 54 |
const d = smooth(ys.map((y, i) => [X(i), y])); |
| 55 |
line.setAttribute("d", d); area.setAttribute("d", d + `L${X(LAST)},${base}L${X(0)},${base}Z`); |
| 56 |
viaLine.setAttribute("d", smooth(days.map((x, i) => [X(i), base - (base - T) * (only < 0 ? x.via : Math.round(val(x) * x.via / (x.t || 1))) / max]))); |
| 57 |
grad.textContent = ""; |
| 58 |
for (let i = 0; i <= LAST; i += Math.max(1, Math.round(N / 52))) el("stop", { offset: (i / LAST).toFixed(4), style: `stop-color:${col(only < 0 ? top(days[i]) : only)}` }, grad); |
| 59 |
strip.textContent = ""; |
| 60 |
days.forEach((x, i) => { if (!val(x)) return; el("rect", { x: X(i) - .9, y: base + 28, width: 1.8, height: 9, rx: .9, style: `fill:${col(only < 0 ? top(x) : only)}` }, strip); }); |
| 61 |
ylab.forEach((t, k) => { t.textContent = Math.round(max * (4 - k) / 4); }); |
| 62 |
peakG.textContent = ""; |
| 63 |
if (only < 0) { |
| 64 |
|
| 65 |
|
| 66 |
const peaks = (data.peaks || []).filter(([i]) => i >= 0 && i <= LAST).sort((a, b) => ys[a[0]] - ys[b[0]]); |
| 67 |
const labelled = []; |
| 68 |
peaks.forEach(([i, label]) => { |
| 69 |
const y = ys[i], free = labelled.length < 4 && labelled.every(j => Math.abs(X(j) - X(i)) > 120); |
| 70 |
el("circle", { cx: X(i), cy: y, r: free ? 3.2 : 2.2, fill: "var(--ink)" }, peakG); |
| 71 |
if (!free) return; |
| 72 |
labelled.push(i); |
| 73 |
el("circle", { cx: X(i), cy: y, r: 4, fill: "none", stroke: "var(--ink)", class: "pu-ring" }, peakG); |
| 74 |
const right = X(i) < W - 160; |
| 75 |
const t = el("text", { x: X(i) + (right ? 8 : -8), y: Math.max(T - 8, y - 8), "text-anchor": right ? "start" : "end", class: "pu-peak" }, peakG); t.textContent = label; |
| 76 |
}); |
| 77 |
} |
| 78 |
} |
| 79 |
build(); |
| 80 |
|
| 81 |
const lg = document.getElementById("pulse-legend"); |
| 82 |
const btn = (label, c, idx) => { const b = document.createElement("button"); b.type = "button"; b.className = "pu-chip"; b.setAttribute("aria-pressed", String(idx === -1)); b.innerHTML = `<i style="background:${c}"></i>`; b.append(label); b.addEventListener("click", () => { only = idx; [...lg.children].forEach(c => c.setAttribute("aria-pressed", "false")); b.setAttribute("aria-pressed", "true"); build(); }); lg.appendChild(b); }; |
| 83 |
btn("all", "var(--ink)", -1); |
| 84 |
data.repos.slice(0, PAL).forEach((n, i) => btn(n.split("/").pop(), col(i), i)); |
| 85 |
|
| 86 |
const totalEl = document.getElementById("pulse-total"), total = Number(totalEl.dataset.total); |
| 87 |
if (!reduce) { const t0 = performance.now(); const tick = now => { const p = Math.min(1, (now - t0) / 900); totalEl.textContent = Math.round(total * (1 - Math.pow(1 - p, 3))).toLocaleString("en-GB"); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } |
| 88 |
else totalEl.textContent = total.toLocaleString("en-GB"); |
| 89 |
let idle = true, timer = 0; |
| 90 |
function heartbeat() { |
| 91 |
if (reduce) return; |
| 92 |
clearTimeout(timer); |
| 93 |
timer = setTimeout(function run() { |
| 94 |
if (!idle || document.hidden) { timer = setTimeout(run, 2000); return; } |
| 95 |
const len = line.getTotalLength(), t0 = performance.now(); |
| 96 |
const go = now => { const p = Math.min(1, (now - t0) / 2600), pt = line.getPointAtLength(len * p); beat.setAttribute("cx", pt.x); beat.setAttribute("cy", pt.y); beat.setAttribute("opacity", idle ? String(Math.sin(p * Math.PI)) : "0"); if (p < 1 && idle) requestAnimationFrame(go); else { beat.setAttribute("opacity", "0"); timer = setTimeout(run, 4500); } }; |
| 97 |
requestAnimationFrame(go); |
| 98 |
}, 800); |
| 99 |
} |
| 100 |
if (!reduce) { |
| 101 |
const t0 = performance.now(); |
| 102 |
let shown = false; |
| 103 |
const reveal = now => { if (shown) return; const p = Math.min(1, (now - t0) / 1600); clipRect.setAttribute("width", L + (W - L) * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(reveal); }; |
| 104 |
requestAnimationFrame(reveal); |
| 105 |
setTimeout(() => { shown = true; clipRect.setAttribute("width", W); heartbeat(); }, 1700); |
| 106 |
} |
| 107 |
|
| 108 |
const fmt = d => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }); |
| 109 |
s.addEventListener("pointermove", e => { |
| 110 |
idle = false; beat.setAttribute("opacity", "0"); |
| 111 |
const r = s.getBoundingClientRect(), x = (e.clientX - r.left) * (W / r.width); |
| 112 |
const i = Math.max(0, Math.min(LAST, Math.round((x - L) / ((W - L - R) / LAST)))), d = days[i]; |
| 113 |
spotRect.setAttribute("fill", "url(#pu-spot)"); spot.setAttribute("gradientTransform", `translate(${X(i)} ${ys[i]}) scale(160 240)`); |
| 114 |
scan.setAttribute("x1", X(i)); scan.setAttribute("x2", X(i)); scan.setAttribute("opacity", ".45"); |
| 115 |
dot.setAttribute("cx", X(i)); dot.setAttribute("cy", ys[i]); dot.style.stroke = col(only < 0 ? top(d) : only); dot.setAttribute("opacity", "1"); |
| 116 |
const n = val(d), share = d.t ? Math.round(100 * d.via / d.t) : 0; |
| 117 |
const bar = d.t ? d.by.map(([k, m]) => `<i style="width:${100 * m / d.t}%;background:${col(k)}"></i>`).join("") : ""; |
| 118 |
const repos = d.by.slice().sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, m]) => `<div class="row"><span>${(data.repos[k] || "").split("/").pop()}</span><span>${m}</span></div>`).join(""); |
| 119 |
card.innerHTML = `<b>${fmt(d.date)}</b><div class="row"><span>changes</span><span>${n}</span></div><div class="row"><span>via agents</span><span>${share}%</span></div>${only < 0 ? repos : ""}${bar ? `<div class="bar">${bar}</div>` : ""}`; |
| 120 |
card.style.opacity = "1"; |
| 121 |
const px = e.clientX - r.left, cw = card.offsetWidth; |
| 122 |
card.style.left = Math.max(0, Math.min(r.width - cw, px + (px > r.width * .6 ? -cw - 14 : 14))) + "px"; |
| 123 |
}); |
| 124 |
s.addEventListener("pointerleave", () => { idle = true; spotRect.setAttribute("fill", "#fff"); scan.setAttribute("opacity", "0"); dot.setAttribute("opacity", "0"); card.style.opacity = "0"; }); |
| 125 |
document.addEventListener("visibilitychange", () => { if (!document.hidden) heartbeat(); }); |
| 126 |
})(); |
| 127 |
</script> |
| 128 |
{% endif %} |
| 129 |
</section> |