Files
peardata/ui/charts.js
T
Raven Scott 5396d9d499
CI / test (push) Successful in 57s
Release rolling / release (push) Successful in 6m59s
Remove Blinking During Data Refresh
2026-07-18 22:36:23 -04:00

654 lines
20 KiB
JavaScript

/**
* 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
}
/**
* Size the backing store only when needed. Resetting canvas.width every paint
* clears the buffer and causes a visible blink on live updates.
* @param {HTMLCanvasElement} canvas
* @returns {{ ctx: CanvasRenderingContext2D, w: number, h: number }|null}
*/
function prepareCanvas(canvas) {
const ctx = canvas.getContext('2d')
if (!ctx) return null
const dpr = window.devicePixelRatio || 1
const w = Math.max(1, Math.round(canvas.clientWidth || 320))
const h = Math.max(1, Math.round(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
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
return { ctx, w, h }
}
export function drawMultiChart(canvas, lines, opts = {}) {
if (!canvas) return
const preparedCanvas = prepareCanvas(canvas)
if (!preparedCanvas) return
const { ctx, w, h } = 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)
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 })
// 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
}
/**
* 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 } = 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)
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,
})
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
}
/**
* 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 } = 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')
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
}
/* ─── 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)))
}
function computeScale(prepared, { stacked, threshold }) {
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
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<string, number[]>} 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)
}