/** * 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, * queryData: (args: object) => Promise, * getPoints: () => number, * isConnected?: () => boolean, * getPrefs?: () => { * cardHeight?: number, * dimSort?: 'name'|'value', * collapsed?: string[], * chartTypes?: Record, * 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} */ chartTypes: new Map(Object.entries(prefs().chartTypes || {})), hiddenDims: /** @type {Map>} */ (new Map()), /** @type {Set} */ relatedIds: new Set(), relatedSeed: '', boardOnly: false, /** @type {Map, labels: string[], status: string, meta: object, mode: string, source?: string, updating?: boolean, emptyReason?: string }>} */ cards: new Map(), /** @type {Map} */ cardEls: new Map(), visible: /** @type {Set} */ (new Set()), observer: /** @type {IntersectionObserver|null} */ (null), built: false, refetchTimer: /** @type {ReturnType|null} */ (null), pan: /** @type {{ active: boolean, pending?: boolean, pointerId?: number, startX: number, startY?: number, startOffset: number }|null} */ (null), /** @type {Map} 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} 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} 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 }>} samples */ /** @type {Set} */ 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} 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 `${escapeHtml(text)}` }) .join('') + (extra ? `+${extra}` : '') 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>} 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 `` }) .join('') + (extra ? `+${extra}` : '') 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 = `
Share · latest
` + rows .slice(0, 8) .map((r) => { const pct = ((Math.abs(r.value) / total) * 100).toFixed(0) return `
${escapeHtml(r.label)}${pct}% · ${escapeHtml(formatVal(r.value))}${unitSuffix}
` }) .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 = `
${escapeHtml(formatDuration(Math.max(0, age)))} ago
` + rows .map( (r) => `
${escapeHtml(r.label)}${escapeHtml(formatVal(r.value))}${unitSuffix}
` ) .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 } /** * Pin or unpin one or more charts (agent / automation API). * @param {string|string[]} chartIds * @param {{ pin?: boolean }} [optsIn] pin=true force pin, false force unpin, omit=toggle each */ function setPinned(chartIds, optsIn = {}) { const ids = (Array.isArray(chartIds) ? chartIds : [chartIds]) .map(String) .filter(Boolean) const force = optsIn.pin for (const id of ids) { if (force === true) state.pinned.add(id) else if (force === false) state.pinned.delete(id) else if (state.pinned.has(id)) state.pinned.delete(id) else state.pinned.add(id) } persistPrefs({ pinned: [...state.pinned] }) const wall = opts.els.wall const top = wall?.scrollTop || 0 render() if (wall) wall.scrollTop = top return { ok: true, pinned: [...state.pinned], touched: ids } } /** * Set chart visualization mode: line|area|bar|pie|stack. * @param {string} id * @param {string} mode */ function setChartMode(id, mode) { const card = state.cards.get(id) || ensureCard(id, opts.getCatalog()?.[id] || {}) if (!card) return { ok: false, error: 'unknown chart' } const next = normalizeChartMode(mode) 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) return { ok: true, chart: id, mode: next } } /** * Filter the charts wall by free text (same as search box). * @param {string} q */ function setFilter(q) { state.filter = String(q || '') if (opts.els.search) opts.els.search.value = state.filter render() return { ok: true, filter: state.filter } } /** * Time window control for the wall. * @param {{ * preset?: string, * seconds?: number, * endOffset?: number, * playing?: boolean, * }} optsIn */ function setTimeWindow(optsIn = {}) { if (optsIn.preset) setPreset(String(optsIn.preset)) else if (optsIn.seconds != null) { setWindow(Number(optsIn.seconds), optsIn.endOffset != null ? Number(optsIn.endOffset) : state.endOffset) } if (optsIn.playing != null) setPlaying(Boolean(optsIn.playing)) return { ok: true, afterSeconds: state.afterSeconds, endOffset: state.endOffset, playing: state.playing, preset: state.preset, } } /** * High-level “show these charts now” for QVAC / automation. * Opens board, pins charts, applies window/type, optional focus. * @param {{ * charts?: string[], * pin?: boolean, * boardOnly?: boolean, * focus?: string, * ts?: number, * preset?: string, * seconds?: number, * mode?: string, * filter?: string, * related?: boolean, * playing?: boolean, * }} optsIn */ function showCharts(optsIn = {}) { const charts = (Array.isArray(optsIn.charts) ? optsIn.charts : []) .map(String) .filter(Boolean) .slice(0, 24) if (optsIn.filter != null) setFilter(optsIn.filter) if (optsIn.preset || optsIn.seconds != null) { setTimeWindow({ preset: optsIn.preset, seconds: optsIn.seconds, playing: optsIn.playing, }) } else if (optsIn.playing != null) { setPlaying(Boolean(optsIn.playing)) } if (optsIn.pin !== false && charts.length) { setPinned(charts, { pin: true }) } if (optsIn.boardOnly != null) setBoardOnly(Boolean(optsIn.boardOnly)) else if (charts.length >= 1 && optsIn.pin !== false) setBoardOnly(true) if (optsIn.mode && charts.length) { for (const id of charts) setChartMode(id, optsIn.mode) } const focusId = optsIn.focus || charts[0] if (focusId) { if (optsIn.ts) focusChartAt(focusId, optsIn.ts) else { scrollToChart(focusId) if (optsIn.openFocus !== false && charts.length === 1) openFocus(focusId) } } if (optsIn.related && focusId) { showRelated(focusId).catch(() => {}) } return { ok: true, charts, pinned: [...state.pinned], boardOnly: state.boardOnly, focus: focusId || null, afterSeconds: state.afterSeconds, playing: state.playing, } } 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 = `Related to ${escapeHtml(seedLabel)}` 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 = '

No related charts found

' } 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 = `${escapeHtml(label)}${escapeHtml(row.reason)} · ${row.score.toFixed(1)}` 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 = `Pinned${state.pinned.size}` 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 = `${escapeHtml(sec.title)}${sec.count}` 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 , 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 = `

Pinned

${pinnedIds.length}drag to reorder` 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 then click Board 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 = `

${escapeHtml(meta.title || id)}

${escapeHtml(metricCardSubtitle(id, meta))}

` 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 = `${escapeHtml(row.label)}${formatVal(row.value)}${row.units ? ` ${escapeHtml(row.units)}` : ''}` 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 = `Correlations${escapeHtml(state.mcMethod)} · ${n}` 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 = '

No strongly changed metrics in this window

' } 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 = `${escapeHtml(row.info || row.id)}${pct} · ${escapeHtml(row.id)}` 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, // Agent / automation surface setPinned, setChartMode, setFilter, setTimeWindow, showCharts, togglePin, cycleChartType, } } /** @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, ''') } /** @param {string} s */ function escapeHtml(s) { return String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** @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 }} card * @param {string[]} dims * @param {Set} hidden */ function renderStatsHtml(card, dims, hidden, units = '') { const u = units ? ` ${escapeHtml(units)}` : '' 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 `${escapeHtml(d)}${formatVal(last)}${u}${formatVal(min)}${formatVal(avg)}${formatVal(max)}` }) .filter(Boolean) if (!rows.length) return '

No dimension stats

' return `${rows.join('')}
DimLastMinAvgMax
` }