Files
dgtlmoon e634365113
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
WIP
2026-06-15 23:49:05 +02:00

281 lines
12 KiB
JavaScript

/*
* Queue activity sparkline — a compact "is it alive?" activity strip for the sidebar.
*
* UX goal: answer the user's gut question "is this thing actually doing anything?".
* - A pinned, gently PULSING leading dot at the right edge beats like a heartbeat
* even when totally idle, so the UI always reads as alive/monitoring — a frozen
* dot would read as broken.
* - Real in-flight work bumps the line, which then scrolls left across the window,
* so you can see it really does check things over the last few minutes.
*
* Signal plotted = queue_size + checking_now (total in-flight work). Either being
* non-zero lifts the trace; plain queue size alone sits at 0 while workers drain it.
*
* Rendering: a scrolling strip chart. The right edge is "now", the left edge is
* "now - WINDOW_SECONDS". New samples always enter from the right; old data scrolls
* off the left and fades out as it goes (left-edge brightness ramp).
*
* Hardening (lessons from common sparkline libs, e.g. fnando/sparkline #14/#24):
* - Autoscaled 0..max with headroom + a MIN floor, so it always stays inside the
* canvas height and never divides by zero on a flat/all-zero queue.
* - devicePixelRatio aware (crisp on retina), re-measured on resize (the rail
* expands 64px->190px on hover).
* - Pauses the rAF loop when the tab is hidden.
*
* History survives a full page reload via localStorage: we persist the queue/checking
* EVENT LOG (a small step-function, not pixels). On load we sample that log across the
* visible window, so the strip immediately shows the last few minutes instead of a
* blank screen.
*
* Fed by the same Socket.IO 'queue_size' / 'checking_now' events as realtime.js.
*/
(function () {
"use strict";
const STORE_KEY = "cdio-queue-spark-v1";
const WINDOW_SECONDS = 180; // how much recent history the strip shows (a few minutes)
const RETAIN_MS = WINDOW_SECONDS * 1000 * 1.5; // keep a little more than one screenful
const MAX_EVENTS = 600; // hard cap on stored events (localStorage size guard)
const MIN_SCALE = 1; // autoscale floor: avoids /0 and stops a 0/1 queue filling the panel
const PULSE_MS = 1500; // leading-dot heartbeat period — liveness even when idle
const RISE_LERP = 0.30; // autoscale rises quickly so a new bump shows promptly
const FALL_LERP = 0.04; // ...and falls slowly so it doesn't jitter
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const FRAME_MS = reduceMotion ? 250 : 100; // 10fps (plenty here); slower under reduced-motion
// --- Shared state across all canvases (desktop rail + mobile drawer share one history) ---
let eventLog = []; // [{t: epoch_ms, v: number}] step-function of total activity
let currentQueue = 0;
let currentChecking = 0;
function totalNow() { return currentQueue + currentChecking; }
function stepValueAt(t) {
// Last event at or before t (step function). Log is small; scan from the end.
for (let i = eventLog.length - 1; i >= 0; i--) {
if (eventLog[i].t <= t) return eventLog[i].v;
}
return NaN; // before our earliest knowledge -> leave that column blank
}
function pushEvent(now) {
const v = totalNow();
const last = eventLog[eventLog.length - 1];
if (!last || last.v !== v) eventLog.push({ t: now, v: v });
}
function trimLog(now) {
const cutoff = now - RETAIN_MS;
// Keep the most recent event that predates the cutoff as a baseline anchor.
let anchor = -1;
for (let i = 0; i < eventLog.length; i++) {
if (eventLog[i].t <= cutoff) anchor = i; else break;
}
if (anchor > 0) eventLog.splice(0, anchor);
if (eventLog.length > MAX_EVENTS) eventLog.splice(0, eventLog.length - MAX_EVENTS);
}
function loadLog() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
const log = Array.isArray(parsed) ? parsed : parsed.log;
if (!Array.isArray(log)) return [];
eventLog = log.filter(e => e && typeof e.t === "number" && typeof e.v === "number");
trimLog(Date.now());
return eventLog;
} catch (e) {
return [];
}
}
let saveTimer = null;
function saveLog() {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
try {
trimLog(Date.now());
localStorage.setItem(STORE_KEY, JSON.stringify({ savedAt: Date.now(), log: eventLog }));
} catch (e) { /* quota / private mode — history is best-effort */ }
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(function () { saveTimer = null; saveLog(); }, 750);
}
// --- One Spark per canvas (different widths: rail vs drawer) ---
function Spark(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.W = 1;
this.cssW = 0;
this.cssH = 0;
this.rawW = -1; // last-seen clientWidth/Height (incl. padding) — cheap change check
this.rawH = -1;
this.displayMax = MIN_SCALE;
this.resize();
}
// Measure the canvas CONTENT box straight from the browser's computed CSS, so
// sizing fully follows the stylesheet (padding/box styling from e.g. the
// .action-sidebar-item class included) instead of hand-computed pixel maths.
// clientWidth/Height already exclude border + scrollbar; subtract padding to
// land on the exact content box that the canvas bitmap is drawn into.
Spark.prototype.resize = function () {
const rawW = this.canvas.clientWidth;
const rawH = this.canvas.clientHeight;
const cs = getComputedStyle(this.canvas);
const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
const cssW = Math.max(0, Math.round(rawW - padX));
const cssH = Math.max(0, Math.round(rawH - padY));
this.rawW = rawW;
this.rawH = rawH;
if (cssW === this.cssW && cssH === this.cssH) return;
this.cssW = cssW;
this.cssH = cssH;
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(cssW * dpr));
this.canvas.height = Math.max(1, Math.round(cssH * dpr));
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.W = Math.max(1, cssW);
};
// Wall-clock time represented by pixel column x: right edge = now, left edge = now - window.
Spark.prototype.columnTime = function (x, now) {
return now - ((this.W - 1 - x) / Math.max(1, this.W - 1)) * (WINDOW_SECONDS * 1000);
};
Spark.prototype.y = function (v) {
const pad = 2;
const h = Math.max(1, this.cssH - pad * 2);
const t = Math.min(1, v / this.displayMax);
return pad + h * (1 - t);
};
Spark.prototype.render = function (now) {
if (this.cssW < 4 || this.cssH < 4) return;
if (this.canvas.clientWidth !== this.rawW || this.canvas.clientHeight !== this.rawH) this.resize();
const W = this.W;
// Sample the step-function across the visible window (newest at right) and
// find the autoscale max in the same pass.
const ys = new Array(W);
let maxV = MIN_SCALE;
for (let x = 0; x < W; x++) {
const v = stepValueAt(this.columnTime(x, now));
ys[x] = v;
if (!isNaN(v) && v > maxV) maxV = v;
}
const tv = totalNow();
if (tv > maxV) maxV = tv;
const rate = maxV > this.displayMax ? RISE_LERP : FALL_LERP;
this.displayMax += (maxV - this.displayMax) * rate;
const ctx = this.ctx;
ctx.clearRect(0, 0, this.cssW, this.cssH);
// Whole trace in a SINGLE stroke; the left->right fade (old data dimming as it
// scrolls out of view) is a gradient strokeStyle rather than one stroke per
// pixel column — ~140 draw calls collapse to 1.
const grad = ctx.createLinearGradient(0, 0, this.cssW, 0);
grad.addColorStop(0, "rgba(255,255,255,0.10)");
grad.addColorStop(1, "rgba(255,255,255,0.95)");
ctx.strokeStyle = grad;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
let pen = false;
for (let x = 0; x < W; x++) {
const v = ys[x];
if (isNaN(v)) { pen = false; continue; } // gap (no history yet) breaks the line
const py = this.y(v);
if (pen) ctx.lineTo(x + 0.5, py);
else { ctx.moveTo(x + 0.5, py); pen = true; }
}
ctx.stroke();
// Pinned, gently pulsing leading dot at the right edge = the "it's alive" tell.
const rightV = isNaN(ys[W - 1]) ? tv : ys[W - 1];
const pulse = 0.5 + 0.5 * Math.sin(((now % PULSE_MS) / PULSE_MS) * Math.PI * 2);
ctx.save();
ctx.shadowColor = "rgba(255,255,255,0.9)";
ctx.shadowBlur = 4 + 5 * pulse;
ctx.fillStyle = "rgba(255,255,255," + (0.75 + 0.25 * pulse).toFixed(3) + ")";
ctx.beginPath();
ctx.arc(W - 1.5, this.y(rightV), 1.6 + 0.7 * pulse, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
// --- Animation loop ---
let instances = [];
let rafId = null;
let lastFrame = 0;
function frame(ts) {
rafId = requestAnimationFrame(frame);
if (ts - lastFrame < FRAME_MS) return;
lastFrame = ts;
const now = Date.now();
for (let i = 0; i < instances.length; i++) instances[i].render(now);
}
function start() { if (rafId === null) { lastFrame = 0; rafId = requestAnimationFrame(frame); } }
function stop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }
function attach(socket) {
if (!socket) return;
socket.on("queue_size", function (d) {
currentQueue = parseInt(d && d.q_length, 10) || 0;
pushEvent(Date.now());
scheduleSave();
});
socket.on("checking_now", function (d) {
currentChecking = parseInt(d && d.count, 10) || 0;
pushEvent(Date.now());
scheduleSave();
});
}
function init() {
const canvases = Array.prototype.slice.call(document.querySelectorAll("canvas.queue-spark"));
if (!canvases.length) return;
loadLog();
// Seed current levels from the server-rendered numbers so the line starts at
// the right height before the first socket event arrives.
const qEl = document.querySelector(".queue-size-int");
const cEl = document.querySelector(".checking-now-int");
if (qEl) currentQueue = parseInt(qEl.textContent, 10) || 0;
if (cEl) currentChecking = parseInt(cEl.textContent, 10) || 0;
pushEvent(Date.now());
instances = canvases.map(function (c) { return new Spark(c); });
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(function () {
for (let i = 0; i < instances.length; i++) instances[i].resize();
});
canvases.forEach(function (c) { ro.observe(c); });
}
document.addEventListener("visibilitychange", function () {
if (document.hidden) { saveLog(); stop(); }
else { start(); }
});
window.addEventListener("beforeunload", saveLog);
// realtime.js exposes the socket either already on window or via this event.
if (window.cdioSocket) attach(window.cdioSocket);
else document.addEventListener("cdio:socket-ready", function (e) { attach(e.detail && e.detail.socket); });
start();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();