/**
* @module miniGraph
* @description Renders a mini bandwidth graph in the console sidebar.
* Loaded as a classic script from the sidebar markup so the first frame
* draws at parse time, independent of page load. When
* routerconsole.graphNewRenderer is enabled, uses the dual-baseline
* client-side canvas renderer; otherwise falls back to the SharedWorker-
* based RRD4J SVG image renderer.
* @author dr|z3d
* @license AGPL3 or later
*/
(() => {
'use strict';
/**
* Whether the new dual-baseline renderer is enabled.
* @type {boolean}
*/
const useNewRenderer = window.graphNewRenderer === true;
// ─── Shared constants ────────────────────────────────────────────────
/**
* Canvas width in pixels.
* @type {number}
*/
const WIDTH = 245;
/**
* Canvas height in pixels.
* @type {number}
*/
const HEIGHT = 50;
/**
* Padding inset in pixels.
* @type {number}
*/
const PAD = 4;
/**
* Drawing area width (excluding padding).
* @type {number}
*/
const DRAW_W = WIDTH - PAD * 2;
/**
* Drawing area height (excluding padding).
* @type {number}
*/
const DRAW_H = HEIGHT - PAD * 2;
/**
* Center Y coordinate — baseline for both halves (padded).
* @type {number}
*/
const CENTER_Y = PAD + DRAW_H / 2;
/**
* sessionStorage key for shift buffers.
* @type {string}
*/
const BUFFER_KEY = "minigraph_buffers";
/**
* Target buffer length for the interpolated RRD fallback (~20 min at 3s).
* @type {number}
*/
const TARGET_BUFFER = 400;
/**
* Max buffer length — matches server BandwidthHistory CAPACITY (1200 = 20 min @1s).
* @type {number}
*/
const MAX_BUFFER = 1200;
/**
* Real seconds represented by each buffer sample (BandwidthHistory samples @1s).
* @type {number}
*/
const SAMPLE_SECONDS = 1;
/**
* Buffer length that represents the configured period. One sample per
* SAMPLE_SECONDS, capped at MAX_BUFFER (so periods beyond 20 min clamp to 20).
* @function periodLength
* @param {number} minutes - Configured graph period in minutes
* @returns {number} Number of samples the buffer should hold
*/
function periodLength(minutes) {
return Math.min(Math.round(minutes * 60 / SAMPLE_SECONDS), MAX_BUFFER);
}
// ─── Shared state ────────────────────────────────────────────────────
/**
* Sidebar refresh interval in ms.
* @type {number}
*/
const POLL_INTERVAL = refresh != null ? Math.max(refresh * 1000, 1000) : 3000;
/** @type {?HTMLCanvasElement} */
let graphCanvas = null;
/** @type {?CanvasRenderingContext2D} */
let graphCtx = null;
/**
* Offscreen canvas for double-buffering.
* @type {?HTMLCanvasElement}
*/
let offscreenCanvas = null;
/** @type {?CanvasRenderingContext2D} */
let offscreenCtx = null;
/**
* Shift buffer for rx data.
* @type {?number[]}
*/
let rxBuffer = null;
/**
* Shift buffer for tx data.
* @type {?number[]}
*/
let txBuffer = null;
/**
* Timestamp of last buffer shift (ms) — prevents double-shift from dual callers.
* @type {number}
*/
let lastShiftTime = 0;
// ─── Legacy-only state ───────────────────────────────────────────────
/** @type {number} */
let refreshInterval = refresh !== null ? Math.max(refresh * 1000, 1000) : 5000;
/** @type {number|undefined} */
let minigraphRefreshIntervalId;
/** @type {number} */
let lastRefreshTime = 0;
/** @type {?SharedWorker} */
let worker;
if (!useNewRenderer) {
offscreenCanvas = document.createElement("canvas");
worker = new SharedWorker("/js/fetchWorker.js");
}
// ═════════════════════════════════════════════════════════════════════
// New dual-baseline canvas renderer
// ═════════════════════════════════════════════════════════════════════
/**
* Reads a CSS custom property from the document root.
* @function getCSSVar
* @param {string} name - CSS variable name (e.g. "--minigraph_in")
* @returns {string} The computed value, or empty string if not set
*/
function getCSSVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/**
* Creates a Canvas fill style from a CSS variable value.
* Supports flat colors ("#0cc", "rgba(...)") and CSS linear-gradient syntax.
* @function parseFillStyle
* @param {CanvasRenderingContext2D} ctx - Canvas rendering context
* @param {string} cssValue - CSS color or linear-gradient string
* @param {number} startY - Gradient start Y coordinate (used only for gradients)
* @param {number} endY - Gradient end Y coordinate (used only for gradients)
* @returns {string|CanvasGradient} A canvas-compatible fill style
*/
function parseFillStyle(ctx, cssValue, startY, endY) {
if (!cssValue || !cssValue.includes("gradient")) {return cssValue || "transparent";}
const grad = ctx.createLinearGradient(0, startY, 0, endY);
const stops = cssValue.match(/#[0-9a-f]{3,8}|rgba?\([^)]+\)|transparent/gi) || [];
const n = stops.length;
stops.forEach((color, i) => {
grad.addColorStop(n > 1 ? i / (n - 1) : 0, color);
});
return grad;
}
/**
* Draws a cardinal spline through a set of points on a canvas context.
* Based on Paul Bourke's cardinal spline implementation.
* @function drawSpline
* @param {CanvasRenderingContext2D} ctx - Canvas rendering context
* @param {Array<{x: number, y: number}>} pts - Array of points to interpolate through
* @param {boolean} close - Whether to close the path back to the first point
* @param {number} tension - Spline tension (0.5 = Catmull-Rom)
*/
function drawSpline(ctx, pts, close, tension) {
const n = pts.length;
if (n < 2) {return;}
const t = tension || 0.5;
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 0; i < n - 1; i++) {
const p0 = i > 0 ? pts[i - 1] : pts[0];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = i + 2 < n ? pts[i + 2] : pts[n - 1];
const cp1x = p1.x + (p2.x - p0.x) / (6 * t);
const cp1y = p1.y + (p2.y - p0.y) / (6 * t);
const cp2x = p2.x - (p3.x - p1.x) / (6 * t);
const cp2y = p2.y - (p3.y - p1.y) / (6 * t);
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
}
if (close) {
ctx.closePath();
}
}
/**
* Converts a data value to a Y coordinate for the top half (inbound, inverted).
* Value 0 maps to CENTER_Y (center), max value maps to PAD (top edge).
* @function valueToYIn
* @param {number} value - The data value
* @param {number} maxVal - The maximum value in the dataset
* @returns {number} Y coordinate
*/
function valueToYIn(value, maxVal) {
if (maxVal <= 0) {return CENTER_Y;}
return CENTER_Y - (value / maxVal) * (CENTER_Y - PAD);
}
/**
* Converts a data value to a Y coordinate for the bottom half (outbound, normal).
* Value 0 maps to CENTER_Y (center), max value maps to HEIGHT - PAD (bottom edge).
* @function valueToYOut
* @param {number} value - The data value
* @param {number} maxVal - The maximum value in the dataset
* @returns {number} Y coordinate
*/
function valueToYOut(value, maxVal) {
if (maxVal <= 0) {return CENTER_Y;}
return CENTER_Y + (value / maxVal) * (HEIGHT - PAD - CENTER_Y);
}
/**
* Converts a data value to a Y coordinate for overlay mode (both lines from top).
* Value 0 maps to PAD (top edge), max value maps to HEIGHT - PAD (bottom edge).
* @function valueToYOverlay
* @param {number} value - The data value
* @param {number} maxVal - The maximum value in the dataset
* @returns {number} Y coordinate
*/
function valueToYOverlay(value, maxVal) {
if (maxVal <= 0) {return PAD;}
return PAD + (value / maxVal) * DRAW_H;
}
/**
* Parses a comma-separated string of numeric values into an array of numbers.
* @function parseValues
* @param {string} str - Comma-separated values
* @returns {number[]} Parsed numeric values
*/
function parseValues(str) {
if (!str) {return [];}
return str.split(",").map(Number);
}
/**
* Linearly interpolates a low-resolution array into a high-resolution array.
* Each pair of adjacent points is expanded to `pointsPerStep` sub-points.
* @function interpolate
* @param {number[]} lowRes - Low-resolution data (e.g. 20 RRD points at 1-min intervals)
* @param {number} pointsPerStep - Number of output points per input interval (e.g. 3 for 20s resolution)
* @returns {number[]} Interpolated high-resolution array
*/
function interpolate(lowRes, pointsPerStep) {
if (!lowRes || lowRes.length < 2) {return lowRes || [];}
if (pointsPerStep < 1) {pointsPerStep = 1;}
const out = [];
for (let i = 0; i < lowRes.length - 1; i++) {
const a = lowRes[i];
const b = lowRes[i + 1];
for (let j = 0; j < pointsPerStep; j++) {
const t = j / pointsPerStep;
out.push(a + (b - a) * t);
}
}
out.push(lowRes[lowRes.length - 1]);
return out;
}
/**
* Restores shift buffers from sessionStorage if available and data-minutes matches.
* Accepts any buffer length >= 2 (old 20-point or new 400-point buffers).
* @function restoreBuffers
* @param {number} minutes - Expected time period
* @returns {boolean} True if buffers were restored
*/
function restoreBuffers(minutes) {
try {
const saved = JSON.parse(sessionStorage.getItem(BUFFER_KEY));
const need = periodLength(minutes);
if (saved && saved.minutes === minutes && saved.rx && saved.tx &&
saved.rx.length >= need / 2 && saved.tx.length >= need / 2) {
rxBuffer = saved.rx;
txBuffer = saved.tx;
return true;
}
} catch (e) { /* ignored */ }
return false;
}
/**
* Persists shift buffers to sessionStorage.
* Caps stored length to the period length to avoid bloat.
* @function saveBuffers
* @param {number} minutes - Time period
*/
function saveBuffers(minutes) {
try {
const need = periodLength(minutes);
const rxStore = rxBuffer.length > need ? rxBuffer.slice(-need) : rxBuffer;
const txStore = txBuffer.length > need ? txBuffer.slice(-need) : txBuffer;
sessionStorage.setItem(BUFFER_KEY, JSON.stringify({
minutes,
rx: rxStore,
tx: txStore
}));
} catch (e) { /* ignored */ }
}
/**
* Draws a single half of the graph (inbound or outbound).
* Supports two-pass rendering: pass="fill" draws only the fill,
* pass="stroke" draws only glow + line. Omit for single-pass (all 3 layers).
* @function drawHalf
* @param {CanvasRenderingContext2D} ctx - Canvas rendering context
* @param {number[]} values - Array of data values
* @param {number} maxVal - Maximum value for scaling
* @param {string} lineColor - Stroke color for the line
* @param {string} fillColor - CSS color or linear-gradient string for the area fill
* @param {Function} yMapper - Function to map value to Y coordinate
* @param {boolean} fillDown - Whether fill goes downward from the curve (true for outbound)
* @param {boolean} rtl - Right-to-left rendering
* @param {number} glowWidth - Glow stroke width
* @param {number} glowAlpha - Glow opacity
* @param {number} glowBlur - Glow blur radius
* @param {number} lineWidth - Line stroke width
* @param {number} tension - Spline tension
* @param {number} baselineY - Y coordinate for fill closure
* @param {?string} blendMode - Canvas globalCompositeOperation for fill (null for none)
* @param {?string} pass - "fill" for fill-only, "stroke" for glow+line only, null for all
*/
function drawHalf(ctx, values, maxVal, lineColor, fillColor, yMapper, fillDown, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, baselineY, blendMode, pass) {
if (values.length < 2) {return;}
if (baselineY == null) {baselineY = CENTER_Y;}
const stepX = DRAW_W / (values.length - 1);
const n = values.length;
const pts = values.map((v, i) => ({
x: rtl ? PAD + (n - 1 - i) * stepX : PAD + i * stepX,
y: yMapper(v, maxVal)
}));
pts[0].x += rtl ? stepX * 0.5 : -stepX * 0.5;
pts[pts.length - 1].x += rtl ? -stepX * 0.5 : stepX * 0.5;
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, WIDTH, HEIGHT);
ctx.clip();
const t = tension || 0.5;
// Fill pass
if (pass !== "stroke") {
const fillStartX = pts[0].x;
const fillEndX = pts[pts.length - 1].x;
ctx.fillStyle = parseFillStyle(ctx, fillColor, baselineY, fillDown ? HEIGHT : PAD);
if (blendMode) {ctx.globalCompositeOperation = blendMode;}
ctx.beginPath();
ctx.moveTo(fillStartX, baselineY);
ctx.lineTo(fillStartX, pts[0].y);
for (let i = 0; i < pts.length - 1; i++) {
const p0 = i > 0 ? pts[i - 1] : pts[0];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = i + 2 < pts.length ? pts[i + 2] : pts[pts.length - 1];
const cp1x = p1.x + (p2.x - p0.x) / (6 * t);
const cp1y = p1.y + (p2.y - p0.y) / (6 * t);
const cp2x = p2.x - (p3.x - p1.x) / (6 * t);
const cp2y = p2.y - (p3.y - p1.y) / (6 * t);
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
}
ctx.lineTo(fillEndX, baselineY);
ctx.closePath();
ctx.fill();
if (blendMode) {ctx.globalCompositeOperation = "source-over";}
}
// Stroke pass (glow + line)
if (pass !== "fill") {
ctx.strokeStyle = lineColor;
ctx.lineWidth = glowWidth;
ctx.globalAlpha = glowAlpha;
ctx.shadowColor = lineColor;
ctx.shadowBlur = glowBlur;
ctx.beginPath();
drawSpline(ctx, pts, false, t);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
ctx.strokeStyle = lineColor;
ctx.lineWidth = lineWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
drawSpline(ctx, pts, false, t);
ctx.stroke();
}
ctx.restore();
}
/**
* Draws a subtle dotted grid behind the graph data.
* Split mode: horizontal line at center baseline, vertical lines at adaptive intervals.
* Overlay mode: 3 horizontal grid lines (top, middle, bottom), vertical lines at adaptive intervals.
* @function drawGrid
* @param {CanvasRenderingContext2D} ctx - Canvas rendering context
* @param {number} minutes - Total time period in minutes
* @param {boolean} split - True for split mode, false for overlay
*/
function drawGrid(ctx, minutes, split) {
ctx.save();
ctx.strokeStyle = getCSSVar("--minigraph_grid") || "rgba(128,128,128,.15)";
ctx.lineWidth = 0.5;
ctx.setLineDash([1, 1]);
// Horizontal lines
ctx.beginPath();
if (split) {
ctx.moveTo(PAD, CENTER_Y);
ctx.lineTo(PAD + DRAW_W, CENTER_Y);
} else {
for (const frac of [0.25, 0.5, 0.75]) {
const y = PAD + frac * DRAW_H;
ctx.moveTo(PAD, y);
ctx.lineTo(PAD + DRAW_W, y);
}
}
ctx.stroke();
// Vertical lines — use clean intervals (5, 10, 15, 20, 30, 60 min)
let stepMin;
let cols;
if (minutes > 40) {
const intervals = [5, 10, 15, 20, 30, 60];
stepMin = minutes / 10;
for (const iv of intervals) {
if (iv >= stepMin) {stepMin = iv; break;}
}
cols = Math.round(minutes / stepMin);
} else {
cols = Math.min(Math.round(minutes / 2), 10);
stepMin = minutes / cols;
}
const stepX = DRAW_W / cols;
for (let i = 1; i < cols; i++) {
ctx.beginPath();
ctx.moveTo(PAD + i * stepX, PAD);
ctx.lineTo(PAD + i * stepX, PAD + DRAW_H);
ctx.stroke();
}
ctx.restore();
}
/**
* Main render function. Reads data from the canvas element attributes
* and draws the dual-baseline graph.
* @function renderNewGraph
* @returns {void}
*/
function renderNewGraph() {
const el = document.getElementById("minigraph");
if (!el) {return;}
if (el !== graphCanvas) {
graphCanvas = el;
graphCtx = null;
}
const continuous = graphCanvas.dataset.continuous === "1";
const rxStr = graphCanvas.dataset.rx;
const txStr = graphCanvas.dataset.tx;
const minutes = parseInt(graphCanvas.dataset.minutes, 10) || 20;
// Parse server data, extract live value (last element), update shift buffer
if (rxStr && txStr) {
const rxAll = parseValues(rxStr);
const txAll = parseValues(txStr);
const liveRx = rxAll.pop();
const liveTx = txAll.pop();
if (rxBuffer === null) {
const need = periodLength(minutes);
if (continuous) {
// Scroll mode — seed buffer from the full server history
// (up to MAX_BUFFER samples from /tmp/i2p-bandwidth.dat),
// not just the last DRAW_W, so prior data survives page loads.
rxBuffer = rxAll.slice();
txBuffer = txAll.slice();
} else if (!restoreBuffers(minutes)) {
// When the server sends the full real 1s history (>= TARGET_BUFFER
// samples, matching BandwidthHistory CAPACITY), use it directly so
// the configured period is shown accurately. The interpolate() path
// is only for the sparse RRD fallback (~minutes points), upsampled
// to the period length for a smooth curve.
if (rxAll.length >= TARGET_BUFFER) {
rxBuffer = rxAll.slice();
txBuffer = txAll.slice();
} else {
rxBuffer = interpolate(rxAll, Math.max(Math.round(need / rxAll.length), 1));
txBuffer = interpolate(txAll, Math.max(Math.round(need / txAll.length), 1));
}
}
// Truncate the buffer to exactly the configured period so the X-axis
// always spans `minutes` and the window fully replaces in `minutes`.
if (rxBuffer.length > need) {
rxBuffer.splice(0, rxBuffer.length - need);
txBuffer.splice(0, txBuffer.length - need);
}
while (rxBuffer.length < DRAW_W) {
rxBuffer.unshift(rxBuffer[0] || 0);
txBuffer.unshift(txBuffer[0] || 0);
}
rxBuffer.push(liveRx);
txBuffer.push(liveTx);
if (rxBuffer.length > need) {
rxBuffer.splice(0, rxBuffer.length - need);
txBuffer.splice(0, txBuffer.length - need);
}
lastShiftTime = Date.now();
if (!continuous) {saveBuffers(minutes);}
} else {
const now = Date.now();
// Each buffer sample represents SAMPLE_SECONDS of real time, so advance
// by elapsed wall-clock seconds — independent of poll cadence. This makes
// the window span exactly `minutes` and fully replace in `minutes` min.
const elapsedSec = (now - lastShiftTime) / 1000;
const shifts = Math.floor(elapsedSec / SAMPLE_SECONDS);
const need = periodLength(minutes);
if (shifts > 0) {
// Catch up on missed shifts — push the latest value for each
const maxShift = Math.min(shifts, need);
for (let s = 0; s < maxShift; s++) {
rxBuffer.shift();
txBuffer.shift();
rxBuffer.push(s === 0 && liveRx !== undefined ? liveRx : rxBuffer[rxBuffer.length - 1]);
txBuffer.push(s === 0 && liveTx !== undefined ? liveTx : txBuffer[txBuffer.length - 1]);
}
lastShiftTime += shifts * SAMPLE_SECONDS * 1000;
if (!continuous) {saveBuffers(minutes);}
} else if (!continuous && liveRx !== undefined) {
rxBuffer[rxBuffer.length - 1] = liveRx;
txBuffer[txBuffer.length - 1] = liveTx;
}
}
}
if (!rxBuffer || !txBuffer) {return;}
const rxValues = rxBuffer;
const txValues = txBuffer;
if (rxValues.length < 2 && txValues.length < 2) {return;}
// Lazily create offscreen canvas for double-buffering
if (!offscreenCanvas) {
offscreenCanvas = document.createElement("canvas");
offscreenCanvas.width = WIDTH;
offscreenCanvas.height = HEIGHT;
}
if (!offscreenCtx) {
offscreenCtx = offscreenCanvas.getContext("2d");
}
if (!graphCtx) {
graphCtx = graphCanvas.getContext("2d");
}
const rxMax = Math.max(...rxValues, 1);
const txMax = Math.max(...txValues, 1);
const globalMax = Math.max(rxMax, txMax);
// Split mode: true = split display (inbound top, outbound bottom), false = overlay
const split = graphCanvas.dataset.split !== "0";
const s = split ? "" : "_combined";
// Read theme colors from CSS variables (use _combined suffix for overlay mode)
const rxColor = getCSSVar("--minigraph_in" + s) || "#0cc";
const txColor = getCSSVar("--minigraph_out" + s) || "#f90";
const rxFill = getCSSVar("--minigraph_in_fill" + s) || "rgba(0,204,204,.15)";
const txFill = getCSSVar("--minigraph_out_fill" + s) || "rgba(255,153,0,.15)";
const rtl = window.graphDirection === "rtl";
const glowWidth = parseFloat(getCSSVar("--minigraph_glow_width" + s)) || 4;
const glowAlpha = parseFloat(getCSSVar("--minigraph_glow_alpha" + s)) || 0.3;
const glowBlur = parseFloat(getCSSVar("--minigraph_glow_blur" + s)) || 6;
const lineWidth = parseFloat(getCSSVar("--minigraph_line_width" + s)) || 1.5;
const tension = parseFloat(getCSSVar("--minigraph_tension" + s)) || 0.5;
const blendMode = getCSSVar("--minigraph_overlay_blend" + s) || "screen";
// Draw to offscreen canvas
offscreenCtx.clearRect(0, 0, WIDTH, HEIGHT);
drawGrid(offscreenCtx, minutes, split);
if (split) {
// Split: single-pass, no blend needed
drawHalf(offscreenCtx, txValues, globalMax, txColor, txFill, valueToYIn, false, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, CENTER_Y, null, null);
drawHalf(offscreenCtx, rxValues, globalMax, rxColor, rxFill, valueToYOut, true, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, CENTER_Y, null, null);
} else {
// Overlay: two-pass — fills blended first, then strokes on top
drawHalf(offscreenCtx, txValues, globalMax, txColor, txFill, valueToYOverlay, false, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, HEIGHT, blendMode, "fill");
drawHalf(offscreenCtx, rxValues, globalMax, rxColor, rxFill, valueToYOverlay, false, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, HEIGHT, blendMode, "fill");
drawHalf(offscreenCtx, txValues, globalMax, txColor, txFill, valueToYOverlay, false, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, HEIGHT, null, "stroke");
drawHalf(offscreenCtx, rxValues, globalMax, rxColor, rxFill, valueToYOverlay, false, rtl, glowWidth, glowAlpha, glowBlur, lineWidth, tension, HEIGHT, null, "stroke");
}
// Copy to visible canvas in one operation
graphCtx.clearRect(0, 0, WIDTH, HEIGHT);
graphCtx.drawImage(offscreenCanvas, 0, 0);
}
/**
* Initializes the new minigraph renderer. Re-renders on a polling interval
* to handle canvas element replacement during sidebar XHR refreshes.
* @function initNewGraph
* @returns {void}
*/
function initNewGraph() {
graphCanvas = document.getElementById("minigraph");
if (!graphCanvas) {return;}
// Initial render
renderNewGraph();
// Combined (overlay) mode class for styling
if (graphCanvas.dataset.split === "0") {
document.body.classList.add("minigraphCombined");
}
// Re-render immediately after full sidebar replacement (refreshAll)
document.addEventListener("sidebarRefreshed", () => {
const el = document.getElementById("minigraph");
if (el !== graphCanvas) {
graphCanvas = el;
graphCtx = null;
}
if (graphCanvas) {
renderNewGraph();
if (graphCanvas.dataset.split === "0") {
document.body.classList.add("minigraphCombined");
} else {
document.body.classList.remove("minigraphCombined");
}
}
});
// In continuous scroll mode, render every 1s for smooth per-pixel shift
const scrollMode = graphCanvas.dataset.continuous === "1";
// Poll for canvas element replacement or data attribute changes.
const pollGraph = () => {
const el = document.getElementById("minigraph");
if (el !== graphCanvas) {
graphCanvas = el;
graphCtx = null;
}
if (graphCanvas && rxBuffer !== null) {renderNewGraph();}
};
let pollIntervalId = setInterval(pollGraph, scrollMode ? 1000 : POLL_INTERVAL);
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearInterval(pollIntervalId);
} else {
// Reset lastShiftTime so the shift logic catches up
// naturally without nulling the buffer — avoids the
// visual "replay" of re-initializing from server data.
lastShiftTime = Date.now();
const el = document.getElementById("minigraph");
const mode = el && el.dataset.continuous === "1";
pollIntervalId = setInterval(pollGraph, mode ? 1000 : POLL_INTERVAL);
}
});
window.addEventListener("beforeunload", () => {
clearInterval(pollIntervalId);
});
}
// ═════════════════════════════════════════════════════════════════════
// Legacy SharedWorker renderer
// ═════════════════════════════════════════════════════════════════════
/**
* Initializes the mini graph by starting the SharedWorker connection and
* setting up the message handler.
* @function miniGraph
* @returns {void}
*/
function miniGraph() {
worker.port.start();
worker.port.addEventListener("message", handleWorkerMessage);
worker.port.postMessage({ type: "connect" });
}
/**
* Handles messages from the SharedWorker, initiating refresh intervals
* and processing graph image updates.
* @function handleWorkerMessage
* @param {MessageEvent} event - The message event from the SharedWorker
* @returns {void}
*/
function handleWorkerMessage(event) {
if (event.data.type === "connected" && !minigraphRefreshIntervalId) {
minigraphRefreshIntervalId = setInterval(refreshGraph, refreshInterval);
}
if (event.data.responseBlob) {
handleGraphUpdate(event.data.responseBlob);
}
}
/**
* Sends a fetch request to the worker for the latest bandwidth graph image.
* @function refreshGraph
* @returns {void}
*/
function refreshGraph() {
const currentTime = Date.now();
if (currentTime - lastRefreshTime >= refreshInterval) {
lastRefreshTime = currentTime;
const graphCanvas = document.getElementById("minigraph");
if (!graphCanvas) { return; }
worker.port.postMessage({ url: `/viewstat.jsp?stat=bw.combined&periodCount=20&width=250&height=50&hideLegend=true&hideGrid=true&hideTitle=true&t=${Date.now()}` });
}
}
/**
* Processes a graph image blob from the worker and renders it to the offscreen canvas.
* @function handleGraphUpdate
* @param {Blob} responseBlob - The graph image blob data
* @returns {void}
*/
function handleGraphUpdate(responseBlob) {
const image = new Image();
const objectUrl = URL.createObjectURL(responseBlob);
image.src = objectUrl;
image.onload = () => {
URL.revokeObjectURL(objectUrl);
const graphCanvas = document.getElementById("minigraph");
if (graphCanvas) {
const ctx = offscreenCanvas.getContext("2d");
offscreenCanvas.width = WIDTH;
offscreenCanvas.height = HEIGHT;
ctx.clearRect(0, 0, WIDTH, HEIGHT);
ctx.drawImage(image, 0, 0);
const parent = graphCanvas.parentNode;
if (parent) {
parent.replaceChild(offscreenCanvas, graphCanvas);
offscreenCanvas.id = "minigraph";
}
}
};
}
// ═════════════════════════════════════════════════════════════════════
// Initialization
// ═════════════════════════════════════════════════════════════════════
/**
* Starts the active renderer: the new canvas renderer at parse time when
* the sidebar markup is already present, otherwise on DOMContentLoaded
* (covers the rare case the script is included before the canvas).
* @function init
* @returns {void}
*/
function init() {
if (useNewRenderer) {
initNewGraph();
} else {
miniGraph();
refreshGraph();
minigraphRefreshIntervalId = setInterval(refreshGraph, refreshInterval);
}
}
// The script tag follows the sidebar markup in sidebar_noframe.jsi, so the
// canvas exists at parse time and the graph paints without waiting for load.
if (document.getElementById("minigraph")) {
init();
} else {
document.addEventListener("DOMContentLoaded", init);
}
document.addEventListener("visibilitychange", () => {
if (useNewRenderer) {return;}
if (document.hidden) {clearInterval(minigraphRefreshIntervalId);}
else {minigraphRefreshIntervalId = setInterval(refreshGraph, refreshInterval);}
});
// Re-render hook for refreshSidebar after full sidebar replacement
window.renderNewGraph = renderNewGraph;
})();