2212 lines
75 KiB
JavaScript
2212 lines
75 KiB
JavaScript
/**
|
||
* 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,
|
||
/** 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)
|
||
}
|
||
|
||
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 ''
|
||
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
|
||
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
|
||
const unitSuffix = model.units ? ` ${model.units}` : ''
|
||
return (
|
||
show
|
||
.map((line) => {
|
||
const last = line.values[line.values.length - 1]
|
||
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
|
||
return `<span class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}">${escapeHtml(text)}</span>`
|
||
})
|
||
.join('') +
|
||
(extra ? `<span class="dim-chip-more muted">+${extra}</span>` : '')
|
||
)
|
||
}
|
||
|
||
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),
|
||
})
|
||
focus.setLegend(focusLegendHtml(model))
|
||
}
|
||
|
||
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')
|
||
}
|
||
|
||
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) {
|
||
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
|
||
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
|
||
const unitSuffix = model.units ? ` ${model.units}` : ''
|
||
const nextHtml =
|
||
show
|
||
.map((line) => {
|
||
const last = line.values[line.values.length - 1]
|
||
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
|
||
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
|
||
})
|
||
.join('') +
|
||
(extra
|
||
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
|
||
: '')
|
||
if (legend.dataset.sig !== nextHtml) {
|
||
legend.dataset.sig = nextHtml
|
||
legend.innerHTML = nextHtml
|
||
legend.querySelectorAll('[data-dim]').forEach((btn) => {
|
||
btn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation()
|
||
toggleDim(id, btn.getAttribute('data-dim') || '')
|
||
})
|
||
})
|
||
}
|
||
}
|
||
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}%`
|
||
}
|
||
|
||
function render() {
|
||
const catalog = opts.getCatalog() || {}
|
||
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
|
||
updateMeta()
|
||
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))
|
||
syncMcUi()
|
||
for (const id of state.cardEls.keys()) updateHighlightBand(id)
|
||
}
|
||
|
||
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()
|
||
render()
|
||
// Catalog just arrived or refreshed — pull history for anything already on screen
|
||
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, ''')
|
||
}
|
||
|
||
/** @param {string} s */
|
||
function escapeHtml(s) {
|
||
return String(s || '')
|
||
.replace(/&/g, '&')
|
||
.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<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>`
|
||
}
|