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
+5 -1
View File
@@ -668,12 +668,16 @@ function redrawCpu() {
els.chartCpuLabel.textContent = 'system.cpu'
}
if (els.legendCpu) {
els.legendCpu.innerHTML = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
const next = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
.map(
(l) =>
`<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>`
)
.join('')
if (els.legendCpu.dataset.sig !== next) {
els.legendCpu.dataset.sig = next
els.legendCpu.innerHTML = next
}
}
}
+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 }
}
+161 -33
View File
@@ -129,6 +129,8 @@ export function createMetricsDashboard(opts) {
retentionSeconds: 0,
hoverSeriesLen: 0,
hoverPaintRaf: 0,
/** Structure signature of the last wall build — used to soft-refresh catalog without wipe. */
structureKey: '',
/** Metric Correlations mode */
mcMode: false,
mcMethod: 'volume',
@@ -367,6 +369,55 @@ export function createMetricsDashboard(opts) {
}, 80)
}
/**
* Stable signature of what the wall would show for the current filter / board / MC / pins.
* Used to skip full DOM rebuilds when only titles or retention meta changed.
* @param {Record<string, object>} catalog
*/
function wallStructureKey(catalog) {
let sections
if (state.mcResults?.length) {
sections = mcResultSections(catalog)
} else if (state.boardOnly) {
sections = []
} else {
sections = groupCatalog(catalog, state.filter).sections
}
const pinned = [...state.pinned].filter((id) => catalog[id]).join(',')
const secParts = sections.map((sec) => {
const ids = sec.groups.flatMap((g) => g.charts.map((c) => c.id))
return `${sec.id}:${ids.join(',')}`
})
return [
state.boardOnly ? 'b1' : 'b0',
state.filter || '',
state.mcResults?.length ? `mc:${state.mcResults.map((r) => r.id).join(',')}` : 'mc:',
`pin:${pinned}`,
...secParts,
].join('|')
}
/**
* Update card titles/subtitles in place when catalog meta changes without a structure change.
* @param {Record<string, object>} catalog
*/
function softUpdateCardMeta(catalog) {
for (const [id, article] of state.cardEls) {
const meta = catalog[id]
if (!meta) continue
ensureCard(id, meta)
const title = article.querySelector('.metric-card-title')
const sub = article.querySelector('.metric-card-sub')
const nextTitle = meta.title || id
const nextSub = metricCardSubtitle(id, meta)
if (title && title.textContent !== nextTitle) {
title.textContent = nextTitle
title.title = id
}
if (sub && sub.textContent !== nextSub) sub.textContent = nextSub
}
}
function queryArgs(chart) {
/** @type {{ chart: string, after: number, points: number, before?: number, group?: string }} */
const args = {
@@ -616,20 +667,22 @@ export function createMetricsDashboard(opts) {
}
function focusLegendHtml(model) {
if (!model) return ''
if (!model) return { html: '', structSig: '' }
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
const unitSuffix = model.units ? ` ${model.units}` : ''
return (
const structSig =
show.map((l) => `${l.label}\0${l.color}\0${l.hidden ? 1 : 0}`).join('|') + `|+${extra}`
const html =
show
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
return `<span class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}">${escapeHtml(text)}</span>`
return `<span class="dim-chip${line.hidden ? ' off' : ''}" data-dim="${escapeAttr(line.label)}" style="--dim-color:${line.color}">${escapeHtml(text)}</span>`
})
.join('') +
(extra ? `<span class="dim-chip-more muted">+${extra}</span>` : '')
)
return { html, structSig, show, unitSuffix }
}
function syncFocusChrome(id) {
@@ -653,7 +706,24 @@ export function createMetricsDashboard(opts) {
onPrev: () => focusStep(-1),
onNext: () => focusStep(1),
})
focus.setLegend(focusLegendHtml(model))
const legend = focusLegendHtml(model)
// Prefer in-place value updates when structure is stable (focus.setLegend always replaces HTML)
const legendEl = focus.el?.querySelector?.('#chart-focus-legend')
if (legendEl && legendEl.dataset.structSig === legend.structSig && legend.show) {
const byDim = new Map(legend.show.map((l) => [l.label, l]))
legendEl.querySelectorAll('[data-dim]').forEach((el) => {
const dim = el.getAttribute('data-dim') || ''
const line = byDim.get(dim)
if (!line) return
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${legend.unitSuffix}` : ''}`
if (el.textContent !== text) el.textContent = text
el.classList.toggle('off', Boolean(line.hidden))
})
} else {
if (legendEl) legendEl.dataset.structSig = legend.structSig || ''
focus.setLegend(legend.html)
}
}
function focusActionsFor(id) {
@@ -736,6 +806,53 @@ export function createMetricsDashboard(opts) {
if (el && !state.visible.has(next)) smoothScrollIntoView(el, 'center')
}
/**
* Rebuild legend only when series structure changes; live values update text in place
* so chips don't flicker/rebind every sample.
* @param {HTMLElement} legend
* @param {string} id
* @param {NonNullable<ReturnType<typeof buildPaintModel>>} model
*/
function updateLegend(legend, id, model) {
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
const unitSuffix = model.units ? ` ${model.units}` : ''
const structSig =
show.map((l) => `${l.label}\0${l.color}\0${l.hidden ? 1 : 0}`).join('|') + `|+${extra}`
if (legend.dataset.structSig !== structSig) {
legend.dataset.structSig = structSig
legend.innerHTML =
show
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
})
.join('') +
(extra
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
: '')
legend.querySelectorAll('[data-dim]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
toggleDim(id, btn.getAttribute('data-dim') || '')
})
})
return
}
// Structure stable — update values without tearing down buttons
const byDim = new Map(show.map((l) => [l.label, l]))
legend.querySelectorAll('[data-dim]').forEach((btn) => {
const dim = btn.getAttribute('data-dim') || ''
const line = byDim.get(dim)
if (!line) return
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
if (btn.textContent !== text) btn.textContent = text
btn.classList.toggle('off', Boolean(line.hidden))
})
}
function paintCard(id) {
const article = state.cardEls.get(id)
if (!article) return
@@ -790,30 +907,7 @@ export function createMetricsDashboard(opts) {
statsEl.innerHTML = renderStatsHtml(card, model.dims, model.hidden, model.units)
}
if (legend && !card.updating) {
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
const unitSuffix = model.units ? ` ${model.units}` : ''
const nextHtml =
show
.map((line) => {
const last = line.values[line.values.length - 1]
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
})
.join('') +
(extra
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
: '')
if (legend.dataset.sig !== nextHtml) {
legend.dataset.sig = nextHtml
legend.innerHTML = nextHtml
legend.querySelectorAll('[data-dim]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
toggleDim(id, btn.getAttribute('data-dim') || '')
})
})
}
updateLegend(legend, id, model)
}
if (focus.isOpen() && focus.currentId() === id) {
syncFocusChrome(id)
@@ -1262,8 +1356,27 @@ export function createMetricsDashboard(opts) {
band.style.width = `${(right - left) * 100}%`
}
function render() {
/**
* Rebuild the wall/TOC. Skips DOM wipe when structure is unchanged (avoids flicker
* on tab switches, catalog polls, and soft re-entry).
* @param {{ force?: boolean }} [renderOpts]
*/
function render(renderOpts = {}) {
const catalog = opts.getCatalog() || {}
const nextKey = wallStructureKey(catalog)
if (
!renderOpts.force &&
state.built &&
state.structureKey === nextKey &&
state.cardEls.size > 0
) {
softUpdateCardMeta(catalog)
updateMeta()
syncChromeFlags()
syncMcUi()
return
}
let { sections } = groupCatalog(catalog, state.filter)
if (state.boardOnly) {
// Board mode: only pinned charts
@@ -1275,7 +1388,14 @@ export function createMetricsDashboard(opts) {
renderToc(sections)
renderWall(sections)
state.built = true
state.structureKey = nextKey
updateMeta()
syncChromeFlags()
syncMcUi()
for (const id of state.cardEls.keys()) updateHighlightBand(id)
}
function syncChromeFlags() {
if (opts.els.boardBtn) {
opts.els.boardBtn.classList.toggle('active', state.boardOnly)
opts.els.boardBtn.setAttribute('aria-pressed', state.boardOnly ? 'true' : 'false')
@@ -1286,8 +1406,6 @@ export function createMetricsDashboard(opts) {
}
opts.els.root?.classList.toggle('mc-mode', state.mcMode)
opts.els.root?.classList.toggle('mc-results', Boolean(state.mcResults?.length))
syncMcUi()
for (const id of state.cardEls.keys()) updateHighlightBand(id)
}
function mcResultSections(catalog) {
@@ -2040,8 +2158,18 @@ export function createMetricsDashboard(opts) {
function setCatalog(_catalog) {
computeRetentionSeconds()
syncPresetUi()
const catalog = opts.getCatalog() || {}
const nextKey = wallStructureKey(catalog)
const soft =
state.built && state.structureKey === nextKey && state.cardEls.size > 0
// Soft path: Docker name enrichment / retention ticks must not wipe the wall or
// replace live rings (that was the main chart flicker every catalog poll).
const wall = opts.els.wall
const top = wall?.scrollTop || 0
render()
// Catalog just arrived or refreshed — pull history for anything already on screen
if (wall) wall.scrollTop = top
if (soft) return
// Full structure change (or first build) — pull history for visible cards
scheduleRefetchVisible()
}
+2
View File
@@ -1198,6 +1198,8 @@ button.metrics-tf.thin-history:not(.active) {
.metric-canvas-wrap {
position: relative;
width: 100%;
/* Isolate canvas layout so legend value text updates don't reflow the plot */
contain: layout style;
}
.metric-highlight-band {