Flicker Reduction
CI / test (push) Successful in 1m8s
Release rolling / release (push) Successful in 7m56s

This commit is contained in:
Raven Scott
2026-07-30 13:12:38 -04:00
parent c541c27ad8
commit f52168feb9
4 changed files with 254 additions and 44 deletions
+86 -10
View File
@@ -53,34 +53,72 @@ export function padLeftFor(units, showYAxis = true) {
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 }|null}
* @returns {{ ctx: CanvasRenderingContext2D, w: number, h: number, commit: () => void }|null}
*/
function prepareCanvas(canvas) {
const ctx = canvas.getContext('2d')
if (!ctx) return null
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(canvas.clientHeight || canvas.height || 140))
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)
return { ctx, 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 } = preparedCanvas
const { ctx, w, h, commit } = preparedCanvas
const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line'))
const maxPoints = opts.maxPoints || 90
@@ -97,13 +135,19 @@ export function drawMultiChart(canvas, lines, opts = {}) {
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 })
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
@@ -130,6 +174,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH)
}
ctx.globalAlpha = 1
commit()
}
/**
@@ -142,7 +187,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
if (!canvas) return
const preparedCanvas = prepareCanvas(canvas)
if (!preparedCanvas) return
const { ctx, w, h } = preparedCanvas
const { ctx, w, h, commit } = preparedCanvas
const maxPoints = opts.maxPoints || 90
const units = opts.units ? String(opts.units) : ''
@@ -164,6 +209,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
}
if (!prepared.length) {
drawEmpty(ctx, w, h, opts.emptyMessage)
commit()
return
}
@@ -173,6 +219,8 @@ export function drawBarChart(canvas, lines, opts = {}) {
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
@@ -228,6 +276,7 @@ export function drawBarChart(canvas, lines, opts = {}) {
ctx.stroke()
}
ctx.globalAlpha = 1
commit()
}
/**
@@ -240,7 +289,7 @@ export function drawPieChart(canvas, lines, opts = {}) {
if (!canvas) return
const preparedCanvas = prepareCanvas(canvas)
if (!preparedCanvas) return
const { ctx, w, h } = preparedCanvas
const { ctx, w, h, commit } = preparedCanvas
const prepared = prepareSeries(lines, opts.maxPoints || 90)
const slices = prepared
@@ -254,6 +303,7 @@ export function drawPieChart(canvas, lines, opts = {}) {
if (!slices.length) {
drawEmpty(ctx, w, h, opts.emptyMessage || 'No data')
commit()
return
}
@@ -312,6 +362,7 @@ export function drawPieChart(canvas, lines, opts = {}) {
ly += 16
}
ctx.globalAlpha = 1
commit()
}
/* ─── shared draw helpers ─── */
@@ -324,7 +375,11 @@ function prepareSeries(lines, maxPoints) {
.filter((l) => l.values.some((v) => Number.isFinite(v)))
}
function computeScale(prepared, { stacked, threshold }) {
/**
* @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) {
@@ -345,6 +400,27 @@ function computeScale(prepared, { stacked, 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 }
}