730 lines
22 KiB
JavaScript
730 lines
22 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
|
|
}
|
|
|
|
/** @type {WeakMap<HTMLCanvasElement, HTMLCanvasElement>} */
|
|
const offscreenLayers = new WeakMap()
|
|
/** @type {WeakMap<HTMLCanvasElement, { min: number, max: number }>} */
|
|
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<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)
|
|
}
|