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