OXYGENHUB
# a6d824c5ebf5 oxygenhub / templates / pulse.html
11.2 KiB · HTML · last changed Profile pulse: the last 3, 6, 9 or 12 months 21 days ago source blame history raw
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 id="pulse-title">Last 12 months</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
document.getElementById("pulse-title").textContent = `Last ${data.months} months`;
20
const days = data.days.map((d, i) => ({ t: d[0], via: d[1], by: d[2], date: new Date(data.start + i * 864e5) }));
21
const N = days.length, LAST = N - 1;
22
const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / LAST);
23
const s = el("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Changes pushed each day over the last year" });
24
stage.prepend(s);
25
const defs = el("defs", {}, s);
26
const grad = el("linearGradient", { id: "pu-grad", x1: L, x2: W - R, y1: 0, y2: 0, gradientUnits: "userSpaceOnUse" }, defs);
27
const fade = el("linearGradient", { id: "pu-fade", x1: 0, x2: 0, y1: 0, y2: 1 }, defs);
28
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": ".5" }, fade); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": "0" }, fade);
29
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);
30
const glow = el("filter", { id: "pu-glow", x: "-5%", y: "-30%", width: "110%", height: "160%" }, defs);
31
el("feGaussianBlur", { stdDeviation: "2.6", result: "b" }, glow); const mg = el("feMerge", {}, glow); el("feMergeNode", { in: "b" }, mg); el("feMergeNode", { in: "SourceGraphic" }, mg);
32
const spot = el("radialGradient", { id: "pu-spot", cx: "0", cy: "0", r: "1", gradientUnits: "userSpaceOnUse" }, defs);
33
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": "1" }, spot); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": ".4" }, spot);
34
const sm = el("mask", { id: "pu-sm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs);
35
const spotRect = el("rect", { width: W, height: H, fill: "#fff" }, sm);
36
const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip);
37
const grid = el("g", {}, s), ylab = [];
38
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)); }
39
days.forEach((d, i) => { if (N > 100 ? d.date.getUTCDate() !== 1 : (d.date.getUTCDate() !== 1 && d.date.getUTCDate() !== 15)) 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 > 100 ? { month: "short", timeZone: "UTC" } : { day: "numeric", month: "short", timeZone: "UTC" }); });
40
const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s);
41
const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot);
42
const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot);
43
const viaLine = el("path", { fill: "none", stroke: "var(--pu-agent)", "stroke-width": 1.3, "stroke-dasharray": "1 3", "stroke-linecap": "round" }, plot);
44
const strip = el("g", {}, plot), peakG = el("g", {}, s);
45
const beat = el("circle", { r: 4.5, fill: "var(--panel)", stroke: "var(--acc)", "stroke-width": 2, filter: "url(#pu-glow)", opacity: 0 }, s);
46
const scan = el("line", { y1: T - 4, y2: base, stroke: "var(--ink)", "stroke-dasharray": "2 3", opacity: 0 }, s);
47
const dot = el("circle", { r: 5, fill: "var(--panel)", "stroke-width": 2.5, opacity: 0 }, s);
48
let only = -1, ys = [];
49
const val = d => only < 0 ? d.t : (d.by.find(p => p[0] === only) || [0, 0])[1];
50
const top = d => d.by.length ? d.by.reduce((a, b) => b[1] > a[1] ? b : a)[0] : 0;
51
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}`; }, "");
52
function build() {
53
  const v = days.map(val), max = Math.max(1, ...v);
54
  ys = v.map(n => base - (base - T) * n / max);
55
  const d = smooth(ys.map((y, i) => [X(i), y]));
56
  line.setAttribute("d", d); area.setAttribute("d", d + `L${X(LAST)},${base}L${X(0)},${base}Z`);
57
  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])));
58
  grad.textContent = "";
59
  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);
60
  strip.textContent = "";
61
  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); });
62
  ylab.forEach((t, k) => { t.textContent = Math.round(max * (4 - k) / 4); });
63
  peakG.textContent = "";
64
  if (only < 0) {
65
    // At most four labels, the biggest days first, none crowding another;
66
    // the other releases are marked without words.
67
    const peaks = (data.peaks || []).filter(([i]) => i >= 0 && i <= LAST).sort((a, b) => ys[a[0]] - ys[b[0]]);
68
    const labelled = [];
69
    peaks.forEach(([i, label]) => {
70
      const y = ys[i], free = labelled.length < 4 && labelled.every(j => Math.abs(X(j) - X(i)) > 120);
71
      el("circle", { cx: X(i), cy: y, r: free ? 3.2 : 2.2, fill: "var(--ink)" }, peakG);
72
      if (!free) return;
73
      labelled.push(i);
74
      el("circle", { cx: X(i), cy: y, r: 4, fill: "none", stroke: "var(--ink)", class: "pu-ring" }, peakG);
75
      const right = X(i) < W - 160;
76
      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;
77
    });
78
  }
79
}
80
build();
81
// Legend: a repository's own year.
82
const lg = document.getElementById("pulse-legend");
83
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); };
84
btn("all", "var(--ink)", -1);
85
data.repos.slice(0, PAL).forEach((n, i) => btn(n.split("/").pop(), col(i), i));
86
// The total counts up; the line draws itself in; then a heartbeat while idle.
87
const totalEl = document.getElementById("pulse-total"), total = Number(totalEl.dataset.total);
88
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); }
89
else totalEl.textContent = total.toLocaleString("en-GB");
90
let idle = true, timer = 0;
91
function heartbeat() {
92
  if (reduce) return;
93
  clearTimeout(timer);
94
  timer = setTimeout(function run() {
95
    if (!idle || document.hidden) { timer = setTimeout(run, 2000); return; }
96
    const len = line.getTotalLength(), t0 = performance.now();
97
    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); } };
98
    requestAnimationFrame(go);
99
  }, 800);
100
}
101
if (!reduce) {
102
  const t0 = performance.now();
103
  let shown = false;
104
  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); };
105
  requestAnimationFrame(reveal);
106
  setTimeout(() => { shown = true; clipRect.setAttribute("width", W); heartbeat(); }, 1700);
107
}
108
// Pointer: spotlight, scan line and the day's card.
109
const fmt = d => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
110
s.addEventListener("pointermove", e => {
111
  idle = false; beat.setAttribute("opacity", "0");
112
  const r = s.getBoundingClientRect(), x = (e.clientX - r.left) * (W / r.width);
113
  const i = Math.max(0, Math.min(LAST, Math.round((x - L) / ((W - L - R) / LAST)))), d = days[i];
114
  spotRect.setAttribute("fill", "url(#pu-spot)"); spot.setAttribute("gradientTransform", `translate(${X(i)} ${ys[i]}) scale(160 240)`);
115
  scan.setAttribute("x1", X(i)); scan.setAttribute("x2", X(i)); scan.setAttribute("opacity", ".45");
116
  dot.setAttribute("cx", X(i)); dot.setAttribute("cy", ys[i]); dot.style.stroke = col(only < 0 ? top(d) : only); dot.setAttribute("opacity", "1");
117
  const n = val(d), share = d.t ? Math.round(100 * d.via / d.t) : 0;
118
  const bar = d.t ? d.by.map(([k, m]) => `<i style="width:${100 * m / d.t}%;background:${col(k)}"></i>`).join("") : "";
119
  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("");
120
  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>` : ""}`;
121
  card.style.opacity = "1";
122
  const px = e.clientX - r.left, cw = card.offsetWidth;
123
  card.style.left = Math.max(0, Math.min(r.width - cw, px + (px > r.width * .6 ? -cw - 14 : 14))) + "px";
124
});
125
s.addEventListener("pointerleave", () => { idle = true; spotRect.setAttribute("fill", "#fff"); scan.setAttribute("opacity", "0"); dot.setAttribute("opacity", "0"); card.style.opacity = "0"; });
126
document.addEventListener("visibilitychange", () => { if (!document.hidden) heartbeat(); });
127
})();
128
  </script>
129
  {% endif %}
130
</section>