Updates
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 6m56s

This commit is contained in:
Raven Scott
2026-07-18 20:17:34 -04:00
parent 52a1823469
commit 41f5476a60
10 changed files with 1470 additions and 90 deletions
+154 -33
View File
@@ -4,13 +4,17 @@
/**
* @param {HTMLCanvasElement|null} canvas
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean }>} lines
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
* @param {{
* threshold?: number|null,
* severity?: string|null,
* stacked?: boolean,
* maxPoints?: number,
* grid?: boolean,
* hoverIndex?: number|null,
* padLeft?: number,
* showYAxis?: boolean,
* units?: string,
* }} [opts]
*/
export function drawMultiChart(canvas, lines, opts = {}) {
@@ -19,29 +23,42 @@ export function drawMultiChart(canvas, lines, opts = {}) {
if (!ctx) return
const dpr = window.devicePixelRatio || 1
const w = canvas.clientWidth || 320
const h = canvas.height || 140
const h = canvas.clientHeight || canvas.height || 140
canvas.width = w * dpr
canvas.height = h * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
const maxPoints = opts.maxPoints || 90
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
const padR = 8
const padT = 6
const padB = 6
const plotW = Math.max(1, w - padL - padR)
const plotH = Math.max(1, h - padT - padB)
if (opts.grid !== false) {
ctx.strokeStyle = 'rgba(154, 168, 188, 0.12)'
ctx.lineWidth = 1
for (let i = 1; i < 4; i++) {
const y = (h / 4) * i
const y = padT + (plotH / 4) * i
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
ctx.moveTo(padL, y)
ctx.lineTo(padL + plotW, y)
ctx.stroke()
}
}
const prepared = lines
.filter((l) => !l.hidden)
.map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) }))
.filter((l) => l.values.length >= 2)
if (!prepared.length) return
if (!prepared.length) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.45)'
ctx.font = '12px ui-sans-serif, system-ui, sans-serif'
ctx.fillText('No data', padL + 8, padT + 20)
return
}
let max = 1
let min = 0
@@ -54,7 +71,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
}
} else {
const all = prepared.flatMap((l) => l.values)
max = Math.max(...all, 1)
max = Math.max(...all, 1e-9)
min = Math.min(...all, 0)
}
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
@@ -63,19 +80,35 @@ export function drawMultiChart(canvas, lines, opts = {}) {
}
const span = max - min || 1
if (opts.showYAxis) {
ctx.fillStyle = 'rgba(154, 168, 188, 0.65)'
ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
for (let i = 0; i <= 4; i++) {
const v = max - (span * i) / 4
const y = padT + (plotH * i) / 4
ctx.fillText(formatAxis(v), padL - 4, y)
}
ctx.textAlign = 'left'
}
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
const y = h - ((opts.threshold - min) / span) * (h - 8) - 4
const y = padT + plotH - ((opts.threshold - min) / span) * plotH
ctx.strokeStyle =
opts.severity === 'critical' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(251, 191, 36, 0.8)'
ctx.setLineDash([5, 4])
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
ctx.moveTo(padL, y)
ctx.lineTo(padL + plotW, y)
ctx.stroke()
ctx.setLineDash([])
}
const xAt = (i, len) => padL + (i / Math.max(1, Math.max(maxPoints, len) - 1)) * plotW
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
if (opts.stacked) {
const len = Math.max(...prepared.map((l) => l.values.length))
/** @type {number[]} */
@@ -84,45 +117,109 @@ export function drawMultiChart(canvas, lines, opts = {}) {
ctx.beginPath()
for (let i = 0; i < len; i++) {
const v = (line.values[i] ?? 0) + acc[i]
const x = (i / Math.max(1, maxPoints - 1)) * w
const y = h - ((v - min) / span) * (h - 8) - 4
const x = xAt(i, len)
const y = yAt(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
for (let i = len - 1; i >= 0; i--) {
const v = acc[i]
const x = (i / Math.max(1, maxPoints - 1)) * w
const y = h - ((v - min) / span) * (h - 8) - 4
const x = xAt(i, len)
const y = yAt(v)
ctx.lineTo(x, y)
}
ctx.closePath()
ctx.fillStyle = line.color + (line.color.length === 7 ? '99' : '')
ctx.fillStyle = withAlpha(line.color, 0.55)
ctx.fill()
for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0
}
return
} else {
prepared.forEach((line, li) => {
const values = line.values
const len = values.length
ctx.strokeStyle = line.color
ctx.lineWidth = 2
ctx.beginPath()
values.forEach((v, i) => {
const x = xAt(i, len)
const y = yAt(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
if ((line.fill || (li === 0 && prepared.length === 1)) && line.fill !== false) {
ctx.lineTo(xAt(len - 1, len), padT + plotH)
ctx.lineTo(xAt(0, len), padT + plotH)
ctx.closePath()
ctx.fillStyle = withAlpha(line.color, 0.16)
ctx.fill()
}
})
}
prepared.forEach((line, li) => {
const values = line.values
ctx.strokeStyle = line.color
ctx.lineWidth = 2
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
const len = Math.max(...prepared.map((l) => l.values.length))
const idx = Math.max(0, Math.min(len - 1, Math.round(opts.hoverIndex)))
const x = xAt(idx, len)
ctx.strokeStyle = 'rgba(226, 232, 240, 0.45)'
ctx.lineWidth = 1
ctx.beginPath()
values.forEach((v, i) => {
const x = (i / Math.max(1, maxPoints - 1)) * w
const y = h - ((v - min) / span) * (h - 8) - 4
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.moveTo(x, padT)
ctx.lineTo(x, padT + plotH)
ctx.stroke()
if ((line.fill || (li === 0 && prepared.length === 1)) && line.fill !== false) {
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.closePath()
ctx.fillStyle = line.color.length === 7 ? line.color + '28' : line.color
for (const line of prepared) {
const v = line.values[idx]
if (v == null) continue
const y = yAt(v)
ctx.fillStyle = line.color
ctx.beginPath()
ctx.arc(x, y, 3.5, 0, Math.PI * 2)
ctx.fill()
}
})
}
}
/**
* Map pointer x → series index for a canvas using the same layout as drawMultiChart.
* @param {HTMLCanvasElement} canvas
* @param {number} clientX
* @param {{ maxPoints?: number, padLeft?: number, showYAxis?: boolean, seriesLen?: number }} [opts]
*/
export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
const rect = canvas.getBoundingClientRect()
const w = rect.width || canvas.clientWidth || 320
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
const padR = 8
const plotW = Math.max(1, w - padL - padR)
const x = clientX - rect.left - padL
if (x < 0 || x > plotW) return null
const maxPoints = opts.maxPoints || 90
const len = Math.max(2, opts.seriesLen || maxPoints)
const idx = Math.round((x / plotW) * (Math.max(maxPoints, len) - 1))
return Math.max(0, Math.min(len - 1, idx))
}
/** @param {number} v */
function formatAxis(v) {
const a = Math.abs(v)
if (a >= 1e9) return (v / 1e9).toFixed(1) + 'G'
if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M'
if (a >= 1e3) return (v / 1e3).toFixed(1) + 'k'
if (a >= 10) return v.toFixed(0)
if (a >= 1) return v.toFixed(1)
return v.toFixed(2)
}
/** @param {string} color @param {number} alpha */
function withAlpha(color, alpha) {
if (!color) return `rgba(154,168,188,${alpha})`
if (color.startsWith('#') && color.length === 7) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
return color
}
export const CHART_PALETTE = {
@@ -136,7 +233,15 @@ export const CHART_PALETTE = {
disk: '#fbbf24',
load: '#a78bfa',
explore: '#fb7185',
compare: ['#34d399', '#2dd4bf', '#38bdf8', '#fbbf24', '#fb7185'],
compare: ['#34d399', '#2dd4bf', '#38bdf8', '#fbbf24', '#fb7185', '#a78bfa', '#f472b6', '#94a3b8'],
}
/**
* @param {number} i
*/
export function seriesColor(i) {
const pal = CHART_PALETTE.compare
return pal[i % pal.length]
}
/**
@@ -148,3 +253,19 @@ export function pushRing(arr, value, max) {
arr.push(Number(value) || 0)
while (arr.length > max) arr.shift()
}
/**
* Push a value into a named dimension ring map.
* @param {Map<string, number[]>} dimMap
* @param {string} dim
* @param {number} value
* @param {number} max
*/
export function pushDim(dimMap, dim, value, max) {
let arr = dimMap.get(dim)
if (!arr) {
arr = []
dimMap.set(dim, arr)
}
pushRing(arr, value, max)
}
+520
View File
@@ -0,0 +1,520 @@
/**
* Master metrics dashboard — sectioned TOC + long-scroll chart wall.
*/
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
import { drawMultiChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js'
/**
* @typedef {{
* root: HTMLElement,
* toc: HTMLElement,
* wall: HTMLElement,
* search: HTMLInputElement|null,
* playBtn: HTMLButtonElement|null,
* presets: HTMLElement|null,
* meta: HTMLElement|null,
* hoverReadout: HTMLElement|null,
* }} DashboardEls
*/
/**
* @param {{
* els: DashboardEls,
* getCatalog: () => Record<string, object>,
* queryData: (args: object) => Promise<object>,
* getPoints: () => number,
* onSelectChart?: (id: string) => void,
* }} opts
*/
export function createMetricsDashboard(opts) {
const state = {
filter: '',
playing: true,
presetId: '5m',
afterSeconds: 300,
hoverIndex: /** @type {number|null} */ (null),
collapsed: /** @type {Set<string>} */ (new Set()),
hiddenDims: /** @type {Map<string, Set<string>>} */ (new Map()),
/** @type {Map<string, { dims: Map<string, number[]>, labels: string[], status: string, meta: object, stacked: boolean }>} */
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),
}
function points() {
const fromPreset = Math.max(30, Math.min(600, Math.round(state.afterSeconds)))
const setting = opts.getPoints?.() || 90
// Prefer denser of setting vs ~1Hz for the window, capped
return Math.min(600, Math.max(setting, Math.min(fromPreset, state.afterSeconds)))
}
function maxPoints() {
return points()
}
function ensureCard(id, meta = {}) {
let card = state.cards.get(id)
if (!card) {
card = {
dims: new Map(),
labels: [],
status: 'idle',
meta: meta || {},
stacked: String(meta.chartType || '').toLowerCase() === 'stacked',
}
state.cards.set(id, card)
} else if (meta && Object.keys(meta).length) {
card.meta = { ...card.meta, ...meta }
card.stacked = String(card.meta.chartType || '').toLowerCase() === 'stacked'
}
return card
}
function setPlaying(on) {
state.playing = Boolean(on)
if (opts.els.playBtn) {
opts.els.playBtn.textContent = state.playing ? 'Pause' : 'Play'
opts.els.playBtn.setAttribute('aria-pressed', state.playing ? 'true' : 'false')
}
}
function setPreset(id) {
const p = TIME_PRESETS.find((x) => x.id === id) || TIME_PRESETS[1]
state.presetId = p.id
state.afterSeconds = p.seconds
if (opts.els.presets) {
opts.els.presets.querySelectorAll('[data-preset]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-preset') === p.id)
})
}
scheduleRefetchVisible()
}
function scheduleRefetchVisible() {
if (state.refetchTimer) clearTimeout(state.refetchTimer)
state.refetchTimer = setTimeout(() => {
for (const id of state.visible) fetchChart(id).catch(() => {})
}, 80)
}
async function fetchChart(id) {
const catalog = opts.getCatalog() || {}
const meta = catalog[id] || state.cards.get(id)?.meta || {}
const card = ensureCard(id, meta)
card.status = 'loading'
paintCard(id)
try {
const q = await opts.queryData({
chart: id,
after: -state.afterSeconds,
points: points(),
})
const labels = Array.isArray(q.labels)
? q.labels.filter((l) => l && l !== 'time')
: []
// query rows: [time, d0, d1, ...] — discover dim names
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}`)
}
card.labels = dimNames
card.dims = new Map()
const max = maxPoints()
for (const name of dimNames) card.dims.set(name, [])
for (const row of q.data || []) {
for (let i = 0; i < dimNames.length; i++) {
const v = row[i + 1]
pushDim(card.dims, dimNames[i], Number(v) || 0, max)
}
}
card.status = card.dims.size ? 'ok' : 'empty'
} catch (err) {
card.status = 'error'
card.meta = { ...card.meta, error: err?.message || 'query failed' }
}
paintCard(id)
}
/**
* @param {object} meta
* @param {number} n
*/
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}`)
}
/**
* Live sample ingest for watched/visible charts.
* @param {Array<{ chart: string, values: Record<string, number> }>} samples
*/
function onSamples(samples) {
if (!state.playing || !state.built) return
const max = maxPoints()
let any = false
for (const s of samples || []) {
if (!s?.chart || !s.values) continue
if (!state.visible.has(s.chart) && !state.cards.has(s.chart)) 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'
paintCard(s.chart)
any = true
}
if (any) updateHoverReadout()
}
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')
if (statusEl) {
statusEl.textContent =
card?.status === 'loading'
? 'loading'
: card?.status === 'error'
? 'error'
: card?.status === 'empty'
? 'empty'
: ''
}
if (!card || !canvas) return
const hidden = state.hiddenDims.get(id) || new Set()
const lines = []
let i = 0
for (const dim of card.labels.length ? card.labels : [...card.dims.keys()]) {
const values = card.dims.get(dim) || []
lines.push({
values,
color: seriesColor(i),
label: dim,
hidden: hidden.has(dim),
fill: card.stacked ? true : undefined,
})
i++
}
drawMultiChart(canvas, lines, {
maxPoints: maxPoints(),
stacked: card.stacked,
showYAxis: true,
hoverIndex: state.hoverIndex,
})
if (legend) {
legend.innerHTML = ''
lines.forEach((line, idx) => {
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'dim-chip' + (line.hidden ? ' off' : '')
btn.style.setProperty('--dim-color', line.color)
const last = line.values[line.values.length - 1]
btn.textContent = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
btn.title = 'Toggle dimension'
btn.addEventListener('click', (ev) => {
ev.stopPropagation()
toggleDim(id, line.label)
})
legend.appendChild(btn)
void idx
})
}
}
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 updateHoverReadout() {
if (!opts.els.hoverReadout) return
if (state.hoverIndex == null) {
opts.els.hoverReadout.textContent = ''
return
}
opts.els.hoverReadout.textContent = `cursor · sample ${state.hoverIndex + 1}`
}
function bindCanvasHover(canvas) {
canvas.addEventListener('mousemove', (ev) => {
const idx = hoverIndexFromEvent(canvas, ev.clientX, {
maxPoints: maxPoints(),
showYAxis: true,
seriesLen: maxPoints(),
})
state.hoverIndex = idx
updateHoverReadout()
for (const id of state.visible) paintCard(id)
})
canvas.addEventListener('mouseleave', () => {
state.hoverIndex = null
updateHoverReadout()
for (const id of state.visible) paintCard(id)
})
}
function render() {
const catalog = opts.getCatalog() || {}
const { sections } = groupCatalog(catalog, state.filter)
renderToc(sections)
renderWall(sections)
state.built = true
if (opts.els.meta) {
const total = Object.keys(catalog).length
const shown = sections.reduce((n, s) => n + s.count, 0)
opts.els.meta.textContent = `${shown} / ${total} charts · ${state.afterSeconds}s window`
}
}
/**
* @param {ReturnType<typeof groupCatalog>['sections']} sections
*/
function renderToc(sections) {
const toc = opts.els.toc
if (!toc) return
toc.innerHTML = ''
if (!sections.length) {
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}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: '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
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
toc.appendChild(sub)
}
}
}
/**
* @param {ReturnType<typeof groupCatalog>['sections']} sections
*/
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()
if (!sections.length) {
const empty = document.createElement('div')
empty.className = 'metrics-empty muted'
empty.textContent = '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 }
)
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)
render()
})
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 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) {
ensureCard(id, meta)
const article = document.createElement('article')
article.className = 'metric-card'
article.dataset.chartId = id
article.innerHTML = `
<header class="metric-card-head">
<div>
<h4 class="metric-card-title">${escapeHtml(meta.title || id)}</h4>
<p class="metric-card-sub muted">${escapeHtml(id)}${meta.units ? ` · ${escapeHtml(meta.units)}` : ''}</p>
</div>
<span class="metric-card-status muted"></span>
</header>
<canvas height="160"></canvas>
<div class="metric-card-legend"></div>
`
const canvas = article.querySelector('canvas')
if (canvas) bindCanvasHover(canvas)
article.addEventListener('dblclick', () => opts.onSelectChart?.(id))
state.cardEls.set(id, article)
grid.appendChild(article)
state.observer.observe(article)
}
fam.appendChild(grid)
body.appendChild(fam)
}
sectionEl.appendChild(body)
wall.appendChild(sectionEl)
}
}
function bindChrome() {
opts.els.search?.addEventListener('input', () => {
state.filter = opts.els.search?.value || ''
render()
})
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'))
})
setPlaying(true)
setPreset(state.presetId)
}
function setCatalog(_catalog) {
render()
}
function redrawVisible() {
for (const id of state.visible) paintCard(id)
}
/** @param {string} id */
function scrollToChart(id) {
const el = state.cardEls.get(id)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('flash')
setTimeout(() => el.classList.remove('flash'), 1200)
return
}
// Chart may be filtered out — clear filter and re-render
state.filter = ''
if (opts.els.search) opts.els.search.value = ''
render()
requestAnimationFrame(() => {
const again = state.cardEls.get(id)
again?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}
bindChrome()
return {
render,
setCatalog,
onSamples,
setPlaying,
setPreset,
redrawVisible,
scrollToChart,
getState: () => state,
}
}
/** @param {string} s */
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** @param {number} v */
function formatVal(v) {
const a = Math.abs(v)
if (a >= 1e6) return (v / 1e6).toFixed(2) + 'M'
if (a >= 1e3) return (v / 1e3).toFixed(2) + 'k'
if (a >= 10) return v.toFixed(1)
return v.toFixed(2)
}
+260 -2
View File
@@ -656,7 +656,254 @@ body.is-offline .offline-banner:not(.hidden) {
opacity: 0.75;
}
/* ─── Charts browser ─── */
/* ─── Master metrics wall (Charts tab) ─── */
.metrics-header {
flex-wrap: wrap;
gap: 12px 20px;
}
.metrics-timebar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
.metrics-presets {
display: inline-flex;
gap: 4px;
padding: 2px;
border-radius: 10px;
background: var(--bg-elevated, rgba(255, 255, 255, 0.04));
}
.metrics-presets .ghost.active {
background: rgba(52, 211, 153, 0.16);
color: var(--text-primary);
}
.metrics-meta,
.metrics-hover {
font-size: 12px;
font-family: var(--font-mono);
}
.metrics-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: var(--space);
min-height: 480px;
height: calc(100vh - var(--titlebar-h) - 168px);
}
.metrics-toc {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: hidden;
}
.metrics-toc-nav {
display: flex;
flex-direction: column;
gap: 2px;
overflow: auto;
flex: 1;
min-height: 0;
padding-right: 2px;
}
.toc-item,
.toc-sub {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
width: 100%;
border: 0;
background: transparent;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
border-radius: 8px;
padding: 8px 10px;
font: inherit;
}
.toc-item:hover,
.toc-sub:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.toc-item {
font-weight: 600;
font-size: 13px;
}
.toc-sub {
padding-left: 18px;
font-size: 12px;
font-family: var(--font-mono);
opacity: 0.9;
}
.toc-count {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint);
}
.metrics-wall {
min-height: 0;
overflow: auto;
padding-right: 4px;
scroll-behavior: smooth;
}
.metrics-empty {
padding: 48px 24px;
text-align: center;
}
.metrics-section {
margin-bottom: 28px;
}
.metrics-section.collapsed .metrics-section-body {
display: none;
}
.metrics-section-head {
display: flex;
align-items: baseline;
gap: 10px;
position: sticky;
top: 0;
z-index: 2;
padding: 8px 0;
background: linear-gradient(
to bottom,
var(--bg-primary) 70%,
transparent
);
backdrop-filter: blur(6px);
}
.metrics-section-head h2 {
margin: 0;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.02em;
}
.section-toggle {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 14px;
padding: 0 4px;
}
.metrics-family {
margin-bottom: 16px;
}
.metrics-family-title {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px 10px;
border-radius: 14px;
background: var(--bg-card, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
min-height: 0;
}
.metric-card-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: flex-start;
}
.metric-card-title {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.metric-card-sub {
margin: 2px 0 0;
font-size: 11px;
font-family: var(--font-mono);
word-break: break-all;
}
.metric-card-status {
font-size: 10px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric-card canvas {
width: 100%;
height: 160px;
display: block;
border-radius: 8px;
}
.metric-card-legend {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 22px;
}
.dim-chip {
border: 0;
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
color: var(--text-secondary);
background: color-mix(in srgb, var(--dim-color, #94a3b8) 18%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dim-color, #94a3b8) 45%, transparent);
}
.dim-chip.off {
opacity: 0.35;
text-decoration: line-through;
}
.metric-card.flash {
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.55), 0 0 24px rgba(52, 211, 153, 0.18);
}
.metrics-detail {
margin-top: var(--space);
}
.charts-browser {
display: grid;
grid-template-columns: 280px 1fr;
@@ -1040,9 +1287,20 @@ code {
}
.charts-grid,
.fleet-layout,
.charts-browser {
.charts-browser,
.metrics-shell {
grid-template-columns: 1fr;
}
.metrics-shell {
height: auto;
max-height: none;
}
.metrics-toc {
max-height: 220px;
}
.metrics-wall {
max-height: 70vh;
}
}
@media (max-width: 720px) {