Remove Blinking During Data Refresh
CI / test (push) Successful in 57s
Release rolling / release (push) Successful in 6m59s

This commit is contained in:
Raven Scott
2026-07-18 22:36:23 -04:00
parent 6d384534f2
commit 5396d9d499
4 changed files with 73 additions and 46 deletions
+11 -1
View File
@@ -293,6 +293,16 @@ function paint(canvasId, lines, opts = {}) {
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
}
let overviewPaintRaf = 0
/** Coalesce Overview redraws into one frame to avoid spark blink. */
function scheduleRedrawAll() {
if (overviewPaintRaf) return
overviewPaintRaf = requestAnimationFrame(() => {
overviewPaintRaf = 0
redrawAll()
})
}
function panelForChart(chart) {
if (chart === 'system.cpu') return els.panelCpu
if (chart === 'mem.available' || chart === 'system.ram') return els.panelRam
@@ -739,7 +749,7 @@ function onSamples(samples, conn) {
}
// Metrics wall only ingests the active agent
if (isActive) metricsDashboard.onSamples(samples || [])
redrawAll()
scheduleRedrawAll()
}
function prependAnomaly(ev) {
+30 -25
View File
@@ -53,17 +53,34 @@ export function padLeftFor(units, showYAxis = true) {
return u.length > 6 ? 52 : 46
}
export function drawMultiChart(canvas, lines, opts = {}) {
if (!canvas) return
/**
* 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
if (!ctx) return null
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.clientHeight || canvas.height || 140
canvas.width = w * dpr
canvas.height = h * dpr
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
@@ -123,15 +140,9 @@ export function drawMultiChart(canvas, lines, opts = {}) {
*/
export function drawBarChart(canvas, lines, opts = {}) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.clientHeight || canvas.height || 140
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const preparedCanvas = prepareCanvas(canvas)
if (!preparedCanvas) return
const { ctx, w, h } = preparedCanvas
const maxPoints = opts.maxPoints || 90
const units = opts.units ? String(opts.units) : ''
@@ -227,15 +238,9 @@ export function drawBarChart(canvas, lines, opts = {}) {
*/
export function drawPieChart(canvas, lines, opts = {}) {
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.clientHeight || canvas.height || 140
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const preparedCanvas = prepareCanvas(canvas)
if (!preparedCanvas) return
const { ctx, w, h } = preparedCanvas
const prepared = prepareSeries(lines, opts.maxPoints || 90)
const slices = prepared
+32 -12
View File
@@ -364,10 +364,13 @@ export function createMetricsDashboard(opts) {
const gen = (state.fetchGen.get(id) || 0) + 1
state.fetchGen.set(id, gen)
// Stale-while-revalidate: keep previous series painted; never blank the canvas.
// Stale-while-revalidate: keep previous series painted; never blank or dim.
if (hadData) {
card.updating = true
paintCard(id)
// Status text only — no repaint (avoids blink before new data arrives)
const article = state.cardEls.get(id)
const statusEl = article?.querySelector('.metric-card-status')
if (statusEl) statusEl.textContent = ''
} else {
card.status = 'loading'
card.emptyReason = 'Waiting for first sample…'
@@ -457,6 +460,18 @@ export function createMetricsDashboard(opts) {
/**
* @param {Array<{ chart: string, values: Record<string, number> }>} samples
*/
/** @type {Set<string>} */
const pendingSamplePaint = new Set()
let samplePaintRaf = 0
function flushSamplePaints() {
samplePaintRaf = 0
for (const id of pendingSamplePaint) paintCard(id)
pendingSamplePaint.clear()
updateHoverReadout()
refreshSectionKpis()
}
function onSamples(samples) {
if (!state.playing || !state.built || state.endOffset > 0) return
const max = maxPoints()
@@ -472,12 +487,12 @@ export function createMetricsDashboard(opts) {
if (!card.labels.includes(dim)) card.labels.push(dim)
}
card.status = 'ok'
paintCard(s.chart)
card.updating = false
pendingSamplePaint.add(s.chart)
any = true
}
if (any) {
updateHoverReadout()
refreshSectionKpis()
if (any && !samplePaintRaf) {
samplePaintRaf = requestAnimationFrame(flushSamplePaints)
}
}
@@ -507,10 +522,10 @@ export function createMetricsDashboard(opts) {
article.classList.toggle('related', state.relatedIds.has(id))
const pinBtn = article.querySelector('.metric-pin-btn')
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
article.classList.toggle('updating', Boolean(card?.updating))
// Never dim the card during SWR — opacity/filter changes look like a blink
article.classList.remove('updating')
if (statusEl) {
if (card?.updating) statusEl.textContent = 'updating'
else if (card?.status === 'loading') statusEl.textContent = 'loading'
if (card?.status === 'loading') statusEl.textContent = 'loading'
else if (card?.status === 'error') statusEl.textContent = 'error'
else if (card?.status === 'empty') statusEl.textContent = 'empty'
else if (card?.status === 'ok' && card.source && card.source !== 'memory') {
@@ -519,7 +534,8 @@ export function createMetricsDashboard(opts) {
}
if (!card || !canvas) return
const height = cardHeightFor(id, card)
canvas.style.height = `${height}px`
const heightPx = `${height}px`
if (canvas.style.height !== heightPx) canvas.style.height = heightPx
const { dims, hidden } = sortedDims(card, id)
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
card.mode = mode
@@ -559,7 +575,7 @@ export function createMetricsDashboard(opts) {
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
severity: anomaly?.severity || null,
emptyMessage: emptyMsg,
dimmed: Boolean(card.updating),
dimmed: false,
units,
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
endOffset: state.endOffset,
@@ -1314,8 +1330,12 @@ export function createMetricsDashboard(opts) {
})
}
tops.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
const slice = tops.slice(0, 6)
const sig = slice.map((r) => `${r.id}:${formatVal(r.value)}:${r.units}`).join('|')
if (strip.dataset.sig === sig) continue
strip.dataset.sig = sig
strip.innerHTML = ''
for (const row of tops.slice(0, 6)) {
for (const row of slice) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'section-kpi'
-8
View File
@@ -1435,14 +1435,6 @@ button.metrics-tf.thin-history:not(.active) {
}
}
.metric-card.updating {
opacity: 0.92;
}
.metric-card.updating canvas {
filter: saturate(0.92);
}
.metric-card-head {
display: flex;
justify-content: space-between;