Files
peardata/ui/dashboard.js
T
Raven Scott f52168feb9
CI / test (push) Successful in 1m8s
Release rolling / release (push) Successful in 7m56s
Flicker Reduction
2026-07-30 13:12:38 -04:00

2340 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Master metrics dashboard — sectioned TOC + long-scroll chart wall.
*/
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
import { rankRelatedCharts } from '../shared/related-metrics.js'
import {
CHART_MODE_LABEL,
chartModeHint,
defaultModeFromMeta,
nextChartMode,
normalizeChartMode,
} from '../shared/chart-types.js'
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
import { getChartFocus } from './chart-focus.js'
const GROUPS = ['average', 'min', 'max', 'sum']
const MIN_WINDOW = 30
const MAX_WINDOW = 21600
const DEFAULT_HEIGHT = 148
const HERO_HEIGHT = 200
const LEGEND_CHIP_CAP = 10
function scrollBehavior() {
try {
if (document.body?.dataset?.reduceMotion === '1') return 'auto'
if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) {
return 'auto'
}
} catch {
// ignore
}
return 'smooth'
}
function smoothScrollIntoView(el, block = 'start') {
el?.scrollIntoView({ behavior: scrollBehavior(), block })
}
/**
* @typedef {{
* root: HTMLElement,
* toc: HTMLElement,
* wall: HTMLElement,
* search: HTMLInputElement|null,
* playBtn: HTMLButtonElement|null,
* presets: HTMLElement|null,
* meta: HTMLElement|null,
* hoverReadout: HTMLElement|null,
* resetBtn?: HTMLButtonElement|null,
* dimSortBtn?: HTMLButtonElement|null,
* groupSelect?: HTMLSelectElement|null,
* forcePlayBtn?: HTMLButtonElement|null,
* relatedPanel?: HTMLElement|null,
* boardBtn?: HTMLButtonElement|null,
* liveEl?: HTMLElement|null,
* liveLabel?: HTMLElement|null,
* retentionHint?: HTMLElement|null,
* correlateBtn?: HTMLButtonElement|null,
* mcBar?: HTMLElement|null,
* mcMethod?: HTMLSelectElement|null,
* mcHint?: HTMLElement|null,
* mcRunBtn?: HTMLButtonElement|null,
* mcClearBtn?: HTMLButtonElement|null,
* mcResultsPanel?: HTMLElement|null,
* }} DashboardEls
*/
/**
* @param {{
* els: DashboardEls,
* getCatalog: () => Record<string, object>,
* queryData: (args: object) => Promise<object>,
* getPoints: () => number,
* isConnected?: () => boolean,
* getPrefs?: () => {
* cardHeight?: number,
* dimSort?: 'name'|'value',
* collapsed?: string[],
* chartTypes?: Record<string, string>,
* pinned?: string[],
* group?: string,
* forcePlay?: boolean,
* filtersOpen?: boolean,
* },
* savePrefs?: (patch: object) => void,
* getAnomaly?: (chartId: string) => { severity?: string, threshold?: number|null }|null,
* getWeights?: (args?: object) => Promise<{ results?: Array<{ id: string, weight: number }> }>,
* }} opts
*/
export function createMetricsDashboard(opts) {
const prefs = () => opts.getPrefs?.() || {}
const focus = getChartFocus()
const state = {
filter: '',
playing: true,
forcePlay: Boolean(prefs().forcePlay),
presetId: '5m',
afterSeconds: 300,
/** Seconds before "now" that the window ends (0 = live edge). */
endOffset: 0,
group: GROUPS.includes(String(prefs().group)) ? String(prefs().group) : 'average',
hoverIndex: /** @type {number|null} */ (null),
dimSort: /** @type {'name'|'value'} */ (prefs().dimSort === 'value' ? 'value' : 'name'),
cardHeight: clampHeight(prefs().cardHeight || DEFAULT_HEIGHT),
collapsed: new Set(prefs().collapsed || []),
pinned: new Set(prefs().pinned || []),
/** @type {Map<string, string>} */
chartTypes: new Map(Object.entries(prefs().chartTypes || {})),
hiddenDims: /** @type {Map<string, Set<string>>} */ (new Map()),
/** @type {Set<string>} */
relatedIds: new Set(),
relatedSeed: '',
boardOnly: false,
/** @type {Map<string, { dims: Map<string, number[]>, labels: string[], status: string, meta: object, mode: string, source?: string, updating?: boolean, emptyReason?: string }>} */
cards: new Map(),
/** @type {Map<string, HTMLElement>} */
cardEls: new Map(),
visible: /** @type {Set<string>} */ (new Set()),
observer: /** @type {IntersectionObserver|null} */ (null),
built: false,
refetchTimer: /** @type {ReturnType<typeof setTimeout>|null} */ (null),
pan: /** @type {{ active: boolean, pending?: boolean, pointerId?: number, startX: number, startY?: number, startOffset: number }|null} */ (null),
/** @type {Map<string, number>} in-flight generation per chart */
fetchGen: new Map(),
/** Seconds of history available across catalog (0 = unknown). */
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',
/** @type {{ after: number, before: number }|null} absolute unix seconds */
highlight: null,
/** @type {Array<{ id: string, weight: number, info?: string, context?: string }>|null} */
mcResults: null,
/** @type {{ startFrac: number, endFrac: number, chartId: string }|null} */
mcBrush: null,
mcRunning: false,
/** Filters panel (search / TOC / group / sort) — default closed for full-width wall */
filtersOpen: Boolean(prefs().filtersOpen),
}
function scheduleHoverPaint() {
if (state.hoverPaintRaf) return
state.hoverPaintRaf = requestAnimationFrame(() => {
state.hoverPaintRaf = 0
for (const id of state.visible) paintCard(id)
})
}
function seriesLenForCard(chartId) {
const card = state.cards.get(chartId)
if (!card) return maxPoints()
let n = 0
for (const arr of card.dims.values()) n = Math.max(n, arr?.length || 0)
return n > 1 ? n : maxPoints()
}
function points() {
const setting = opts.getPoints?.() || 90
return Math.min(600, Math.max(setting, Math.min(state.afterSeconds, 360)))
}
function maxPoints() {
return points()
}
function persistPrefs(patch) {
opts.savePrefs?.(patch)
}
function ensureCard(id, meta = {}) {
let card = state.cards.get(id)
const defaultMode = defaultModeFromMeta(meta)
if (!card) {
card = {
dims: new Map(),
labels: [],
status: 'idle',
meta: meta || {},
mode: normalizeChartMode(state.chartTypes.get(id) || defaultMode),
source: '',
}
state.cards.set(id, card)
} else if (meta && Object.keys(meta).length) {
card.meta = { ...card.meta, ...meta }
if (!state.chartTypes.has(id)) {
card.mode = defaultMode
}
}
return card
}
function setPlaying(on) {
if (state.forcePlay && !on) return
state.playing = Boolean(on)
if (state.playing) state.endOffset = 0
if (opts.els.playBtn) {
opts.els.playBtn.textContent = state.playing ? 'Pause' : 'Play'
opts.els.playBtn.setAttribute('aria-pressed', state.playing ? 'true' : 'false')
opts.els.playBtn.disabled = state.forcePlay
}
syncLiveUi()
syncPresetUi()
updateMeta()
if (state.playing) scheduleRefetchVisible()
}
function syncLiveUi() {
const live = opts.els.liveEl
const label = opts.els.liveLabel
if (!live) return
live.classList.toggle('is-live', state.playing && state.endOffset === 0)
live.classList.toggle('is-paused', !state.playing || state.endOffset > 0)
live.classList.toggle('is-force', state.forcePlay)
if (label) {
label.textContent = state.forcePlay
? 'Force live'
: state.playing && state.endOffset === 0
? 'Live'
: 'Paused'
}
}
function setForcePlay(on) {
state.forcePlay = Boolean(on)
persistPrefs({ forcePlay: state.forcePlay })
if (opts.els.forcePlayBtn) {
opts.els.forcePlayBtn.classList.toggle('active', state.forcePlay)
opts.els.forcePlayBtn.setAttribute('aria-pressed', state.forcePlay ? 'true' : 'false')
}
opts.els.root?.classList.toggle('force-play', state.forcePlay)
if (state.forcePlay) setPlaying(true)
else if (opts.els.playBtn) opts.els.playBtn.disabled = false
syncLiveUi()
}
function setGroup(group) {
state.group = GROUPS.includes(group) ? group : 'average'
persistPrefs({ group: state.group })
if (opts.els.groupSelect) opts.els.groupSelect.value = state.group
scheduleRefetchVisible()
updateMeta()
}
function setPreset(id) {
const p = TIME_PRESETS.find((x) => x.id === id) || TIME_PRESETS[1]
if (!presetAvailable(p.seconds)) return
state.presetId = p.id
state.afterSeconds = p.seconds
state.endOffset = 0
state.playing = true
if (opts.els.playBtn) {
opts.els.playBtn.textContent = 'Pause'
opts.els.playBtn.setAttribute('aria-pressed', 'true')
}
syncLiveUi()
syncPresetUi()
scheduleRefetchVisible()
updateMeta()
}
/**
* Best-known history depth in seconds.
* Uses catalog first/last unix timestamps and/or already-loaded card series.
* Never treats "age of last sample" as retention (that was locking presets forever).
*/
function computeRetentionSeconds() {
const catalog = opts.getCatalog() || {}
let best = 0
for (const meta of Object.values(catalog)) {
const first = Number(meta?.first_entry ?? meta?.firstEntry ?? 0)
const last = Number(meta?.last_entry ?? meta?.lastEntry ?? 0)
// Unix seconds (~1e9+). Ignore zeros / relative counters.
if (first > 1e9 && last >= first) {
best = Math.max(best, last - first)
}
}
// Live estimate from points already in the wall (~1s samples)
for (const card of state.cards.values()) {
if (card.status !== 'ok') continue
let n = 0
for (const arr of card.dims.values()) n = Math.max(n, arr?.length || 0)
if (n > 2) best = Math.max(best, n - 1)
}
state.retentionSeconds = best
return best
}
function presetAvailable(seconds) {
// Always selectable — empty wall / disconnected shows idle state.
// Retention is advisory in the hint, not a hard lock.
void seconds
return true
}
function setWindow(seconds, endOffset = state.endOffset) {
state.afterSeconds = clamp(seconds, MIN_WINDOW, MAX_WINDOW)
state.endOffset = Math.max(0, endOffset)
const match = TIME_PRESETS.find(
(p) => p.seconds === state.afterSeconds && state.endOffset === 0
)
state.presetId = match ? match.id : 'custom'
if (state.endOffset > 0 && !state.forcePlay) setPlaying(false)
syncPresetUi()
scheduleRefetchVisible()
updateMeta()
}
function resetWindow() {
setPreset('5m')
setPlaying(true)
}
function syncPresetUi() {
if (!opts.els.presets) return
const retention = computeRetentionSeconds()
const catalogEmpty = !Object.keys(opts.getCatalog() || {}).length
const connected = Boolean(opts.isConnected?.() ?? !catalogEmpty)
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
const id = btn.getAttribute('data-preset') || ''
const p = TIME_PRESETS.find((x) => x.id === id)
const seconds = p?.seconds || 0
const thin = connected && !catalogEmpty && retention > 0 && retention + 30 < seconds
btn.classList.toggle('active', id === state.presetId)
btn.classList.toggle('thin-history', thin)
btn.disabled = false
btn.title = !connected
? `Last ${id} · connect an agent to load samples`
: thin
? `Last ${id} · ~${formatDuration(retention)} buffered (may be sparse)`
: `Last ${id}`
})
const hint = opts.els.retentionHint
if (hint) {
if (!connected || catalogEmpty) {
hint.textContent = 'Connect an agent to populate charts for the selected window.'
hint.classList.remove('hidden')
} else if (retention > 0 && retention < state.afterSeconds) {
hint.textContent = `Buffered history ~${formatDuration(retention)} — longer windows may look sparse until more samples arrive.`
hint.classList.remove('hidden')
} else {
hint.textContent = ''
hint.classList.add('hidden')
}
}
}
function updateMeta() {
if (!opts.els.meta) return
const catalog = opts.getCatalog() || {}
const total = Object.keys(catalog).length
const shown = [...state.cardEls.keys()].length || total
const lag =
state.endOffset > 0 ? ` · lag ${formatDuration(state.endOffset)}` : ''
const pins = state.pinned.size ? ` · ${state.pinned.size} pinned` : ''
opts.els.meta.textContent = `${shown} charts · ${formatDuration(state.afterSeconds)}${lag} · ${state.group}${pins}`
}
function scheduleRefetchVisible() {
if (state.refetchTimer) clearTimeout(state.refetchTimer)
state.refetchTimer = setTimeout(() => {
for (const id of state.visible) fetchChart(id).catch(() => {})
}, 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 = {
chart,
after: -state.afterSeconds,
points: points(),
group: state.group,
}
if (state.endOffset > 0) args.before = -state.endOffset
return args
}
async function fetchChart(id) {
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || state.cards.get(id)?.meta || {}
const card = ensureCard(id, meta)
const hadData = card.status === 'ok' && card.dims.size > 0
const gen = (state.fetchGen.get(id) || 0) + 1
state.fetchGen.set(id, gen)
// Stale-while-revalidate: keep previous series painted; never blank or dim.
if (hadData) {
card.updating = true
// 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…'
paintCard(id)
}
try {
const q = await opts.queryData(queryArgs(id))
if (state.fetchGen.get(id) !== gen) return
const labels = Array.isArray(q.labels)
? q.labels.filter((l) => l && l !== 'time')
: []
let dimNames = labels.length
? labels
: inferDimNames(meta, q.data?.[0]?.length ? q.data[0].length - 1 : 0)
if (!dimNames.length && q.data?.[0]) {
const n = q.data[0].length - 1
dimNames = Array.from({ length: Math.max(0, n) }, (_, i) => `d${i}`)
}
const nextDims = new Map()
const max = maxPoints()
for (const name of dimNames) nextDims.set(name, [])
for (const row of q.data || []) {
for (let i = 0; i < dimNames.length; i++) {
pushDim(nextDims, dimNames[i], Number(row[i + 1]) || 0, max)
}
}
const pointCount = [...nextDims.values()].reduce((n, arr) => Math.max(n, arr.length), 0)
// Commit result in place — previous paint stays until this swap (no blank frame).
card.labels = dimNames
card.dims = nextDims
card.source = q.source || 'memory'
if (pointCount >= 2) {
card.status = 'ok'
card.emptyReason = ''
} else {
card.status = 'empty'
card.emptyReason = retentionEmptyReason(meta)
}
} catch (err) {
if (state.fetchGen.get(id) !== gen) return
if (!hadData) {
card.status = 'error'
card.emptyReason = err?.message || 'query failed'
}
card.meta = { ...card.meta, error: err?.message || 'query failed' }
} finally {
if (state.fetchGen.get(id) === gen) {
card.updating = false
paintCard(id)
refreshSectionKpis()
// Refresh retention hint as series grow (do not re-disable presets)
computeRetentionSeconds()
const hint = opts.els.retentionHint
if (hint && Object.keys(opts.getCatalog() || {}).length) {
const retention = state.retentionSeconds
if (retention > 0 && retention < state.afterSeconds) {
hint.textContent = `Buffered history ~${formatDuration(retention)} — longer windows may look sparse until more samples arrive.`
hint.classList.remove('hidden')
} else if (retention >= state.afterSeconds) {
hint.textContent = ''
hint.classList.add('hidden')
}
}
}
}
}
function retentionEmptyReason(meta) {
const first = Number(meta?.first_entry ?? meta?.firstEntry ?? 0)
const last = Number(meta?.last_entry ?? meta?.lastEntry ?? 0)
if (first > 0 && state.afterSeconds > last - first + 60) {
return `No samples this far back — try a shorter window`
}
if (!first && !last) return 'Waiting for first sample…'
return 'No data for this window'
}
function inferDimNames(meta, n) {
const dims = meta.dimensions
if (Array.isArray(dims) && dims.length) {
return dims.map((d) => (typeof d === 'string' ? d : d.id || d.name)).filter(Boolean)
}
return Array.from({ length: n }, (_, i) => `d${i}`)
}
/**
* @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()
let any = false
for (const s of samples || []) {
if (!s?.chart || !s.values) continue
if (!state.visible.has(s.chart)) continue
const catalog = opts.getCatalog() || {}
const card = ensureCard(s.chart, catalog[s.chart] || {})
for (const [dim, val] of Object.entries(s.values)) {
if (typeof val !== 'number') continue
pushDim(card.dims, dim, val, max)
if (!card.labels.includes(dim)) card.labels.push(dim)
}
card.status = 'ok'
card.updating = false
pendingSamplePaint.add(s.chart)
any = true
}
if (any && !samplePaintRaf) {
samplePaintRaf = requestAnimationFrame(flushSamplePaints)
}
}
function sortedDims(card, chartId) {
const hidden = state.hiddenDims.get(chartId) || new Set()
const dims = card.labels.length ? [...card.labels] : [...card.dims.keys()]
if (state.dimSort === 'value') {
dims.sort((a, b) => {
const av = lastVal(card.dims.get(a))
const bv = lastVal(card.dims.get(b))
return bv - av
})
} else {
dims.sort((a, b) => a.localeCompare(b))
}
return { dims, hidden }
}
/**
* Build drawable series + draw opts for a chart card.
* @param {string} id
*/
function buildPaintModel(id) {
const card = state.cards.get(id)
if (!card) return null
const { dims, hidden } = sortedDims(card, id)
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
card.mode = mode
/** @type {Array<{ values: number[], color: string, label: string, hidden: boolean, fill?: boolean }>} */
const lines = []
let i = 0
for (const dim of dims) {
const values = card.dims.get(dim) || []
lines.push({
values,
color: seriesColor(i),
label: dim,
hidden: hidden.has(dim),
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
})
i++
}
const anomaly = opts.getAnomaly?.(id) || null
const emptyMsg =
card.status === 'loading'
? card.emptyReason || 'Loading…'
: card.status === 'error'
? card.emptyReason || 'Error'
: card.status === 'empty'
? card.emptyReason || 'No data'
: 'No data'
const units = card.meta?.units || ''
const showY = mode !== 'pie'
return {
card,
dims,
hidden,
mode,
lines,
anomaly,
units,
emptyMsg,
drawOpts: {
mode,
maxPoints: maxPoints(),
showYAxis: showY,
padLeft: padLeftFor(units, showY),
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
severity: anomaly?.severity || null,
emptyMessage: emptyMsg,
dimmed: false,
units,
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
endOffset: state.endOffset,
},
}
}
/** Ordered chart ids currently on the wall (for focus prev/next). */
function wallChartIds() {
const ids = []
for (const el of opts.els.wall?.querySelectorAll('[data-chart-id]') || []) {
const id = el.getAttribute('data-chart-id')
if (id) ids.push(id)
}
return ids.length ? ids : [...state.cardEls.keys()]
}
/**
* @param {string} id
* @param {HTMLCanvasElement} canvas
* @param {ReturnType<typeof buildPaintModel>} model
*/
function paintCanvas(id, canvas, model) {
if (!model || !canvas) return
drawChart(canvas, model.lines, model.drawOpts)
}
function focusLegendHtml(model) {
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}` : ''
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' : ''}" 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) {
if (!focus.isOpen() || focus.currentId() !== id) return
const model = buildPaintModel(id)
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || state.cards.get(id)?.meta || {}
const status =
model?.card?.status === 'ok' && model.card.source && model.card.source !== 'memory'
? model.card.source
: model?.card?.status === 'ok'
? ''
: model?.card?.status || ''
focus.updateChrome({
id,
title: meta.title || id,
subtitle: metricCardSubtitle(id, meta),
units: meta.units || model?.units || '',
status,
actions: focusActionsFor(id),
onPrev: () => focusStep(-1),
onNext: () => focusStep(1),
})
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) {
const card = state.cards.get(id)
const mode = card?.mode || 'line'
return [
{
id: 'pin',
label: state.pinned.has(id) ? '★ Pin' : '☆ Pin',
title: 'Pin to board',
active: state.pinned.has(id),
onClick: () => {
togglePin(id)
syncFocusChrome(id)
},
},
{
id: 'type',
label: CHART_MODE_LABEL[mode] || mode,
title: chartModeHint(mode),
onClick: () => {
cycleChartType(id)
syncFocusChrome(id)
focus.refresh()
},
},
{
id: 'related',
label: 'Related',
title: 'Find related charts',
onClick: () => {
focus.close()
showRelated(id)
},
},
]
}
function openFocus(id) {
if (!id) return
ensureCard(id, (opts.getCatalog() || {})[id] || {})
if (!state.cards.get(id)?.dims?.size) {
fetchChart(id).catch(() => {})
}
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || {}
focus.open({
id,
title: meta.title || id,
subtitle: metricCardSubtitle(id, meta),
units: meta.units || '',
paint: (canvas) => {
const model = buildPaintModel(id)
paintCanvas(id, canvas, model)
},
onPrev: () => focusStep(-1),
onNext: () => focusStep(1),
actions: focusActionsFor(id),
onClose: () => {
const el = state.cardEls.get(id)
el?.classList.remove('is-focused')
},
})
for (const el of state.cardEls.values()) el.classList.remove('is-focused')
state.cardEls.get(id)?.classList.add('is-focused')
syncFocusChrome(id)
focus.refresh()
}
/** @param {number} delta */
function focusStep(delta) {
const ids = wallChartIds()
if (!ids.length) return
const cur = focus.currentId()
let idx = ids.indexOf(cur)
if (idx < 0) idx = 0
const next = ids[(idx + delta + ids.length) % ids.length]
openFocus(next)
const el = state.cardEls.get(next)
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
const card = state.cards.get(id)
const canvas = article.querySelector('canvas')
const statusEl = article.querySelector('.metric-card-status')
const legend = article.querySelector('.metric-card-legend')
article.classList.toggle('pinned', state.pinned.has(id))
article.classList.toggle('related', state.relatedIds.has(id))
article.classList.toggle('is-focused', focus.isOpen() && focus.currentId() === id)
const pinBtn = article.querySelector('.metric-pin-btn')
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
const typeBtn = article.querySelector('.metric-type-btn')
if (typeBtn && card) {
typeBtn.textContent = CHART_MODE_LABEL[card.mode] || card.mode
typeBtn.title = chartModeHint(card.mode)
}
// Never dim the card during SWR — opacity/filter changes look like a blink
article.classList.remove('updating')
if (statusEl) {
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') {
statusEl.textContent = card.source
} else statusEl.textContent = ''
}
const weightChip = article.querySelector('.metric-weight-chip')
const mcRow = state.mcResults?.find((r) => r.id === id)
if (weightChip) {
if (mcRow) {
weightChip.textContent = `${(mcRow.weight * 100).toFixed(0)}%`
weightChip.classList.remove('hidden')
weightChip.title = `Correlation weight ${mcRow.weight.toFixed(3)}`
} else {
weightChip.classList.add('hidden')
}
}
const model = buildPaintModel(id)
if (!card || !canvas || !model) return
const height = cardHeightFor(id, card)
const heightPx = `${height}px`
if (canvas.style.height !== heightPx) canvas.style.height = heightPx
updateHighlightBand(id)
if (model.anomaly?.severity) article.dataset.severity = model.anomaly.severity
else delete article.dataset.severity
article.dataset.chartMode = model.mode
paintCanvas(id, canvas, model)
updateCardTooltip(article, id, card, model.lines, model.units)
const statsEl = article.querySelector('.metric-card-stats')
if (statsEl && article.classList.contains('expanded')) {
statsEl.innerHTML = renderStatsHtml(card, model.dims, model.hidden, model.units)
}
if (legend && !card.updating) {
updateLegend(legend, id, model)
}
if (focus.isOpen() && focus.currentId() === id) {
syncFocusChrome(id)
focus.refresh()
}
}
/**
* Per-card hover tooltip with values + units.
* @param {HTMLElement} article
* @param {string} id
* @param {object} card
* @param {Array<{ label: string, values: number[], color: string, hidden?: boolean }>} lines
* @param {string} units
*/
function updateCardTooltip(article, id, card, lines, units) {
let tip = article.querySelector('.chart-hover-tip')
if (!tip) {
tip = document.createElement('div')
tip.className = 'chart-hover-tip hidden'
tip.setAttribute('aria-hidden', 'true')
article.style.position = 'relative'
article.appendChild(tip)
}
const mode = normalizeChartMode(card.mode)
const unitSuffix = units ? ` ${escapeHtml(units)}` : ''
// Pie = snapshot of latest values (always show while card hovered via canvas leave/enter)
if (mode === 'pie') {
if (!state.visible.has(id) || !article.matches(':hover')) {
tip.classList.add('hidden')
article.classList.remove('is-hovering')
return
}
const rows = lines
.filter((l) => !l.hidden)
.map((l) => ({
label: l.label,
value: l.values[l.values.length - 1],
color: l.color,
}))
.filter((r) => r.value != null && Number.isFinite(r.value) && Math.abs(r.value) > 0)
.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
const total = rows.reduce((s, r) => s + Math.abs(r.value), 0) || 1
tip.innerHTML =
`<header>Share · latest</header>` +
rows
.slice(0, 8)
.map((r) => {
const pct = ((Math.abs(r.value) / total) * 100).toFixed(0)
return `<div class="tip-row"><i style="background:${r.color}"></i><span>${escapeHtml(r.label)}</span><strong>${pct}% · ${escapeHtml(formatVal(r.value))}${unitSuffix}</strong></div>`
})
.join('')
tip.classList.remove('hidden')
article.classList.add('is-hovering')
return
}
if (state.hoverIndex == null || !state.visible.has(id)) {
tip.classList.add('hidden')
article.classList.remove('is-hovering')
return
}
const idx = Math.round(state.hoverIndex)
const rows = lines
.filter((l) => !l.hidden)
.map((l) => {
const vals = l.values || []
const i = Math.max(0, Math.min(vals.length - 1, idx))
return { label: l.label, value: vals[i], color: l.color }
})
.filter((r) => r.value != null && Number.isFinite(r.value))
.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
.slice(0, 8)
if (!rows.length) {
tip.classList.add('hidden')
return
}
const frac = maxPoints() > 1 ? idx / (maxPoints() - 1) : 1
const age = state.endOffset + state.afterSeconds * (1 - frac)
tip.innerHTML =
`<header>${escapeHtml(formatDuration(Math.max(0, age)))} ago</header>` +
rows
.map(
(r) =>
`<div class="tip-row"><i style="background:${r.color}"></i><span>${escapeHtml(r.label)}</span><strong>${escapeHtml(formatVal(r.value))}${unitSuffix}</strong></div>`
)
.join('')
tip.classList.remove('hidden')
article.classList.add('is-hovering')
}
function cardHeightFor(id, card) {
const base = state.cardHeight || DEFAULT_HEIGHT
if (state.pinned.has(id)) return Math.max(base, HERO_HEIGHT)
const dims = card?.labels?.length || card?.dims?.size || 0
if (dims >= 8) return Math.max(base, 180)
if (isHeroChart(id, card?.meta)) return Math.max(base, 172)
return base
}
function isHeroChart(id, meta) {
const priority = Number(meta?.priority ?? 9999)
return priority > 0 && priority <= 200
}
function toggleDim(chartId, dim) {
let set = state.hiddenDims.get(chartId)
if (!set) {
set = new Set()
state.hiddenDims.set(chartId, set)
}
if (set.has(dim)) set.delete(dim)
else set.add(dim)
paintCard(chartId)
}
function cycleChartType(id) {
const card = state.cards.get(id)
if (!card) return
const next = nextChartMode(card.mode, { ...card.meta, id, dimensions: card.labels })
card.mode = next
state.chartTypes.set(id, next)
persistPrefs({ chartTypes: Object.fromEntries(state.chartTypes) })
const typeBtn = state.cardEls.get(id)?.querySelector('.metric-type-btn')
if (typeBtn) {
typeBtn.textContent = CHART_MODE_LABEL[next] || next
typeBtn.title = chartModeHint(next)
}
paintCard(id)
}
function togglePin(id) {
if (state.pinned.has(id)) state.pinned.delete(id)
else state.pinned.add(id)
persistPrefs({ pinned: [...state.pinned] })
// Soft refresh: keep scroll position / avoid wall wipe flicker
const wall = opts.els.wall
const top = wall?.scrollTop || 0
render()
if (wall) wall.scrollTop = top
}
async function showRelated(seedId) {
state.relatedSeed = seedId
/** @type {Array<{ id: string, weight: number }>|undefined} */
let weights
try {
const w = await opts.getWeights?.({ chart: seedId, limit: 80 })
weights = w?.results
} catch {
weights = undefined
}
const catalog = opts.getCatalog() || {}
const ranked = rankRelatedCharts(seedId, catalog, state.cards, {
limit: 16,
weights,
})
state.relatedIds = new Set(ranked.map((r) => r.id))
const panel = opts.els.relatedPanel
if (panel) {
panel.classList.remove('hidden')
panel.innerHTML = ''
const head = document.createElement('header')
head.className = 'related-head'
const seedLabel = chartOptionLabel(seedId, catalog[seedId] || {})
head.innerHTML = `<strong>Related to ${escapeHtml(seedLabel)}</strong>`
const clear = document.createElement('button')
clear.type = 'button'
clear.className = 'ghost'
clear.textContent = 'Clear'
clear.addEventListener('click', () => clearRelated())
head.appendChild(clear)
panel.appendChild(head)
const list = document.createElement('div')
list.className = 'related-list'
if (!ranked.length) {
list.innerHTML = '<p class="muted">No related charts found</p>'
} else {
for (const row of ranked) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'related-item'
const label = chartOptionLabel(row.id, catalog[row.id] || {})
btn.innerHTML = `<span>${escapeHtml(label)}</span><span class="muted">${escapeHtml(row.reason)} · ${row.score.toFixed(1)}</span>`
btn.title = row.id
btn.addEventListener('click', () => scrollToChart(row.id))
list.appendChild(btn)
}
}
panel.appendChild(list)
}
for (const id of state.cardEls.keys()) paintCard(id)
}
function clearRelated() {
state.relatedIds.clear()
state.relatedSeed = ''
opts.els.relatedPanel?.classList.add('hidden')
if (opts.els.relatedPanel) opts.els.relatedPanel.innerHTML = ''
for (const id of state.cardEls.keys()) paintCard(id)
}
function updateHoverReadout() {
if (!opts.els.hoverReadout) return
if (state.hoverIndex == null) {
opts.els.hoverReadout.textContent = state.endOffset
? `paused · ${formatDuration(state.endOffset)} ago`
: ''
return
}
const denom = Math.max(1, (state.hoverSeriesLen || maxPoints()) - 1)
const frac = state.hoverIndex / denom
const age = state.endOffset + state.afterSeconds * (1 - frac)
opts.els.hoverReadout.textContent = `~${formatDuration(Math.max(0, age))} ago`
}
function bindCanvasInteractions(canvas, chartId) {
canvas.title =
'Scroll to browse · Ctrl/⌘ or Shift + scroll to zoom · drag horizontally to pan · Correlate mode: drag to highlight'
canvas.addEventListener('mousemove', (ev) => {
if (state.pan?.active || state.mcBrush) return
const card = state.cards.get(chartId)
const seriesLen = seriesLenForCard(chartId)
const units = card?.meta?.units || ''
const showY = normalizeChartMode(card?.mode) !== 'pie'
const idx = hoverIndexFromEvent(canvas, ev.clientX, {
showYAxis: showY,
seriesLen,
units,
padLeft: padLeftFor(units, showY),
})
state.hoverIndex = idx
state.hoverSeriesLen = seriesLen
updateHoverReadout()
scheduleHoverPaint()
})
canvas.addEventListener('mouseleave', () => {
if (state.pan?.active || state.mcBrush) return
state.hoverIndex = null
state.hoverSeriesLen = 0
updateHoverReadout()
scheduleHoverPaint()
})
// Natural wall scroll by default. Zoom only with an intentional modifier
// (otherwise every chart canvas traps the wheel and scrolling feels stuck).
canvas.addEventListener(
'wheel',
(ev) => {
const card = state.cards.get(chartId)
if (normalizeChartMode(card?.mode) === 'pie') return
const zoomIntent = ev.ctrlKey || ev.metaKey || ev.shiftKey
if (!zoomIntent) return // let .metrics-wall / #content scroll
ev.preventDefault()
const factor = ev.deltaY > 0 ? 1.25 : 0.8
setWindow(Math.round(state.afterSeconds * factor), state.endOffset)
},
{ passive: false }
)
canvas.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0) return
const card = state.cards.get(chartId)
if (normalizeChartMode(card?.mode) === 'pie') return
// Correlate mode: horizontal brush sets highlight window
if (state.mcMode) {
const frac = canvasFracFromClientX(canvas, ev.clientX, card)
state.mcBrush = { startFrac: frac, endFrac: frac, chartId }
try {
canvas.setPointerCapture(ev.pointerId)
} catch {
// ignore
}
canvas.classList.add('brushing')
return
}
if (state.forcePlay) return
// Defer pan until a clear horizontal drag — avoids fighting trackpad scroll.
state.pan = {
active: false,
pending: true,
pointerId: ev.pointerId,
startX: ev.clientX,
startY: ev.clientY,
startOffset: state.endOffset,
}
try {
canvas.setPointerCapture(ev.pointerId)
} catch {
// ignore
}
})
canvas.addEventListener('pointermove', (ev) => {
if (state.mcBrush && state.mcBrush.chartId === chartId) {
const card = state.cards.get(chartId)
state.mcBrush.endFrac = canvasFracFromClientX(canvas, ev.clientX, card)
updateHighlightBand(chartId)
syncMcUi()
return
}
if (!state.pan) return
const dx = ev.clientX - state.pan.startX
const dy = ev.clientY - state.pan.startY
if (state.pan.pending) {
if (Math.hypot(dx, dy) < 8) return
// Vertical-dominant gesture → cancel pan so scroll/selection feels natural
if (Math.abs(dy) > Math.abs(dx) * 1.15) {
state.pan = null
try {
canvas.releasePointerCapture(ev.pointerId)
} catch {
// ignore
}
return
}
state.pan.pending = false
state.pan.active = true
setPlaying(false)
canvas.classList.add('panning')
}
if (!state.pan.active) return
const pxPerSec = Math.max(2, canvas.clientWidth / state.afterSeconds)
const deltaSec = Math.round(-dx / pxPerSec)
state.endOffset = Math.max(0, state.pan.startOffset + deltaSec)
state.presetId = 'custom'
syncPresetUi()
updateMeta()
updateHoverReadout()
})
const endPan = (ev) => {
if (state.mcBrush && state.mcBrush.chartId === chartId) {
const brush = state.mcBrush
state.mcBrush = null
canvas.classList.remove('brushing')
try {
canvas.releasePointerCapture(ev.pointerId)
} catch {
// ignore
}
commitBrushHighlight(brush)
return
}
if (!state.pan) return
const wasActive = state.pan.active
state.pan = null
canvas.classList.remove('panning')
try {
canvas.releasePointerCapture(ev.pointerId)
} catch {
// ignore
}
if (wasActive) scheduleRefetchVisible()
}
canvas.addEventListener('pointerup', endPan)
canvas.addEventListener('pointercancel', endPan)
}
function canvasFracFromClientX(canvas, clientX, card) {
const units = card?.meta?.units || ''
const showY = normalizeChartMode(card?.mode) !== 'pie'
const padL = padLeftFor(units, showY)
const padR = 10
const rect = canvas.getBoundingClientRect()
const plotW = Math.max(1, rect.width - padL - padR)
const x = clientX - rect.left - padL
return Math.max(0, Math.min(1, x / plotW))
}
/** Map brush fractions (0=oldest … 1=newest in view) to absolute unix seconds. */
function commitBrushHighlight(brush) {
if (!brush) return
const lo = Math.min(brush.startFrac, brush.endFrac)
const hi = Math.max(brush.startFrac, brush.endFrac)
if (hi - lo < 0.02) {
syncMcUi()
return
}
const nowSec = Math.floor(Date.now() / 1000)
const viewEnd = nowSec - state.endOffset
const viewStart = viewEnd - state.afterSeconds
const after = Math.round(viewStart + lo * state.afterSeconds)
const before = Math.round(viewStart + hi * state.afterSeconds)
if (before - after < 15) {
if (opts.els.mcHint) {
opts.els.mcHint.textContent = 'Highlight must be at least 15 seconds'
}
syncMcUi()
return
}
state.highlight = { after, before }
for (const id of state.cardEls.keys()) updateHighlightBand(id)
syncMcUi()
}
function updateHighlightBand(chartId) {
const article = state.cardEls.get(chartId)
if (!article) return
let band = article.querySelector('.metric-highlight-band')
if (!band) {
band = document.createElement('div')
band.className = 'metric-highlight-band'
const wrap = article.querySelector('.metric-canvas-wrap') || article
wrap.appendChild(band)
}
const hl = state.highlight
const brush = state.mcBrush
if (!hl && !brush) {
band.classList.add('hidden')
return
}
const nowSec = Math.floor(Date.now() / 1000)
const viewEnd = nowSec - state.endOffset
const viewStart = viewEnd - state.afterSeconds
let after
let before
if (brush && brush.chartId === chartId) {
const lo = Math.min(brush.startFrac, brush.endFrac)
const hi = Math.max(brush.startFrac, brush.endFrac)
after = viewStart + lo * state.afterSeconds
before = viewStart + hi * state.afterSeconds
} else if (hl) {
after = hl.after
before = hl.before
} else {
band.classList.add('hidden')
return
}
const leftFrac = (after - viewStart) / Math.max(1, state.afterSeconds)
const rightFrac = (before - viewStart) / Math.max(1, state.afterSeconds)
const left = Math.max(0, Math.min(1, leftFrac))
const right = Math.max(0, Math.min(1, rightFrac))
if (right <= left) {
band.classList.add('hidden')
return
}
band.classList.remove('hidden')
band.style.left = `${left * 100}%`
band.style.width = `${(right - left) * 100}%`
}
/**
* 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
sections = []
}
if (state.mcResults?.length) {
sections = mcResultSections(catalog)
}
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')
}
if (opts.els.correlateBtn) {
opts.els.correlateBtn.classList.toggle('active', state.mcMode)
opts.els.correlateBtn.setAttribute('aria-pressed', state.mcMode ? 'true' : 'false')
}
opts.els.root?.classList.toggle('mc-mode', state.mcMode)
opts.els.root?.classList.toggle('mc-results', Boolean(state.mcResults?.length))
}
function mcResultSections(catalog) {
const charts = []
for (const row of state.mcResults || []) {
const meta = catalog[row.id] || {
title: row.info || row.id,
context: row.context || '',
family: '',
}
charts.push({ id: row.id, meta })
}
return [
{
id: 'correlations',
title: 'Metric Correlations',
count: charts.length,
groups: [{ family: '_default', charts }],
},
]
}
function setBoardOnly(on) {
state.boardOnly = Boolean(on)
render()
}
function renderToc(sections) {
const toc = opts.els.toc
if (!toc) return
toc.innerHTML = ''
if (state.pinned.size) {
const pinBtn = document.createElement('button')
pinBtn.type = 'button'
pinBtn.className = 'toc-item'
pinBtn.innerHTML = `<span>Pinned</span><span class="toc-count">${state.pinned.size}</span>`
pinBtn.addEventListener('click', () => {
const el = opts.els.wall?.querySelector('[data-section="pinned"]')
if (el) smoothScrollIntoView(el, 'start')
})
toc.appendChild(pinBtn)
}
if (!sections.length && !state.pinned.size) {
const empty = document.createElement('p')
empty.className = 'muted'
empty.textContent = 'No charts — connect an agent'
toc.appendChild(empty)
return
}
for (const sec of sections) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'toc-item'
btn.innerHTML = `<span>${escapeHtml(sec.title)}</span><span class="toc-count">${sec.count}</span>`
btn.addEventListener('click', () => {
const el = opts.els.wall?.querySelector(`[data-section="${sec.id}"]`)
if (el) smoothScrollIntoView(el, 'start')
})
toc.appendChild(btn)
for (const g of sec.groups.slice(0, 12)) {
if (g.family === '_default') continue
const sub = document.createElement('button')
sub.type = 'button'
sub.className = 'toc-sub'
sub.textContent = g.family
sub.title = g.family
sub.addEventListener('click', () => {
const section = opts.els.wall?.querySelector(`[data-section="${sec.id}"]`)
const el = section
? [...section.querySelectorAll('[data-family]')].find(
(n) => n.getAttribute('data-family') === g.family
)
: null
if (el) smoothScrollIntoView(el, 'start')
})
toc.appendChild(sub)
}
}
}
function renderWall(sections) {
const wall = opts.els.wall
if (!wall) return
if (state.observer) {
state.observer.disconnect()
state.observer = null
}
wall.innerHTML = ''
state.visible.clear()
state.cardEls.clear()
const catalog = opts.getCatalog() || {}
const pinnedIds = [...state.pinned].filter((id) => catalog[id])
if (!sections.length && !pinnedIds.length) {
const empty = document.createElement('div')
empty.className = 'metrics-empty muted'
if (state.boardOnly) {
empty.innerHTML =
'Board is empty — pin charts with <strong>☆</strong>, then open Board again.'
} else {
empty.textContent = Object.keys(catalog).length
? 'No charts match this filter'
: 'Connect an agent to load the metrics catalog'
}
wall.appendChild(empty)
return
}
state.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const id = entry.target.getAttribute('data-chart-id')
if (!id) continue
if (entry.isIntersecting) {
state.visible.add(id)
const card = state.cards.get(id)
if (!card || card.status === 'idle' || card.status === 'error') {
fetchChart(id).catch(() => {})
} else {
paintCard(id)
}
} else {
state.visible.delete(id)
}
}
},
{ root: wall, rootMargin: '240px 0px', threshold: 0.05 }
)
if (pinnedIds.length) {
const pinSec = document.createElement('section')
pinSec.className = 'metrics-section metrics-pinned'
pinSec.dataset.section = 'pinned'
const head = document.createElement('header')
head.className = 'metrics-section-head'
head.innerHTML = `<h2>Pinned</h2><span class="muted">${pinnedIds.length}</span><span class="muted pin-hint">drag to reorder</span>`
pinSec.appendChild(head)
const grid = document.createElement('div')
grid.className = 'metrics-grid'
for (const id of pinnedIds) {
const el = buildCardEl(id, catalog[id] || {})
enablePinDrag(el, id, grid)
grid.appendChild(el)
}
pinSec.appendChild(grid)
wall.appendChild(pinSec)
} else if (state.boardOnly) {
const empty = document.createElement('div')
empty.className = 'metrics-empty muted'
empty.innerHTML =
'Board is empty — pin charts with <strong>☆</strong> then click <strong>Board</strong> again.'
wall.appendChild(empty)
}
for (const sec of sections) {
const sectionEl = document.createElement('section')
sectionEl.className = 'metrics-section'
sectionEl.dataset.section = sec.id
if (state.collapsed.has(sec.id)) sectionEl.classList.add('collapsed')
const head = document.createElement('header')
head.className = 'metrics-section-head'
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.className = 'section-toggle'
toggle.textContent = state.collapsed.has(sec.id) ? '▸' : '▾'
toggle.addEventListener('click', () => {
if (state.collapsed.has(sec.id)) state.collapsed.delete(sec.id)
else state.collapsed.add(sec.id)
persistPrefs({ collapsed: [...state.collapsed] })
const collapsed = state.collapsed.has(sec.id)
sectionEl.classList.toggle('collapsed', collapsed)
toggle.textContent = collapsed ? '▸' : '▾'
})
const title = document.createElement('h2')
title.textContent = sec.title
const count = document.createElement('span')
count.className = 'muted'
count.textContent = `${sec.count}`
head.append(toggle, title, count)
sectionEl.appendChild(head)
const kpis = document.createElement('div')
kpis.className = 'section-kpis'
kpis.dataset.sectionKpis = sec.id
sectionEl.appendChild(kpis)
const body = document.createElement('div')
body.className = 'metrics-section-body'
for (const g of sec.groups) {
const fam = document.createElement('div')
fam.className = 'metrics-family'
fam.dataset.family = g.family
if (g.family !== '_default') {
const fh = document.createElement('h3')
fh.className = 'metrics-family-title'
fh.textContent = g.family
fam.appendChild(fh)
}
const grid = document.createElement('div')
grid.className = 'metrics-grid'
for (const { id, meta } of g.charts) {
if (state.pinned.has(id)) continue
grid.appendChild(buildCardEl(id, meta))
}
if (!grid.children.length) continue
fam.appendChild(grid)
body.appendChild(fam)
}
sectionEl.appendChild(body)
wall.appendChild(sectionEl)
}
refreshSectionKpis()
}
/**
* @param {string} id
* @param {object} meta
*/
/**
* @param {HTMLElement} article
* @param {string} id
* @param {HTMLElement} grid
*/
function enablePinDrag(article, id, grid) {
article.draggable = true
article.classList.add('pin-draggable')
article.addEventListener('dragstart', (ev) => {
article.classList.add('dragging')
ev.dataTransfer?.setData('text/plain', id)
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = 'move'
})
article.addEventListener('dragend', () => article.classList.remove('dragging'))
article.addEventListener('dragover', (ev) => {
ev.preventDefault()
const dragging = grid.querySelector('.dragging')
if (!dragging || dragging === article) return
const rect = article.getBoundingClientRect()
const before = ev.clientY < rect.top + rect.height / 2
grid.insertBefore(dragging, before ? article : article.nextSibling)
})
article.addEventListener('drop', (ev) => {
ev.preventDefault()
const order = [...grid.querySelectorAll('[data-chart-id]')].map((n) =>
n.getAttribute('data-chart-id')
)
state.pinned = new Set(order.filter(Boolean))
persistPrefs({ pinned: [...state.pinned] })
})
}
function buildCardEl(id, meta) {
const card = ensureCard(id, meta)
const height = cardHeightFor(id, card)
const hero = isHeroChart(id, meta)
const article = document.createElement('article')
article.className =
'metric-card' +
(state.pinned.has(id) ? ' pinned' : '') +
(hero ? ' metric-card--hero' : '')
article.dataset.chartId = id
article.innerHTML = `
<header class="metric-card-head">
<div>
<h4 class="metric-card-title" title="${escapeAttr(id)}">${escapeHtml(meta.title || id)}</h4>
<p class="metric-card-sub muted">${escapeHtml(metricCardSubtitle(id, meta))}</p>
</div>
<div class="metric-card-actions">
<button type="button" class="btn btn-ghost metric-pin-btn" title="Pin">${state.pinned.has(id) ? '★' : '☆'}</button>
<button type="button" class="btn btn-ghost metric-related-btn" title="Find related">⇢</button>
<button type="button" class="btn btn-ghost metric-type-btn" title="${escapeAttr(chartModeHint(card.mode))}">${escapeHtml(CHART_MODE_LABEL[card.mode] || card.mode)}</button>
<button type="button" class="btn btn-ghost metric-focus-btn" title="Fullscreen (double-click)" aria-label="Fullscreen">⛶</button>
<span class="metric-card-status muted"></span>
</div>
</header>
<div class="metric-canvas-wrap">
<canvas height="${height}"></canvas>
<div class="metric-highlight-band hidden" aria-hidden="true"></div>
<span class="metric-weight-chip hidden"></span>
<button type="button" class="metric-focus-fab" title="Fullscreen" aria-label="Open fullscreen">⛶</button>
</div>
<div class="metric-card-legend"></div>
<div class="metric-card-stats hidden"></div>
<div class="metric-resize" title="Drag to resize · double-click to reset"></div>
`
const canvas = article.querySelector('canvas')
if (canvas) {
canvas.style.height = `${height}px`
bindCanvasInteractions(canvas, id)
}
article.addEventListener('mouseenter', () => {
if (normalizeChartMode(card.mode) === 'pie') paintCard(id)
})
article.addEventListener('mouseleave', () => {
const tip = article.querySelector('.chart-hover-tip')
tip?.classList.add('hidden')
article.classList.remove('is-hovering')
})
article.querySelector('.metric-pin-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation()
togglePin(id)
})
article.querySelector('.metric-related-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation()
showRelated(id)
})
article.querySelector('.metric-type-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation()
cycleChartType(id)
})
const openFocusHandler = (ev) => {
ev.stopPropagation()
openFocus(id)
}
article.querySelector('.metric-focus-btn')?.addEventListener('click', openFocusHandler)
article.querySelector('.metric-focus-fab')?.addEventListener('click', openFocusHandler)
bindResizeHandle(article)
article.querySelector('.metric-card-title')?.addEventListener('click', (ev) => {
ev.stopPropagation()
article.classList.toggle('expanded')
const statsEl = article.querySelector('.metric-card-stats')
if (!statsEl) return
if (article.classList.contains('expanded')) {
statsEl.classList.remove('hidden')
const { dims, hidden } = sortedDims(card, id)
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
} else {
statsEl.classList.add('hidden')
}
})
article.addEventListener('dblclick', (ev) => {
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-resize')) return
ev.preventDefault()
openFocus(id)
})
state.cardEls.set(id, article)
state.observer?.observe(article)
return article
}
function bindResizeHandle(article) {
const handle = article.querySelector('.metric-resize')
if (!handle) return
let startY = 0
let startH = 0
const onMove = (ev) => {
state.cardHeight = clampHeight(startH + (ev.clientY - startY))
for (const [cid, el] of state.cardEls) {
const c = el.querySelector('canvas')
if (c) c.style.height = `${cardHeightFor(cid, state.cards.get(cid))}px`
}
for (const id of state.visible) paintCard(id)
}
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
persistPrefs({ cardHeight: state.cardHeight })
}
handle.addEventListener('pointerdown', (ev) => {
ev.preventDefault()
ev.stopPropagation()
startY = ev.clientY
startH = state.cardHeight
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
})
handle.addEventListener('dblclick', (ev) => {
ev.preventDefault()
ev.stopPropagation()
state.cardHeight = DEFAULT_HEIGHT
persistPrefs({ cardHeight: state.cardHeight })
for (const id of state.cardEls.keys()) paintCard(id)
})
}
function refreshSectionKpis() {
const wall = opts.els.wall
if (!wall) return
for (const strip of wall.querySelectorAll('[data-section-kpis]')) {
const secId = strip.getAttribute('data-section-kpis')
const section = strip.closest('.metrics-section')
if (!section) continue
const ids = [...section.querySelectorAll('[data-chart-id]')].map((n) =>
n.getAttribute('data-chart-id')
)
/** @type {Array<{ id: string, label: string, value: number, units: string }>} */
const tops = []
for (const id of ids) {
if (!id) continue
const card = state.cards.get(id)
if (!card || card.status !== 'ok') continue
let best = 0
let bestDim = ''
for (const [dim, arr] of card.dims) {
const v = Math.abs(lastVal(arr))
if (v >= best) {
best = v
bestDim = dim
}
}
tops.push({
id,
label: card.meta.title || id,
value: lastVal(card.dims.get(bestDim)),
units: card.meta.units || '',
})
}
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 slice) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'section-kpi'
btn.innerHTML = `<span class="kpi-label">${escapeHtml(row.label)}</span><span class="kpi-val">${formatVal(row.value)}${row.units ? ` <small>${escapeHtml(row.units)}</small>` : ''}</span>`
btn.addEventListener('click', () => scrollToChart(row.id))
strip.appendChild(btn)
}
}
}
function toggleDimSort() {
state.dimSort = state.dimSort === 'name' ? 'value' : 'name'
persistPrefs({ dimSort: state.dimSort })
if (opts.els.dimSortBtn) {
opts.els.dimSortBtn.textContent = state.dimSort === 'name' ? 'Sort: name' : 'Sort: value'
}
for (const id of state.visible) paintCard(id)
}
function setFiltersOpen(open) {
state.filtersOpen = Boolean(open)
persistPrefs({ filtersOpen: state.filtersOpen })
syncFiltersUi()
}
function syncFiltersUi() {
const root = opts.els.root
const panel = opts.els.filtersPanel
const btn = opts.els.filtersBtn
root?.classList.toggle('charts-filters-open', state.filtersOpen)
if (panel) {
if (state.filtersOpen) panel.removeAttribute('hidden')
else panel.setAttribute('hidden', '')
}
if (btn) {
btn.classList.toggle('active', state.filtersOpen)
btn.setAttribute('aria-expanded', state.filtersOpen ? 'true' : 'false')
}
syncFilterChip()
}
function syncFilterChip() {
const chip = opts.els.filterChip
if (!chip) return
const q = (state.filter || '').trim()
const show = Boolean(q) && !state.filtersOpen
chip.classList.toggle('hidden', !show)
if (!show) {
chip.innerHTML = ''
return
}
chip.innerHTML = ''
const text = document.createElement('span')
text.className = 'filter-chip-text'
text.textContent = `Filtered: ${q}`
text.title = q
const clear = document.createElement('button')
clear.type = 'button'
clear.textContent = '×'
clear.title = 'Clear filter'
clear.setAttribute('aria-label', 'Clear chart filter')
clear.addEventListener('click', () => {
state.filter = ''
if (opts.els.search) opts.els.search.value = ''
syncFilterChip()
render()
})
chip.appendChild(text)
chip.appendChild(clear)
}
function bindChrome() {
opts.els.search?.addEventListener('input', () => {
state.filter = opts.els.search?.value || ''
syncFilterChip()
render()
})
opts.els.filtersBtn?.addEventListener('click', () => setFiltersOpen(!state.filtersOpen))
opts.els.playBtn?.addEventListener('click', () => setPlaying(!state.playing))
opts.els.presets?.querySelectorAll('[data-preset]').forEach((btn) => {
btn.addEventListener('click', () => setPreset(btn.getAttribute('data-preset') || '5m'))
})
opts.els.resetBtn?.addEventListener('click', () => resetWindow())
opts.els.dimSortBtn?.addEventListener('click', () => toggleDimSort())
if (opts.els.dimSortBtn) {
opts.els.dimSortBtn.textContent = state.dimSort === 'name' ? 'Sort: name' : 'Sort: value'
}
if (opts.els.groupSelect) {
opts.els.groupSelect.value = state.group
opts.els.groupSelect.addEventListener('change', () => {
setGroup(opts.els.groupSelect?.value || 'average')
})
}
opts.els.forcePlayBtn?.addEventListener('click', () => setForcePlay(!state.forcePlay))
opts.els.boardBtn?.addEventListener('click', () => setBoardOnly(!state.boardOnly))
opts.els.correlateBtn?.addEventListener('click', () => setMcMode(!state.mcMode))
opts.els.mcMethod?.addEventListener('change', () => {
state.mcMethod = opts.els.mcMethod?.value || 'volume'
})
opts.els.mcRunBtn?.addEventListener('click', () => runMetricCorrelations().catch(() => {}))
opts.els.mcClearBtn?.addEventListener('click', () => clearMcResults())
bindKeyboard()
setForcePlay(state.forcePlay)
setPlaying(true)
// Default live 5m; presets unlock when catalog arrives via setCatalog()
state.presetId = '5m'
state.afterSeconds = 300
state.endOffset = 0
syncPresetUi()
syncLiveUi()
syncMcUi()
syncFiltersUi()
}
function setMcMode(on) {
state.mcMode = Boolean(on)
if (state.mcMode) {
state.boardOnly = false
setPlaying(false)
} else if (!state.mcResults?.length) {
state.highlight = null
}
opts.els.root?.classList.toggle('mc-mode', state.mcMode)
if (opts.els.correlateBtn) {
opts.els.correlateBtn.classList.toggle('active', state.mcMode)
opts.els.correlateBtn.setAttribute('aria-pressed', state.mcMode ? 'true' : 'false')
}
syncMcUi()
for (const id of state.cardEls.keys()) updateHighlightBand(id)
}
function syncMcUi() {
const bar = opts.els.mcBar
if (bar) bar.classList.toggle('hidden', !state.mcMode && !state.mcResults?.length)
const hl = state.highlight
const dur = hl ? hl.before - hl.after : 0
const canRun = Boolean(hl && dur >= 15 && !state.mcRunning)
if (opts.els.mcRunBtn) opts.els.mcRunBtn.disabled = !canRun || state.mcRunning
if (opts.els.mcHint) {
if (state.mcRunning) opts.els.mcHint.textContent = 'Scoring metrics…'
else if (state.mcResults?.length) {
opts.els.mcHint.textContent = `${state.mcResults.length} correlated charts · ${state.mcMethod}`
} else if (hl && dur >= 15) {
opts.els.mcHint.textContent = `Highlight ${formatDuration(dur)} · baseline ~${formatDuration(dur * 4)} before`
} else if (hl) {
opts.els.mcHint.textContent = 'Highlight must be at least 15 seconds'
} else {
opts.els.mcHint.textContent = 'Brush a chart (≥15s), then Find Correlations'
}
}
if (opts.els.mcMethod && opts.els.mcMethod.value !== state.mcMethod) {
opts.els.mcMethod.value = state.mcMethod
}
}
async function runMetricCorrelations(override = {}) {
const hl = override.highlight || state.highlight
if (!hl || hl.before - hl.after < 15) {
syncMcUi()
return
}
const method = override.method || state.mcMethod || 'volume'
state.mcRunning = true
syncMcUi()
try {
const res = await opts.getWeights?.({
method,
after: hl.after,
before: hl.before,
// baseline auto-computed server-side when omitted
points: 500,
limit: 80,
timeout: 30_000,
})
if (res?.error) {
if (opts.els.mcHint) opts.els.mcHint.textContent = res.error
return
}
const results = Array.isArray(res?.results) ? res.results : []
state.highlight = hl
state.mcMethod = method
state.mcResults = results.filter((r) => r?.id && Number(r.weight) > 0)
state.mcMode = true
// Align wall window to highlight + pause
const nowSec = Math.floor(Date.now() / 1000)
const ageEnd = Math.max(0, nowSec - hl.before)
const win = Math.max(30, hl.before - hl.after)
setWindow(win, ageEnd)
setPlaying(false)
renderMcResultsPanel(res)
render()
// Prefetch result cards
for (const row of state.mcResults.slice(0, 40)) {
fetchChart(row.id).catch(() => {})
}
} catch (err) {
if (opts.els.mcHint) {
opts.els.mcHint.textContent = err?.message || 'Correlation failed'
}
} finally {
state.mcRunning = false
syncMcUi()
}
}
function renderMcResultsPanel(res) {
const panel = opts.els.mcResultsPanel
if (!panel) return
panel.classList.remove('hidden')
panel.innerHTML = ''
const head = document.createElement('header')
head.className = 'related-head'
const n = state.mcResults?.length || 0
head.innerHTML = `<strong>Correlations</strong><span class="muted">${escapeHtml(state.mcMethod)} · ${n}</span>`
const clear = document.createElement('button')
clear.type = 'button'
clear.className = 'ghost'
clear.textContent = 'Clear'
clear.addEventListener('click', () => clearMcResults())
head.appendChild(clear)
panel.appendChild(head)
const list = document.createElement('div')
list.className = 'related-list'
if (!n) {
list.innerHTML = '<p class="muted">No strongly changed metrics in this window</p>'
} else {
for (const row of state.mcResults.slice(0, 40)) {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'related-item'
const pct = `${(Number(row.weight) * 100).toFixed(0)}%`
btn.innerHTML = `<span>${escapeHtml(row.info || row.id)}</span><span class="muted">${pct} · ${escapeHtml(row.id)}</span>`
btn.addEventListener('click', () => scrollToChart(row.id))
list.appendChild(btn)
}
}
panel.appendChild(list)
void res
}
function clearMcResults() {
state.mcResults = null
state.highlight = null
state.mcMode = false
state.mcBrush = null
opts.els.mcResultsPanel?.classList.add('hidden')
if (opts.els.mcResultsPanel) opts.els.mcResultsPanel.innerHTML = ''
opts.els.root?.classList.remove('mc-mode', 'mc-results')
if (opts.els.correlateBtn) {
opts.els.correlateBtn.classList.remove('active')
opts.els.correlateBtn.setAttribute('aria-pressed', 'false')
}
syncMcUi()
render()
}
/**
* Open Correlate around an absolute event time (ms).
* @param {number} tsMs
* @param {{ method?: string, halfWindowSec?: number }} [optsIn]
*/
function correlateAround(tsMs, optsIn = {}) {
if (!tsMs || !Number.isFinite(tsMs)) return
const half = Math.max(30, Number(optsIn.halfWindowSec) || 60)
const center = Math.floor(tsMs / 1000)
const highlight = { after: center - half, before: center + half }
state.highlight = highlight
state.mcMethod = optsIn.method || 'anomaly-rate'
setMcMode(true)
const nowSec = Math.floor(Date.now() / 1000)
setWindow(Math.max(300, half * 6), Math.max(0, nowSec - highlight.before))
setPlaying(false)
runMetricCorrelations({ highlight, method: state.mcMethod }).catch(() => {})
}
function bindKeyboard() {
const root = opts.els.root
if (!root || root.dataset.keysBound) return
root.dataset.keysBound = '1'
root.tabIndex = root.tabIndex >= 0 ? root.tabIndex : -1
window.addEventListener('keydown', (ev) => {
if (!root || root.classList.contains('hidden')) return
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
if (ev.key === 'Escape') {
const t = /** @type {HTMLElement} */ (ev.target)
t.blur()
if (
state.filtersOpen &&
opts.els.filtersPanel &&
opts.els.filtersPanel.contains(t)
) {
setFiltersOpen(false)
}
}
return
}
if (focus.isOpen()) {
if (ev.key === ' ' || ev.code === 'Space') {
ev.preventDefault()
setPlaying(!state.playing)
} else if (ev.key >= '1' && ev.key <= '5') {
const presets = ['1m', '5m', '15m', '1h', '6h']
setPreset(presets[Number(ev.key) - 1])
}
// Esc / arrows handled by chart-focus
return
}
if (ev.key === ' ' || ev.code === 'Space') {
ev.preventDefault()
setPlaying(!state.playing)
} else if (ev.key === '/') {
ev.preventDefault()
if (!state.filtersOpen) setFiltersOpen(true)
requestAnimationFrame(() => opts.els.search?.focus())
} else if (ev.key === 'r' || ev.key === 'R') {
resetWindow()
} else if (ev.key === 'f' || ev.key === 'F') {
setForcePlay(!state.forcePlay)
} else if (ev.key === 'b' || ev.key === 'B') {
setBoardOnly(!state.boardOnly)
} else if (ev.key === 'Escape') {
if (state.mcResults || state.mcMode) clearMcResults()
else if (state.filtersOpen) setFiltersOpen(false)
else clearRelated()
} else if (ev.key === 'c' || ev.key === 'C') {
setMcMode(!state.mcMode)
} else if (ev.key >= '1' && ev.key <= '5') {
const presets = ['1m', '5m', '15m', '1h', '6h']
setPreset(presets[Number(ev.key) - 1])
}
})
}
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()
if (wall) wall.scrollTop = top
if (soft) return
// Full structure change (or first build) — pull history for visible cards
scheduleRefetchVisible()
}
/** Refresh retention meta after a background catalog poll. */
function refreshRetention() {
syncPresetUi()
}
/** Clear series caches (e.g. active peer switched). */
function resetData() {
for (const card of state.cards.values()) {
card.dims = new Map()
card.labels = []
card.status = 'idle'
card.source = ''
card.updating = false
card.emptyReason = ''
}
state.retentionSeconds = 0
syncPresetUi()
scheduleRefetchVisible()
}
function redrawVisible() {
for (const id of state.visible) paintCard(id)
}
/** @param {string} id */
function scrollToChart(id) {
const el = state.cardEls.get(id)
if (el) {
smoothScrollIntoView(el, 'center')
el.classList.add('flash')
setTimeout(() => el.classList.remove('flash'), 1200)
return
}
state.filter = ''
if (opts.els.search) opts.els.search.value = ''
render()
requestAnimationFrame(() => {
const again = state.cardEls.get(id)
if (!again) return
smoothScrollIntoView(again, 'center')
again.classList.add('flash')
setTimeout(() => again.classList.remove('flash'), 1200)
})
}
/**
* Jump to chart and pause near an event timestamp.
* @param {string} id
* @param {number} [tsMs]
*/
function focusChartAt(id, tsMs) {
if (tsMs && Number.isFinite(tsMs)) {
const ageSec = Math.max(0, Math.round((Date.now() - tsMs) / 1000))
setWindow(Math.max(300, Math.min(MAX_WINDOW, ageSec + 120)), Math.max(0, ageSec - 60))
setPlaying(false)
}
scrollToChart(id)
}
bindChrome()
return {
render,
setCatalog,
onSamples,
setPlaying,
setPreset,
setGroup,
setForcePlay,
resetWindow,
resetData,
redrawVisible,
scrollToChart,
focusChartAt,
openFocus,
closeFocus: () => focus.close(),
showRelated,
clearRelated,
setBoardOnly,
setMcMode,
runMetricCorrelations,
clearMcResults,
correlateAround,
refreshRetention,
getState: () => state,
}
}
/** @param {number[]|undefined} arr */
function lastVal(arr) {
if (!arr || !arr.length) return 0
return Number(arr[arr.length - 1]) || 0
}
/** @param {number} n @param {number} lo @param {number} hi */
function clamp(n, lo, hi) {
return Math.min(hi, Math.max(lo, n))
}
/** @param {number} h */
function clampHeight(h) {
return clamp(Number(h) || DEFAULT_HEIGHT, 100, 360)
}
/** @param {string} s */
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;')
}
/** @param {string} s */
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** @param {number} v */
function formatVal(v) {
const a = Math.abs(v)
if (a >= 1e6) return (v / 1e6).toFixed(2) + 'M'
if (a >= 1e3) return (v / 1e3).toFixed(2) + 'k'
if (a >= 10) return v.toFixed(1)
return v.toFixed(2)
}
/** @param {number} sec */
function formatDuration(sec) {
const s = Math.round(sec)
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.round(s / 60)}m`
if (s < 86400) return `${(s / 3600).toFixed(s % 3600 ? 1 : 0)}h`
return `${(s / 86400).toFixed(1)}d`
}
/**
* @param {{ dims: Map<string, number[]> }} card
* @param {string[]} dims
* @param {Set<string>} hidden
*/
function renderStatsHtml(card, dims, hidden, units = '') {
const u = units ? ` <small class="muted">${escapeHtml(units)}</small>` : ''
const rows = dims
.filter((d) => !hidden.has(d))
.map((d) => {
const arr = card.dims.get(d) || []
if (!arr.length) return null
let min = Infinity
let max = -Infinity
let sum = 0
for (const v of arr) {
min = Math.min(min, v)
max = Math.max(max, v)
sum += v
}
const avg = sum / arr.length
const last = arr[arr.length - 1]
return `<tr><td>${escapeHtml(d)}</td><td>${formatVal(last)}${u}</td><td>${formatVal(min)}</td><td>${formatVal(avg)}</td><td>${formatVal(max)}</td></tr>`
})
.filter(Boolean)
if (!rows.length) return '<p class="muted">No dimension stats</p>'
return `<table class="dim-stats"><thead><tr><th>Dim</th><th>Last</th><th>Min</th><th>Avg</th><th>Max</th></tr></thead><tbody>${rows.join('')}</tbody></table>`
}