/** * Canvas chart helpers — line / area / stacked / bar / multibar / pie. */ import { normalizeChartMode } from '../shared/chart-types.js' /** * @param {HTMLCanvasElement|null} canvas * @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines * @param {{ * mode?: string, * threshold?: number|null, * severity?: string|null, * stacked?: boolean, * maxPoints?: number, * grid?: boolean, * hoverIndex?: number|null, * padLeft?: number, * showYAxis?: boolean, * units?: string, * emptyMessage?: string, * dimmed?: boolean, * windowSeconds?: number|null, * endOffset?: number, * }} [opts] */ export function drawChart(canvas, lines, opts = {}) { const mode = normalizeChartMode( opts.mode || (opts.stacked ? 'stacked' : undefined) || 'line' ) if (mode === 'pie') return drawPieChart(canvas, lines, opts) if (mode === 'bar') return drawBarChart(canvas, lines, { ...opts, grouped: false }) if (mode === 'multibar') return drawBarChart(canvas, lines, { ...opts, grouped: true }) return drawMultiChart(canvas, lines, { ...opts, stacked: mode === 'stacked', mode, }) } /** * @param {HTMLCanvasElement|null} canvas * @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines * @param {object} [opts] */ /** * Shared Y-axis left padding — keep paint + hover hit-test in sync. * @param {string|undefined|null} units * @param {boolean} [showYAxis=true] */ export function padLeftFor(units, showYAxis = true) { if (showYAxis === false) return 0 const u = units ? String(units) : '' return u.length > 6 ? 52 : 46 } /** @type {WeakMap} */ const offscreenLayers = new WeakMap() /** @type {WeakMap} */ const scaleMemory = new WeakMap() /** * Size the backing store only when needed. Resetting canvas.width every paint * clears the buffer and causes a visible blink on live updates. * * Painting goes to an offscreen layer first, then a single blit to the visible * canvas so live updates never flash an empty frame mid-draw. * @param {HTMLCanvasElement} canvas * @returns {{ ctx: CanvasRenderingContext2D, w: number, h: number, commit: () => void }|null} */ function prepareCanvas(canvas) { const dpr = window.devicePixelRatio || 1 // Prefer explicit style height (stable) over clientHeight during layout thrash const styleH = parseFloat(canvas.style.height) const w = Math.max(1, Math.round(canvas.clientWidth || 320)) const h = Math.max( 1, Math.round( (Number.isFinite(styleH) && styleH > 0 ? styleH : 0) || canvas.clientHeight || canvas.height || 140 ) ) const bw = Math.max(1, Math.round(w * dpr)) const bh = Math.max(1, Math.round(h * dpr)) if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw canvas.height = bh } let layer = offscreenLayers.get(canvas) if (!layer || layer.width !== bw || layer.height !== bh) { layer = document.createElement('canvas') layer.width = bw layer.height = bh offscreenLayers.set(canvas, layer) } const ctx = layer.getContext('2d') if (!ctx) return null ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, w, h) const commit = () => { const main = canvas.getContext('2d') if (!main) return // Identity in device pixels — blit the finished frame in one shot main.setTransform(1, 0, 0, 1, 0, 0) main.clearRect(0, 0, bw, bh) main.drawImage(layer, 0, 0) } return { ctx, w, h, commit } } export function drawMultiChart(canvas, lines, opts = {}) { if (!canvas) return const preparedCanvas = prepareCanvas(canvas) if (!preparedCanvas) return const { ctx, w, h, commit } = preparedCanvas const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line')) const maxPoints = opts.maxPoints || 90 const units = opts.units ? String(opts.units) : '' const showY = opts.showYAxis !== false const padL = opts.padLeft ?? padLeftFor(units, showY) const padR = 10 const padT = 12 const showX = opts.windowSeconds != null && Number(opts.windowSeconds) > 0 const padB = showX ? 18 : 10 const plotW = Math.max(1, w - padL - padR) const plotH = Math.max(1, h - padT - padB) const prepared = prepareSeries(lines, maxPoints) if (!prepared.length) { drawEmpty(ctx, w, h, opts.emptyMessage) commit() return } if (opts.dimmed) ctx.globalAlpha = 0.72 const stacked = mode === 'stacked' || opts.stacked === true const { min, max, span } = computeScale(prepared, { stacked, threshold: opts.threshold, canvas, smooth: opts.smoothScale !== false, }) // Domain X on actual series length so short rings fill the plot width const len = Math.max(1, ...prepared.map((l) => l.values.length)) const xAt = (i) => padL + (i / Math.max(1, len - 1)) * plotW const yAt = (v) => padT + plotH - ((v - min) / span) * plotH drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) if (showY) drawYAxis(ctx, padL, padT, plotH, min, max, span, units) if (showX) drawXAxis(ctx, padL, padT, plotW, plotH, opts) if (opts.threshold != null && Number.isFinite(opts.threshold)) { drawThreshold(ctx, padL, plotW, yAt(opts.threshold), opts.severity) } if (stacked) { drawStacked(ctx, prepared, xAt, yAt) } else { prepared.forEach((line) => { const wantFill = mode === 'area' ? line.fill !== false : line.fill === true drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) }) } if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) { drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH) } ctx.globalAlpha = 1 commit() } /** * Time-series columns (bar) or grouped columns (multibar). * @param {HTMLCanvasElement|null} canvas * @param {Array<{ values: number[], color: string, label?: string, hidden?: boolean }>} lines * @param {object} [opts] */ export function drawBarChart(canvas, lines, opts = {}) { if (!canvas) return const preparedCanvas = prepareCanvas(canvas) if (!preparedCanvas) return const { ctx, w, h, commit } = preparedCanvas const maxPoints = opts.maxPoints || 90 const units = opts.units ? String(opts.units) : '' const padL = opts.padLeft ?? padLeftFor(units, true) const padR = 10 const padT = 12 const showX = opts.windowSeconds != null && Number(opts.windowSeconds) > 0 const padB = showX ? 18 : 10 const plotW = Math.max(1, w - padL - padR) const plotH = Math.max(1, h - padT - padB) const grouped = Boolean(opts.grouped) let prepared = prepareSeries(lines, maxPoints) // Multibar with many dims becomes unreadable — keep top series by latest |value| if (grouped && prepared.length > 6) { prepared = [...prepared] .sort((a, b) => Math.abs(last(b.values)) - Math.abs(last(a.values))) .slice(0, 6) } if (!prepared.length) { drawEmpty(ctx, w, h, opts.emptyMessage) commit() return } if (opts.dimmed) ctx.globalAlpha = 0.72 const stackedBars = !grouped && prepared.length > 1 const { min, max, span } = computeScale(prepared, { stacked: stackedBars, threshold: opts.threshold, canvas, smooth: opts.smoothScale !== false, }) const len = Math.max(...prepared.map((l) => l.values.length)) const yAt = (v) => padT + plotH - ((v - min) / span) * plotH const y0 = yAt(Math.max(min, Math.min(max, 0))) drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) drawYAxis(ctx, padL, padT, plotH, min, max, span, units) if (showX) drawXAxis(ctx, padL, padT, plotW, plotH, opts) const slot = plotW / Math.max(1, len) const groupCount = grouped ? prepared.length : 1 const barW = Math.max(1.5, Math.min(14, (slot * 0.72) / groupCount)) for (let i = 0; i < len; i++) { const cx = padL + slot * i + slot / 2 if (grouped) { prepared.forEach((line, gi) => { const v = line.values[i] ?? 0 const x = cx - (groupCount * barW) / 2 + gi * barW const y = yAt(v) ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.75) roundRect(ctx, x, Math.min(y, y0), barW - 0.5, Math.abs(y0 - y), 2) ctx.fill() }) } else if (stackedBars) { let acc = 0 for (const line of prepared) { const v = line.values[i] ?? 0 const y1 = yAt(acc) const y2 = yAt(acc + v) ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.8) roundRect(ctx, cx - barW / 2, Math.min(y1, y2), barW, Math.abs(y2 - y1), 1) ctx.fill() acc += v } } else { const line = prepared[0] const v = line.values[i] ?? 0 const y = yAt(v) ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.8) roundRect(ctx, cx - barW / 2, Math.min(y, y0), barW, Math.abs(y0 - y), 2) ctx.fill() } } if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) { const idx = clampIdx(opts.hoverIndex, len) const x = padL + slot * idx + slot / 2 ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)' ctx.beginPath() ctx.moveTo(x, padT) ctx.lineTo(x, padT + plotH) ctx.stroke() } ctx.globalAlpha = 1 commit() } /** * Donut / pie of the latest sample (composition snapshot). * @param {HTMLCanvasElement|null} canvas * @param {Array<{ values: number[], color: string, label?: string, hidden?: boolean }>} lines * @param {object} [opts] */ export function drawPieChart(canvas, lines, opts = {}) { if (!canvas) return const preparedCanvas = prepareCanvas(canvas) if (!preparedCanvas) return const { ctx, w, h, commit } = preparedCanvas const prepared = prepareSeries(lines, opts.maxPoints || 90) const slices = prepared .map((l) => ({ label: l.label || '', color: l.color, value: Math.abs(last(l.values)), })) .filter((s) => s.value > 0) .sort((a, b) => b.value - a.value) if (!slices.length) { drawEmpty(ctx, w, h, opts.emptyMessage || 'No data') commit() return } if (opts.dimmed) ctx.globalAlpha = 0.72 const light = isLightTheme() const ink = light ? 'rgba(15, 23, 42, 0.88)' : 'rgba(244, 247, 251, 0.9)' const muted = light ? 'rgba(71, 85, 105, 0.85)' : 'rgba(154, 168, 188, 0.85)' const legendInk = light ? 'rgba(30, 41, 59, 0.92)' : 'rgba(200, 209, 223, 0.92)' const total = slices.reduce((s, x) => s + x.value, 0) || 1 const cx = w * 0.38 const cy = h / 2 const r = Math.min(w * 0.32, h * 0.42) const inner = r * 0.58 let angle = -Math.PI / 2 for (const slice of slices) { const sweep = (slice.value / total) * Math.PI * 2 ctx.beginPath() ctx.arc(cx, cy, r, angle, angle + sweep, false) ctx.arc(cx, cy, inner, angle + sweep, angle, true) ctx.closePath() ctx.fillStyle = slice.color ctx.fill() angle += sweep } // Center total ctx.fillStyle = ink ctx.font = '600 12px "Outfit", ui-sans-serif, system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(formatAxis(total), cx, cy - 6) if (opts.units) { ctx.fillStyle = muted ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace' ctx.fillText(String(opts.units).slice(0, 12), cx, cy + 10) } // Side legend (top contributors) const lx = w * 0.62 let ly = Math.max(14, cy - slices.slice(0, 6).length * 11) ctx.textAlign = 'left' ctx.textBaseline = 'middle' for (const slice of slices.slice(0, 6)) { const pct = ((slice.value / total) * 100).toFixed(0) ctx.fillStyle = slice.color ctx.beginPath() ctx.arc(lx, ly, 3.5, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = legendInk ctx.font = '11px "IBM Plex Mono", ui-monospace, Menlo, monospace' const name = slice.label.length > 14 ? slice.label.slice(0, 13) + '…' : slice.label ctx.fillText(`${name} ${pct}%`, lx + 10, ly) ly += 16 } ctx.globalAlpha = 1 commit() } /* ─── shared draw helpers ─── */ function prepareSeries(lines, maxPoints) { return (lines || []) .filter((l) => !l.hidden) .map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) })) .filter((l) => l.values.length >= 1) .filter((l) => l.values.some((v) => Number.isFinite(v))) } /** * @param {Array<{ values: number[] }>} prepared * @param {{ stacked?: boolean, threshold?: number|null, canvas?: HTMLCanvasElement|null, smooth?: boolean }} opts */ function computeScale(prepared, { stacked, threshold, canvas, smooth }) { let max = 1 let min = 0 if (stacked) { const len = Math.max(...prepared.map((l) => l.values.length)) for (let i = 0; i < len; i++) { let sum = 0 for (const l of prepared) sum += l.values[i] ?? 0 max = Math.max(max, sum) } min = 0 } else { const all = prepared.flatMap((l) => l.values) max = Math.max(...all, 1e-9) min = Math.min(...all, 0) } if (threshold != null && Number.isFinite(threshold)) { max = Math.max(max, threshold) min = Math.min(min, threshold) } if (max > 0) max *= 1.05 // Expand immediately for new peaks; shrink slowly so live rings don't jump // every time a tall sample scrolls out of the window. if (smooth && canvas) { const prev = scaleMemory.get(canvas) if (prev && Number.isFinite(prev.min) && Number.isFinite(prev.max)) { const decay = 0.18 if (max >= prev.max) { // keep expanded max } else { max = prev.max + (max - prev.max) * decay } if (min <= prev.min) { // keep expanded min } else { min = prev.min + (min - prev.min) * decay } } scaleMemory.set(canvas, { min, max }) } const span = max - min || 1 return { min, max, span } } function drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) { for (let i = 0; i <= 4; i++) { const y = padT + (plotH * i) / 4 ctx.strokeStyle = i === 4 ? 'rgba(154, 168, 188, 0.18)' : 'rgba(154, 168, 188, 0.1)' ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(padL, y) ctx.lineTo(padL + plotW, y) ctx.stroke() } if (min <= 0 && max > 0) { const y0 = yAt(0) ctx.strokeStyle = 'rgba(154, 168, 188, 0.32)' ctx.beginPath() ctx.moveTo(padL, y0) ctx.lineTo(padL + plotW, y0) ctx.stroke() } } function drawYAxis(ctx, padL, padT, plotH, min, max, span, units) { ctx.fillStyle = 'rgba(154, 168, 188, 0.7)' ctx.font = '10px "IBM Plex Mono", ui-monospace, Menlo, monospace' ctx.textAlign = 'right' ctx.textBaseline = 'middle' for (let i = 0; i <= 4; i++) { const v = max - (span * i) / 4 const y = padT + (plotH * i) / 4 ctx.fillText(formatAxis(v), padL - 5, y) } if (units) { ctx.textAlign = 'left' ctx.textBaseline = 'bottom' ctx.fillStyle = 'rgba(45, 212, 191, 0.75)' ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace' ctx.fillText(units.length > 14 ? units.slice(0, 13) + '…' : units, padL + 2, padT - 1) } } function drawThreshold(ctx, padL, plotW, y, severity) { ctx.strokeStyle = severity === 'critical' ? 'rgba(248, 113, 113, 0.85)' : 'rgba(251, 191, 36, 0.85)' ctx.setLineDash([5, 4]) ctx.lineWidth = 1.5 ctx.beginPath() ctx.moveTo(padL, y) ctx.lineTo(padL + plotW, y) ctx.stroke() ctx.setLineDash([]) } function drawStacked(ctx, prepared, xAt, yAt) { const len = Math.max(...prepared.map((l) => l.values.length)) /** @type {number[]} */ const acc = new Array(len).fill(0) for (const line of prepared) { ctx.beginPath() for (let i = 0; i < len; i++) { const v = (line.values[i] ?? 0) + acc[i] const x = xAt(i) const y = yAt(v) if (i === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) } for (let i = len - 1; i >= 0; i--) { ctx.lineTo(xAt(i), yAt(acc[i])) } ctx.closePath() ctx.fillStyle = withAlpha(line.color, 0.5) ctx.fill() ctx.beginPath() for (let i = 0; i < len; i++) { const v = (line.values[i] ?? 0) + acc[i] const x = xAt(i) const y = yAt(v) if (i === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) } ctx.strokeStyle = withAlpha(line.color, 0.9) ctx.lineWidth = 1.25 ctx.stroke() for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0 } } function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) { const values = line.values const len = values.length if (wantFill) { const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH) grad.addColorStop(0, withAlpha(line.color, 0.28)) grad.addColorStop(1, withAlpha(line.color, 0.02)) ctx.beginPath() values.forEach((v, i) => { const x = xAt(i) const y = yAt(v) if (i === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) }) ctx.lineTo(xAt(len - 1), padT + plotH) ctx.lineTo(xAt(0), padT + plotH) ctx.closePath() ctx.fillStyle = grad ctx.fill() } ctx.strokeStyle = line.color ctx.lineWidth = 2 ctx.lineJoin = 'round' ctx.lineCap = 'round' ctx.beginPath() values.forEach((v, i) => { const x = xAt(i) const y = yAt(v) if (i === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) }) ctx.stroke() } function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH) { const len = Math.max(...prepared.map((l) => l.values.length)) const idx = clampIdx(hoverIndex, len) const x = xAt(idx) ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)' ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(x, padT) ctx.lineTo(x, padT + plotH) ctx.stroke() for (const line of prepared) { const v = line.values[idx] if (v == null) continue const y = yAt(v) ctx.beginPath() ctx.fillStyle = isLightTheme() ? 'rgba(255, 255, 255, 0.9)' : 'rgba(10, 12, 16, 0.85)' ctx.arc(x, y, 5, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = line.color ctx.beginPath() ctx.arc(x, y, 3.25, 0, Math.PI * 2) ctx.fill() } } function drawXAxis(ctx, padL, padT, plotW, plotH, opts) { const windowSec = Number(opts.windowSeconds) || 0 if (windowSec <= 0) return const endOffset = Math.max(0, Number(opts.endOffset) || 0) const ticks = 3 const ink = isLightTheme() ? 'rgba(100, 116, 139, 0.75)' : 'rgba(154, 168, 188, 0.55)' ctx.fillStyle = ink ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace' ctx.textBaseline = 'top' for (let t = 0; t <= ticks; t++) { const frac = t / ticks const x = padL + frac * plotW const age = endOffset + windowSec * (1 - frac) const label = age < 1.5 ? 'now' : `-${formatDurShort(age)}` ctx.textAlign = t === 0 ? 'left' : t === ticks ? 'right' : 'center' ctx.fillText(label, x, padT + plotH + 2) } } function drawEmpty(ctx, w, h, message) { ctx.fillStyle = isLightTheme() ? 'rgba(100, 116, 139, 0.65)' : 'rgba(154, 168, 188, 0.55)' ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' const lines = String(message || 'No data').split('\n') const startY = h / 2 - ((lines.length - 1) * 16) / 2 lines.forEach((line, i) => { ctx.fillText(line, w / 2, startY + i * 16) }) } function isLightTheme() { try { return document.documentElement?.getAttribute('data-theme') === 'light' } catch { return false } } function formatDurShort(sec) { const s = Math.max(0, Number(sec) || 0) if (s < 60) return `${Math.round(s)}s` if (s < 3600) return `${Math.round(s / 60)}m` if (s < 86400) return `${(s / 3600).toFixed(s >= 10 * 3600 ? 0 : 1)}h` return `${(s / 86400).toFixed(1)}d` } function roundRect(ctx, x, y, w, h, r) { const rr = Math.min(r, w / 2, h / 2) if (h < 0.5) return ctx.beginPath() ctx.moveTo(x + rr, y) ctx.arcTo(x + w, y, x + w, y + h, rr) ctx.arcTo(x + w, y + h, x, y + h, rr) ctx.arcTo(x, y + h, x, y, rr) ctx.arcTo(x, y, x + w, y, rr) ctx.closePath() } function last(arr) { return arr?.length ? arr[arr.length - 1] : 0 } function clampIdx(idx, len) { return Math.max(0, Math.min(len - 1, Math.round(idx))) } /** * Map pointer x → series index for a canvas using the same layout as drawMultiChart. * @param {HTMLCanvasElement} canvas * @param {number} clientX * @param {{ padLeft?: number, showYAxis?: boolean, seriesLen?: number, units?: string }} [opts] */ export function hoverIndexFromEvent(canvas, clientX, opts = {}) { const rect = canvas.getBoundingClientRect() const w = rect.width || canvas.clientWidth || 320 const padL = opts.padLeft ?? padLeftFor(opts.units, opts.showYAxis !== false) const padR = 10 const plotW = Math.max(1, w - padL - padR) const x = clientX - rect.left - padL if (x < 0 || x > plotW) return null const len = Math.max(2, opts.seriesLen || 2) const idx = Math.round((x / plotW) * (len - 1)) return Math.max(0, Math.min(len - 1, idx)) } /** @param {number} v */ export function formatAxis(v) { const a = Math.abs(v) if (a >= 1e9) return (v / 1e9).toFixed(1) + 'G' if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M' if (a >= 1e3) return (v / 1e3).toFixed(1) + 'k' if (a >= 100) return v.toFixed(0) if (a >= 10) return v.toFixed(1) if (a >= 1) return v.toFixed(2) if (a >= 0.01) return v.toFixed(3) return v.toFixed(4) } /** @param {string} color @param {number} alpha */ function withAlpha(color, alpha) { if (!color) return `rgba(154,168,188,${alpha})` if (color.startsWith('#') && color.length === 7) { const r = parseInt(color.slice(1, 3), 16) const g = parseInt(color.slice(3, 5), 16) const b = parseInt(color.slice(5, 7), 16) return `rgba(${r},${g},${b},${alpha})` } return color } export const CHART_PALETTE = { cpuUser: '#34d399', cpuSystem: '#2dd4bf', cpuIowait: '#fbbf24', cpuSteal: '#f87171', cpuIrq: '#38bdf8', ram: '#2dd4bf', net: '#38bdf8', disk: '#fbbf24', load: '#a78bfa', explore: '#fb7185', compare: ['#34d399', '#2dd4bf', '#38bdf8', '#fbbf24', '#fb7185', '#a78bfa', '#f472b6', '#94a3b8'], } /** * @param {number} i */ export function seriesColor(i) { const pal = CHART_PALETTE.compare return pal[i % pal.length] } /** * @param {number[]} arr * @param {number} value * @param {number} max */ export function pushRing(arr, value, max) { arr.push(Number(value) || 0) while (arr.length > max) arr.shift() } /** * Push a value into a named dimension ring map. * @param {Map} dimMap * @param {string} dim * @param {number} value * @param {number} max */ export function pushDim(dimMap, dim, value, max) { let arr = dimMap.get(dim) if (!arr) { arr = [] dimMap.set(dim, arr) } pushRing(arr, value, max) }