Metric Correlations
CI / test (push) Successful in 1m3s
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-18 22:43:42 -04:00
parent 5396d9d499
commit 93a239e8ef
11 changed files with 1258 additions and 31 deletions
+362 -6
View File
@@ -55,6 +55,13 @@ function smoothScrollIntoView(el, block = 'start') {
* 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
*/
@@ -118,6 +125,16 @@ export function createMetricsDashboard(opts) {
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,
}
function scheduleHoverPaint() {
@@ -532,10 +549,22 @@ export function createMetricsDashboard(opts) {
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')
}
}
if (!card || !canvas) return
const height = cardHeightFor(id, card)
const heightPx = `${height}px`
if (canvas.style.height !== heightPx) canvas.style.height = heightPx
updateHighlightBand(id)
const { dims, hidden } = sortedDims(card, id)
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
card.mode = mode
@@ -822,10 +851,11 @@ export function createMetricsDashboard(opts) {
}
function bindCanvasInteractions(canvas, chartId) {
canvas.title = 'Scroll to browse · Ctrl/⌘ or Shift + scroll to zoom · drag horizontally to pan'
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) return
if (state.pan?.active || state.mcBrush) return
const card = state.cards.get(chartId)
const seriesLen = seriesLenForCard(chartId)
const units = card?.meta?.units || ''
@@ -842,7 +872,7 @@ export function createMetricsDashboard(opts) {
scheduleHoverPaint()
})
canvas.addEventListener('mouseleave', () => {
if (state.pan?.active) return
if (state.pan?.active || state.mcBrush) return
state.hoverIndex = null
state.hoverSeriesLen = 0
updateHoverReadout()
@@ -867,9 +897,23 @@ export function createMetricsDashboard(opts) {
canvas.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0) return
if (state.forcePlay) 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,
@@ -886,6 +930,13 @@ export function createMetricsDashboard(opts) {
}
})
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
@@ -918,6 +969,18 @@ export function createMetricsDashboard(opts) {
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
@@ -933,6 +996,89 @@ export function createMetricsDashboard(opts) {
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)
@@ -940,6 +1086,9 @@ export function createMetricsDashboard(opts) {
// Board mode: only pinned charts
sections = []
}
if (state.mcResults?.length) {
sections = mcResultSections(catalog)
}
renderToc(sections)
renderWall(sections)
state.built = true
@@ -948,6 +1097,34 @@ export function createMetricsDashboard(opts) {
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) {
@@ -1202,7 +1379,11 @@ export function createMetricsDashboard(opts) {
<span class="metric-card-status muted"></span>
</div>
</header>
<canvas height="${height}"></canvas>
<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>
</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>
@@ -1377,6 +1558,12 @@ export function createMetricsDashboard(opts) {
}
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)
@@ -1386,6 +1573,168 @@ export function createMetricsDashboard(opts) {
state.endOffset = 0
syncPresetUi()
syncLiveUi()
syncMcUi()
}
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() {
@@ -1413,7 +1762,10 @@ export function createMetricsDashboard(opts) {
} else if (ev.key === 'b' || ev.key === 'B') {
setBoardOnly(!state.boardOnly)
} else if (ev.key === 'Escape') {
clearRelated()
if (state.mcResults || state.mcMode) clearMcResults()
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])
@@ -1506,6 +1858,10 @@ export function createMetricsDashboard(opts) {
showRelated,
clearRelated,
setBoardOnly,
setMcMode,
runMetricCorrelations,
clearMcResults,
correlateAround,
refreshRetention,
getState: () => state,
}
+87
View File
@@ -1137,6 +1137,92 @@ button.metrics-tf.thin-history:not(.active) {
scroll-behavior: auto;
}
.metrics-mc-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
margin: 0 0 8px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, var(--border-color));
background: color-mix(in srgb, var(--accent-primary) 8%, var(--bg-secondary));
flex-shrink: 0;
}
.metrics-mc-bar.hidden {
display: none;
}
.metrics-mc-hint {
flex: 1 1 180px;
font-size: 12px;
min-width: 0;
}
#metrics-correlate.active,
#charts-view.mc-mode #metrics-correlate {
background: rgba(52, 211, 153, 0.18);
color: var(--text-primary);
}
.metric-canvas-wrap {
position: relative;
width: 100%;
}
.metric-highlight-band {
position: absolute;
top: 0;
bottom: 0;
background: rgba(52, 211, 153, 0.16);
border-left: 1px solid rgba(52, 211, 153, 0.55);
border-right: 1px solid rgba(52, 211, 153, 0.55);
pointer-events: none;
z-index: 2;
}
.metric-highlight-band.hidden {
display: none;
}
.metric-weight-chip {
position: absolute;
top: 6px;
right: 8px;
z-index: 3;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
padding: 2px 6px;
border-radius: 6px;
background: rgba(52, 211, 153, 0.22);
color: var(--text-primary);
pointer-events: none;
}
.metric-weight-chip.hidden {
display: none;
}
.metric-card canvas.brushing {
cursor: col-resize;
}
#charts-view.mc-mode .metric-card canvas {
cursor: col-resize;
}
.mc-results-panel {
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
}
.anomaly-actions {
display: inline-flex;
gap: 8px;
margin-left: 8px;
}
.related-panel {
margin-bottom: var(--space);
padding: 12px 14px;
@@ -1463,6 +1549,7 @@ button.metrics-tf.thin-history:not(.active) {
letter-spacing: 0.04em;
}
.metric-canvas-wrap canvas,
.metric-card canvas {
width: 100%;
height: 148px;