Flicker Reduction
This commit is contained in:
+86
-10
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user