Update
This commit is contained in:
@@ -22,7 +22,8 @@ import {
|
||||
saveSettings,
|
||||
applySettingsToDom,
|
||||
} from './client/settings.js'
|
||||
import { drawMultiChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
||||
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
||||
import { defaultModeFromMeta } from './shared/chart-types.js'
|
||||
import { createMetricsDashboard } from './ui/dashboard.js'
|
||||
import {
|
||||
buildFleetRoster,
|
||||
@@ -266,7 +267,7 @@ function pushPeerCpu(peerId, value) {
|
||||
}
|
||||
|
||||
function paint(canvasId, lines, opts = {}) {
|
||||
drawMultiChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
|
||||
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
|
||||
}
|
||||
|
||||
function panelForChart(chart) {
|
||||
@@ -388,9 +389,12 @@ function redrawCpu() {
|
||||
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
|
||||
].filter((l) => l.values.length >= 2)
|
||||
if (!lines.length) {
|
||||
paint('chart-cpu', [{ values: series.cpu, color: CHART_PALETTE.cpuUser }], opts)
|
||||
paint('chart-cpu', [{ values: series.cpu, color: CHART_PALETTE.cpuUser }], {
|
||||
...opts,
|
||||
mode: 'area',
|
||||
})
|
||||
} else {
|
||||
paint('chart-cpu', lines, { ...opts, stacked: false })
|
||||
paint('chart-cpu', lines, { ...opts, mode: 'stacked' })
|
||||
}
|
||||
if (els.chartCpuLabel) els.chartCpuLabel.textContent = 'system.cpu'
|
||||
if (els.legendCpu) {
|
||||
@@ -405,38 +409,49 @@ function redrawAll() {
|
||||
applyAnomalyHighlights()
|
||||
redrawCpu()
|
||||
const ramChart = anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'
|
||||
paint('chart-ram', [{ values: series.ram, color: CHART_PALETTE.ram }], anomalyOptsFor(ramChart))
|
||||
paint(
|
||||
'chart-ram',
|
||||
[{ values: series.ram, color: CHART_PALETTE.ram }],
|
||||
{ ...anomalyOptsFor(ramChart), mode: 'area' }
|
||||
)
|
||||
paint(
|
||||
'chart-net',
|
||||
[
|
||||
{ values: series.net, color: CHART_PALETTE.net, label: 'rx', fill: false },
|
||||
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx', fill: false },
|
||||
{ values: series.net, color: CHART_PALETTE.net, label: 'rx' },
|
||||
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx' },
|
||||
].filter((l) => l.values.length >= 2).length
|
||||
? [
|
||||
{ values: series.net, color: CHART_PALETTE.net, fill: false },
|
||||
{ values: series.netTx, color: CHART_PALETTE.disk, fill: false },
|
||||
{ values: series.net, color: CHART_PALETTE.net },
|
||||
{ values: series.netTx, color: CHART_PALETTE.disk },
|
||||
]
|
||||
: [{ values: series.net, color: CHART_PALETTE.net }],
|
||||
anomalyOptsFor('system.net')
|
||||
{ ...anomalyOptsFor('system.net'), mode: 'area' }
|
||||
)
|
||||
paint(
|
||||
'chart-io',
|
||||
[
|
||||
{ values: series.io, color: CHART_PALETTE.disk, fill: false },
|
||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, fill: false },
|
||||
{ values: series.io, color: CHART_PALETTE.disk },
|
||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||
].filter((l) => l.values.length >= 2).length
|
||||
? [
|
||||
{ values: series.io, color: CHART_PALETTE.disk, fill: false },
|
||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, fill: false },
|
||||
{ values: series.io, color: CHART_PALETTE.disk },
|
||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||
]
|
||||
: [{ values: series.io, color: CHART_PALETTE.disk }],
|
||||
anomalyOptsFor('system.io')
|
||||
{ ...anomalyOptsFor('system.io'), mode: 'area' }
|
||||
)
|
||||
paint(
|
||||
'chart-load',
|
||||
[{ values: series.load, color: CHART_PALETTE.load }],
|
||||
{ ...anomalyOptsFor('system.load'), mode: 'line' }
|
||||
)
|
||||
paint('chart-load', [{ values: series.load, color: CHART_PALETTE.load }], anomalyOptsFor('system.load'))
|
||||
paint(
|
||||
'chart-explore',
|
||||
[{ values: series.explore, color: CHART_PALETTE.explore }],
|
||||
anomalyOptsFor(exploreChartId)
|
||||
{
|
||||
...anomalyOptsFor(exploreChartId),
|
||||
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
|
||||
}
|
||||
)
|
||||
if (selectedCatalogChart && series.detail.length) {
|
||||
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
|
||||
@@ -854,8 +869,8 @@ async function selectCatalogChart(id) {
|
||||
if (i === 0) series.detail = values.slice()
|
||||
}
|
||||
paint('chart-detail', lines, {
|
||||
stacked: String(meta.chartType || '').toLowerCase() === 'stacked',
|
||||
showYAxis: true,
|
||||
mode: defaultModeFromMeta(meta),
|
||||
showYAxis: defaultModeFromMeta(meta) !== 'pie',
|
||||
})
|
||||
} else {
|
||||
for (const row of q.data || []) pushPoint('detail', row[1] ?? 0)
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ Do not name third-party products in code, commits, or user-facing copy.
|
||||
- [x] Pan / zoom / reset on a card (updates shared window)
|
||||
- [x] Synced crosshair / shared hover time across visible cards
|
||||
- [x] Dimension show/hide + sort by name / latest value
|
||||
- [x] Chart type switch (line / area / stacked) where units allow
|
||||
- [x] Chart type switch (line / area / stacked / bar / multibar / pie) — catalog default + sensible cycle
|
||||
- [x] Resize card height; persist prefs
|
||||
- [x] Section overview KPI strip (latest values) above detail charts
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Chart display modes — how a metrics card should paint its series.
|
||||
*
|
||||
* Catalog agents emit chartType: line | area | stacked.
|
||||
* The desktop can also render bar / multibar / pie where they make sense.
|
||||
*/
|
||||
|
||||
/** @typedef {'line'|'area'|'stacked'|'bar'|'multibar'|'pie'} ChartMode */
|
||||
|
||||
/** @type {ChartMode[]} */
|
||||
export const CHART_MODES = ['line', 'area', 'stacked', 'bar', 'multibar', 'pie']
|
||||
|
||||
/** Short labels for the type toggle button */
|
||||
export const CHART_MODE_LABEL = {
|
||||
line: 'line',
|
||||
area: 'area',
|
||||
stacked: 'stack',
|
||||
bar: 'bar',
|
||||
multibar: 'bars',
|
||||
pie: 'pie',
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {ChartMode}
|
||||
*/
|
||||
export function normalizeChartMode(raw) {
|
||||
const m = String(raw || 'line')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_-]/g, '')
|
||||
if (m === 'multibar' || m === 'bargroup') return 'multibar'
|
||||
if (m === 'stack') return 'stacked'
|
||||
if (CHART_MODES.includes(/** @type {ChartMode} */ (m))) {
|
||||
return /** @type {ChartMode} */ (m)
|
||||
}
|
||||
return 'line'
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mode from catalog metadata.
|
||||
* @param {object} [meta]
|
||||
* @returns {ChartMode}
|
||||
*/
|
||||
export function defaultModeFromMeta(meta = {}) {
|
||||
return normalizeChartMode(meta.chartType || meta.chart_type || 'line')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [meta]
|
||||
*/
|
||||
function dimCount(meta = {}) {
|
||||
const dims = meta.dimensions
|
||||
if (Array.isArray(dims)) return dims.length
|
||||
if (dims && typeof dims === 'object') return Object.keys(dims).length
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Composition charts (parts of a whole) — pie / stacked are natural.
|
||||
* @param {object} [meta]
|
||||
*/
|
||||
export function isCompositionChart(meta = {}) {
|
||||
const mode = defaultModeFromMeta(meta)
|
||||
if (mode === 'stacked') return true
|
||||
const units = String(meta.units || '')
|
||||
if (/%|percentage|percent/i.test(units)) return true
|
||||
const id = String(meta.id || meta.name || '')
|
||||
// Common composition contexts
|
||||
if (/\.(cpu|ram|swap|slab|kernel|states|usage)$/i.test(id)) return true
|
||||
if (/^(system\.cpu|system\.ram|mem\.(swap|kernel|slab))/i.test(id)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes that make sense for this chart (catalog default first).
|
||||
* @param {object} [meta]
|
||||
* @returns {ChartMode[]}
|
||||
*/
|
||||
export function allowedModesFor(meta = {}) {
|
||||
const preferred = defaultModeFromMeta(meta)
|
||||
const n = dimCount(meta)
|
||||
const composition = isCompositionChart(meta)
|
||||
/** @type {Set<ChartMode>} */
|
||||
const set = new Set(['line', 'area'])
|
||||
|
||||
if (n !== 1) set.add('stacked')
|
||||
set.add('bar')
|
||||
if (n === 0 || (n >= 2 && n <= 16)) set.add('multibar')
|
||||
if (composition || preferred === 'stacked') set.add('pie')
|
||||
|
||||
// Always allow the catalog default
|
||||
set.add(preferred)
|
||||
|
||||
const order = /** @type {ChartMode[]} */ ([
|
||||
preferred,
|
||||
...CHART_MODES.filter((m) => m !== preferred && set.has(m)),
|
||||
])
|
||||
return order
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle to the next allowed mode.
|
||||
* @param {ChartMode|string} current
|
||||
* @param {object} [meta]
|
||||
* @returns {ChartMode}
|
||||
*/
|
||||
export function nextChartMode(current, meta = {}) {
|
||||
const allowed = allowedModesFor(meta)
|
||||
const cur = normalizeChartMode(current)
|
||||
const idx = allowed.indexOf(cur)
|
||||
return allowed[(idx < 0 ? 0 : idx + 1) % allowed.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Human hint for tooltips / empty states.
|
||||
* @param {ChartMode|string} mode
|
||||
*/
|
||||
export function chartModeHint(mode) {
|
||||
switch (normalizeChartMode(mode)) {
|
||||
case 'area':
|
||||
return 'Filled time series — good for volume and rates'
|
||||
case 'stacked':
|
||||
return 'Stacked over time — parts of a whole'
|
||||
case 'bar':
|
||||
return 'Columns over time — compare magnitude per sample'
|
||||
case 'multibar':
|
||||
return 'Grouped bars — compare dimensions side by side'
|
||||
case 'pie':
|
||||
return 'Snapshot share — latest values as a whole'
|
||||
default:
|
||||
return 'Lines over time — independent series'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'brittle'
|
||||
import {
|
||||
allowedModesFor,
|
||||
defaultModeFromMeta,
|
||||
nextChartMode,
|
||||
normalizeChartMode,
|
||||
isCompositionChart,
|
||||
} from '../shared/chart-types.js'
|
||||
|
||||
test('normalizeChartMode aliases', (t) => {
|
||||
t.is(normalizeChartMode('STACK'), 'stacked')
|
||||
t.is(normalizeChartMode('multi-bar'), 'multibar')
|
||||
t.is(normalizeChartMode('nope'), 'line')
|
||||
})
|
||||
|
||||
test('defaultModeFromMeta respects catalog', (t) => {
|
||||
t.is(defaultModeFromMeta({ chartType: 'area' }), 'area')
|
||||
t.is(defaultModeFromMeta({ chart_type: 'stacked' }), 'stacked')
|
||||
t.is(defaultModeFromMeta({}), 'line')
|
||||
})
|
||||
|
||||
test('composition charts get pie in allowed modes', (t) => {
|
||||
const meta = {
|
||||
id: 'system.cpu',
|
||||
chartType: 'stacked',
|
||||
units: 'percentage',
|
||||
dimensions: ['user', 'system', 'idle'],
|
||||
}
|
||||
t.ok(isCompositionChart(meta))
|
||||
const allowed = allowedModesFor(meta)
|
||||
t.ok(allowed.includes('pie'))
|
||||
t.ok(allowed.includes('stacked'))
|
||||
t.is(allowed[0], 'stacked')
|
||||
})
|
||||
|
||||
test('line charts cycle through sensible modes', (t) => {
|
||||
const meta = { chartType: 'line', dimensions: ['a', 'b'] }
|
||||
let mode = 'line'
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < 8; i++) {
|
||||
mode = nextChartMode(mode, meta)
|
||||
seen.add(mode)
|
||||
}
|
||||
t.ok(seen.has('area'))
|
||||
t.ok(seen.has('bar'))
|
||||
t.absent(seen.has('pie'))
|
||||
})
|
||||
+378
-71
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Canvas chart helpers — multi-series lines / stacked areas for the desktop.
|
||||
* Canvas chart helpers — line / area / stacked / bar / multibar / pie.
|
||||
*/
|
||||
import { normalizeChartMode } from '../shared/chart-types.js'
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement|null} canvas
|
||||
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
|
||||
* @param {{
|
||||
* mode?: string,
|
||||
* threshold?: number|null,
|
||||
* severity?: string|null,
|
||||
* stacked?: boolean,
|
||||
@@ -19,6 +21,25 @@
|
||||
* dimmed?: boolean,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function drawChart(canvas, lines, opts = {}) {
|
||||
const mode = normalizeChartMode(
|
||||
opts.mode || (opts.stacked ? 'stacked' : undefined) || 'line'
|
||||
)
|
||||
if (mode === 'pie') return drawPieChart(canvas, lines, opts)
|
||||
if (mode === 'bar') return drawBarChart(canvas, lines, { ...opts, grouped: false })
|
||||
if (mode === 'multibar') return drawBarChart(canvas, lines, { ...opts, grouped: true })
|
||||
return drawMultiChart(canvas, lines, {
|
||||
...opts,
|
||||
stacked: mode === 'stacked',
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement|null} canvas
|
||||
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
|
||||
* @param {object} [opts]
|
||||
*/
|
||||
export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
@@ -31,80 +52,315 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line'))
|
||||
const maxPoints = opts.maxPoints || 90
|
||||
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
|
||||
const padR = 8
|
||||
const padT = 6
|
||||
const padB = 6
|
||||
const units = opts.units ? String(opts.units) : ''
|
||||
const padL = opts.padLeft ?? (opts.showYAxis !== false ? (units.length > 6 ? 52 : 46) : 0)
|
||||
const padR = 10
|
||||
const padT = 12
|
||||
const padB = 10
|
||||
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 = padT + (plotH / 4) * i
|
||||
ctx.beginPath()
|
||||
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)
|
||||
const prepared = prepareSeries(lines, maxPoints)
|
||||
if (!prepared.length) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.55)'
|
||||
ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif'
|
||||
const msg = opts.emptyMessage || 'No data'
|
||||
const linesMsg = String(msg).split('\n')
|
||||
linesMsg.forEach((line, i) => {
|
||||
ctx.fillText(line, padL + 8, padT + 20 + i * 16)
|
||||
})
|
||||
drawEmpty(ctx, padL, padT, opts.emptyMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (opts.dimmed) ctx.globalAlpha = 0.72
|
||||
|
||||
const stacked = mode === 'stacked' || opts.stacked === true
|
||||
const { min, max, span } = computeScale(prepared, { stacked, threshold: opts.threshold })
|
||||
const xAt = (i, len) => padL + (i / Math.max(1, Math.max(maxPoints, len) - 1)) * plotW
|
||||
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
|
||||
|
||||
drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt)
|
||||
if (opts.showYAxis !== false) drawYAxis(ctx, padL, padT, plotH, min, max, span, units)
|
||||
|
||||
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
|
||||
drawThreshold(ctx, padL, plotW, yAt(opts.threshold), opts.severity)
|
||||
}
|
||||
|
||||
if (stacked) {
|
||||
drawStacked(ctx, prepared, xAt, yAt, maxPoints)
|
||||
} else {
|
||||
prepared.forEach((line) => {
|
||||
const wantFill = mode === 'area' ? line.fill !== false : line.fill === true
|
||||
drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill)
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
|
||||
drawHoverCrosshair(ctx, prepared, opts.hoverIndex, xAt, yAt, padT, plotH, maxPoints)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series columns (bar) or grouped columns (multibar).
|
||||
* @param {HTMLCanvasElement|null} canvas
|
||||
* @param {Array<{ values: number[], color: string, label?: string, hidden?: boolean }>} lines
|
||||
* @param {object} [opts]
|
||||
*/
|
||||
export function drawBarChart(canvas, lines, opts = {}) {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvas.clientWidth || 320
|
||||
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 units = opts.units ? String(opts.units) : ''
|
||||
const padL = opts.padLeft ?? (units.length > 6 ? 52 : 46)
|
||||
const padR = 10
|
||||
const padT = 12
|
||||
const padB = 10
|
||||
const plotW = Math.max(1, w - padL - padR)
|
||||
const plotH = Math.max(1, h - padT - padB)
|
||||
const grouped = Boolean(opts.grouped)
|
||||
|
||||
let prepared = prepareSeries(lines, maxPoints)
|
||||
// Multibar with many dims becomes unreadable — keep top series by latest |value|
|
||||
if (grouped && prepared.length > 6) {
|
||||
prepared = [...prepared]
|
||||
.sort((a, b) => Math.abs(last(b.values)) - Math.abs(last(a.values)))
|
||||
.slice(0, 6)
|
||||
}
|
||||
if (!prepared.length) {
|
||||
drawEmpty(ctx, padL, padT, opts.emptyMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (opts.dimmed) ctx.globalAlpha = 0.72
|
||||
|
||||
const stackedBars = !grouped && prepared.length > 1
|
||||
const { min, max, span } = computeScale(prepared, {
|
||||
stacked: stackedBars,
|
||||
threshold: opts.threshold,
|
||||
})
|
||||
const len = Math.max(...prepared.map((l) => l.values.length))
|
||||
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
|
||||
const y0 = yAt(Math.max(min, Math.min(max, 0)))
|
||||
|
||||
drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt)
|
||||
drawYAxis(ctx, padL, padT, plotH, min, max, span, units)
|
||||
|
||||
const slot = plotW / Math.max(1, len)
|
||||
const groupCount = grouped ? prepared.length : 1
|
||||
const barW = Math.max(1.5, Math.min(14, (slot * 0.72) / groupCount))
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const cx = padL + slot * i + slot / 2
|
||||
if (grouped) {
|
||||
prepared.forEach((line, gi) => {
|
||||
const v = line.values[i] ?? 0
|
||||
const x = cx - (groupCount * barW) / 2 + gi * barW
|
||||
const y = yAt(v)
|
||||
ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.75)
|
||||
roundRect(ctx, x, Math.min(y, y0), barW - 0.5, Math.abs(y0 - y), 2)
|
||||
ctx.fill()
|
||||
})
|
||||
} else if (stackedBars) {
|
||||
let acc = 0
|
||||
for (const line of prepared) {
|
||||
const v = line.values[i] ?? 0
|
||||
const y1 = yAt(acc)
|
||||
const y2 = yAt(acc + v)
|
||||
ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.8)
|
||||
roundRect(ctx, cx - barW / 2, Math.min(y1, y2), barW, Math.abs(y2 - y1), 1)
|
||||
ctx.fill()
|
||||
acc += v
|
||||
}
|
||||
} else {
|
||||
const line = prepared[0]
|
||||
const v = line.values[i] ?? 0
|
||||
const y = yAt(v)
|
||||
ctx.fillStyle = withAlpha(line.color, i === opts.hoverIndex ? 0.95 : 0.8)
|
||||
roundRect(ctx, cx - barW / 2, Math.min(y, y0), barW, Math.abs(y0 - y), 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
|
||||
const idx = clampIdx(opts.hoverIndex, len)
|
||||
const x = padL + slot * idx + slot / 2
|
||||
ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padT)
|
||||
ctx.lineTo(x, padT + plotH)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Donut / pie of the latest sample (composition snapshot).
|
||||
* @param {HTMLCanvasElement|null} canvas
|
||||
* @param {Array<{ values: number[], color: string, label?: string, hidden?: boolean }>} lines
|
||||
* @param {object} [opts]
|
||||
*/
|
||||
export function drawPieChart(canvas, lines, opts = {}) {
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvas.clientWidth || 320
|
||||
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 prepared = prepareSeries(lines, opts.maxPoints || 90)
|
||||
const slices = prepared
|
||||
.map((l) => ({
|
||||
label: l.label || '',
|
||||
color: l.color,
|
||||
value: Math.abs(last(l.values)),
|
||||
}))
|
||||
.filter((s) => s.value > 0)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
if (!slices.length) {
|
||||
drawEmpty(ctx, 12, 16, opts.emptyMessage || 'No data')
|
||||
return
|
||||
}
|
||||
|
||||
if (opts.dimmed) ctx.globalAlpha = 0.72
|
||||
|
||||
const total = slices.reduce((s, x) => s + x.value, 0) || 1
|
||||
const cx = w * 0.38
|
||||
const cy = h / 2
|
||||
const r = Math.min(w * 0.32, h * 0.42)
|
||||
const inner = r * 0.58
|
||||
let angle = -Math.PI / 2
|
||||
|
||||
for (const slice of slices) {
|
||||
const sweep = (slice.value / total) * Math.PI * 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, angle, angle + sweep, false)
|
||||
ctx.arc(cx, cy, inner, angle + sweep, angle, true)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = slice.color
|
||||
ctx.fill()
|
||||
angle += sweep
|
||||
}
|
||||
|
||||
// Center total
|
||||
ctx.fillStyle = 'rgba(244, 247, 251, 0.9)'
|
||||
ctx.font = '600 12px "Outfit", ui-sans-serif, system-ui, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(formatAxis(total), cx, cy - 6)
|
||||
if (opts.units) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.85)'
|
||||
ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace'
|
||||
ctx.fillText(String(opts.units).slice(0, 12), cx, cy + 10)
|
||||
}
|
||||
|
||||
// Side legend (top contributors)
|
||||
const lx = w * 0.62
|
||||
let ly = Math.max(14, cy - slices.slice(0, 6).length * 11)
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
for (const slice of slices.slice(0, 6)) {
|
||||
const pct = ((slice.value / total) * 100).toFixed(0)
|
||||
ctx.fillStyle = slice.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = 'rgba(200, 209, 223, 0.92)'
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, Menlo, monospace'
|
||||
const name = slice.label.length > 14 ? slice.label.slice(0, 13) + '…' : slice.label
|
||||
ctx.fillText(`${name} ${pct}%`, lx + 10, ly)
|
||||
ly += 16
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
/* ─── shared draw helpers ─── */
|
||||
|
||||
function prepareSeries(lines, maxPoints) {
|
||||
return (lines || [])
|
||||
.filter((l) => !l.hidden)
|
||||
.map((l) => ({ ...l, values: (l.values || []).slice(-maxPoints) }))
|
||||
.filter((l) => l.values.length >= 1)
|
||||
.filter((l) => l.values.some((v) => Number.isFinite(v)))
|
||||
}
|
||||
|
||||
function computeScale(prepared, { stacked, threshold }) {
|
||||
let max = 1
|
||||
let min = 0
|
||||
if (opts.stacked) {
|
||||
if (stacked) {
|
||||
const len = Math.max(...prepared.map((l) => l.values.length))
|
||||
for (let i = 0; i < len; i++) {
|
||||
let sum = 0
|
||||
for (const l of prepared) sum += l.values[i] ?? 0
|
||||
max = Math.max(max, sum)
|
||||
}
|
||||
min = 0
|
||||
} else {
|
||||
const all = prepared.flatMap((l) => l.values)
|
||||
max = Math.max(...all, 1e-9)
|
||||
min = Math.min(...all, 0)
|
||||
}
|
||||
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
|
||||
max = Math.max(max, opts.threshold)
|
||||
min = Math.min(min, opts.threshold)
|
||||
if (threshold != null && Number.isFinite(threshold)) {
|
||||
max = Math.max(max, threshold)
|
||||
min = Math.min(min, threshold)
|
||||
}
|
||||
if (max > 0) max *= 1.05
|
||||
const span = max - min || 1
|
||||
return { min, max, span }
|
||||
}
|
||||
|
||||
if (opts.showYAxis) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.65)'
|
||||
ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
|
||||
function drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) {
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padT + (plotH * i) / 4
|
||||
ctx.strokeStyle = i === 4 ? 'rgba(154, 168, 188, 0.18)' : 'rgba(154, 168, 188, 0.1)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y)
|
||||
ctx.lineTo(padL + plotW, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
if (min <= 0 && max > 0) {
|
||||
const y0 = yAt(0)
|
||||
ctx.strokeStyle = 'rgba(154, 168, 188, 0.32)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padL, y0)
|
||||
ctx.lineTo(padL + plotW, y0)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function drawYAxis(ctx, padL, padT, plotH, min, max, span, units) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.7)'
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, 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.fillText(formatAxis(v), padL - 5, y)
|
||||
}
|
||||
if (units) {
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'bottom'
|
||||
ctx.fillStyle = 'rgba(45, 212, 191, 0.75)'
|
||||
ctx.font = '9px "IBM Plex Mono", ui-monospace, Menlo, monospace'
|
||||
ctx.fillText(units.length > 14 ? units.slice(0, 13) + '…' : units, padL + 2, padT - 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
|
||||
const y = padT + plotH - ((opts.threshold - min) / span) * plotH
|
||||
function drawThreshold(ctx, padL, plotW, y, severity) {
|
||||
ctx.strokeStyle =
|
||||
opts.severity === 'critical' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(251, 191, 36, 0.8)'
|
||||
severity === 'critical' ? 'rgba(248, 113, 113, 0.85)' : 'rgba(251, 191, 36, 0.85)'
|
||||
ctx.setLineDash([5, 4])
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
@@ -114,10 +370,7 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
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) {
|
||||
function drawStacked(ctx, prepared, xAt, yAt, maxPoints) {
|
||||
const len = Math.max(...prepared.map((l) => l.values.length))
|
||||
/** @type {number[]} */
|
||||
const acc = new Array(len).fill(0)
|
||||
@@ -125,28 +378,56 @@ 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 = xAt(i, len)
|
||||
const x = xAt(i, Math.max(maxPoints, 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 = xAt(i, len)
|
||||
const y = yAt(v)
|
||||
ctx.lineTo(x, y)
|
||||
ctx.lineTo(xAt(i, Math.max(maxPoints, len)), yAt(acc[i]))
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = withAlpha(line.color, 0.55)
|
||||
ctx.fillStyle = withAlpha(line.color, 0.5)
|
||||
ctx.fill()
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = (line.values[i] ?? 0) + acc[i]
|
||||
const x = xAt(i, Math.max(maxPoints, len))
|
||||
const y = yAt(v)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.strokeStyle = withAlpha(line.color, 0.9)
|
||||
ctx.lineWidth = 1.25
|
||||
ctx.stroke()
|
||||
for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0
|
||||
}
|
||||
} else {
|
||||
prepared.forEach((line, li) => {
|
||||
}
|
||||
|
||||
function drawLineSeries(ctx, line, xAt, yAt, padT, plotH, wantFill) {
|
||||
const values = line.values
|
||||
const len = values.length
|
||||
if (wantFill) {
|
||||
const grad = ctx.createLinearGradient(0, padT, 0, padT + plotH)
|
||||
grad.addColorStop(0, withAlpha(line.color, 0.28))
|
||||
grad.addColorStop(1, withAlpha(line.color, 0.02))
|
||||
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.lineTo(xAt(len - 1, len), padT + plotH)
|
||||
ctx.lineTo(xAt(0, len), padT + plotH)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.strokeStyle = line.color
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
values.forEach((v, i) => {
|
||||
const x = xAt(i, len)
|
||||
@@ -155,21 +436,13 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (opts.hoverIndex != null && Number.isFinite(opts.hoverIndex)) {
|
||||
function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, maxPoints) {
|
||||
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)'
|
||||
const idx = clampIdx(hoverIndex, len)
|
||||
const x = xAt(idx, Math.max(maxPoints, len))
|
||||
ctx.strokeStyle = 'rgba(226, 232, 240, 0.4)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, padT)
|
||||
@@ -179,13 +452,45 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
||||
const v = line.values[idx]
|
||||
if (v == null) continue
|
||||
const y = yAt(v)
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(10, 12, 16, 0.85)'
|
||||
ctx.arc(x, y, 5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = line.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3.5, 0, Math.PI * 2)
|
||||
ctx.arc(x, y, 3.25, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
function drawEmpty(ctx, padL, padT, message) {
|
||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.5)'
|
||||
ctx.font = '12px "Outfit", ui-sans-serif, system-ui, sans-serif'
|
||||
String(message || 'No data')
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
ctx.fillText(line, padL + 8, padT + 18 + i * 16)
|
||||
})
|
||||
}
|
||||
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
const rr = Math.min(r, w / 2, h / 2)
|
||||
if (h < 0.5) return
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + rr, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, rr)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, rr)
|
||||
ctx.arcTo(x, y + h, x, y, rr)
|
||||
ctx.arcTo(x, y, x + w, y, rr)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function last(arr) {
|
||||
return arr?.length ? arr[arr.length - 1] : 0
|
||||
}
|
||||
|
||||
function clampIdx(idx, len) {
|
||||
return Math.max(0, Math.min(len - 1, Math.round(idx)))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,8 +502,8 @@ export function drawMultiChart(canvas, lines, 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 padL = opts.padLeft ?? (opts.showYAxis !== false ? 46 : 0)
|
||||
const padR = 10
|
||||
const plotW = Math.max(1, w - padL - padR)
|
||||
const x = clientX - rect.left - padL
|
||||
if (x < 0 || x > plotW) return null
|
||||
@@ -209,14 +514,16 @@ export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
|
||||
}
|
||||
|
||||
/** @param {number} v */
|
||||
function formatAxis(v) {
|
||||
export 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)
|
||||
if (a >= 100) return v.toFixed(0)
|
||||
if (a >= 10) return v.toFixed(1)
|
||||
if (a >= 1) return v.toFixed(2)
|
||||
if (a >= 0.01) return v.toFixed(3)
|
||||
return v.toFixed(4)
|
||||
}
|
||||
|
||||
/** @param {string} color @param {number} alpha */
|
||||
|
||||
+171
-33
@@ -3,7 +3,14 @@
|
||||
*/
|
||||
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
|
||||
import { rankRelatedCharts } from '../shared/related-metrics.js'
|
||||
import { drawMultiChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js'
|
||||
import {
|
||||
CHART_MODE_LABEL,
|
||||
chartModeHint,
|
||||
defaultModeFromMeta,
|
||||
nextChartMode,
|
||||
normalizeChartMode,
|
||||
} from '../shared/chart-types.js'
|
||||
import { drawChart, hoverIndexFromEvent, pushDim, seriesColor } from './charts.js'
|
||||
|
||||
const GROUPS = ['average', 'min', 'max', 'sum']
|
||||
|
||||
@@ -11,6 +18,23 @@ 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 {{
|
||||
@@ -109,15 +133,14 @@ export function createMetricsDashboard(opts) {
|
||||
|
||||
function ensureCard(id, meta = {}) {
|
||||
let card = state.cards.get(id)
|
||||
const defaultMode =
|
||||
String(meta.chartType || '').toLowerCase() === 'stacked' ? 'stacked' : 'line'
|
||||
const defaultMode = defaultModeFromMeta(meta)
|
||||
if (!card) {
|
||||
card = {
|
||||
dims: new Map(),
|
||||
labels: [],
|
||||
status: 'idle',
|
||||
meta: meta || {},
|
||||
mode: state.chartTypes.get(id) || defaultMode,
|
||||
mode: normalizeChartMode(state.chartTypes.get(id) || defaultMode),
|
||||
source: '',
|
||||
}
|
||||
state.cards.set(id, card)
|
||||
@@ -487,23 +510,25 @@ export function createMetricsDashboard(opts) {
|
||||
const height = cardHeightFor(id, card)
|
||||
canvas.style.height = `${height}px`
|
||||
const { dims, hidden } = sortedDims(card, id)
|
||||
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
|
||||
card.mode = mode
|
||||
const lines = []
|
||||
let i = 0
|
||||
for (const dim of dims) {
|
||||
const values = card.dims.get(dim) || []
|
||||
const mode = card.mode || 'line'
|
||||
lines.push({
|
||||
values,
|
||||
color: seriesColor(i),
|
||||
label: dim,
|
||||
hidden: hidden.has(dim),
|
||||
fill: mode === 'area' || mode === 'stacked' ? true : mode === 'line' ? false : undefined,
|
||||
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
|
||||
})
|
||||
i++
|
||||
}
|
||||
const anomaly = opts.getAnomaly?.(id) || null
|
||||
if (anomaly?.severity) article.dataset.severity = anomaly.severity
|
||||
else delete article.dataset.severity
|
||||
article.dataset.chartMode = mode
|
||||
const emptyMsg =
|
||||
card.status === 'loading'
|
||||
? card.emptyReason || 'Loading…'
|
||||
@@ -512,28 +537,40 @@ export function createMetricsDashboard(opts) {
|
||||
: card.status === 'empty'
|
||||
? card.emptyReason || 'No data'
|
||||
: 'No data'
|
||||
drawMultiChart(canvas, lines, {
|
||||
const units = card.meta?.units || ''
|
||||
drawChart(canvas, lines, {
|
||||
mode,
|
||||
maxPoints: maxPoints(),
|
||||
stacked: card.mode === 'stacked',
|
||||
showYAxis: true,
|
||||
hoverIndex: state.hoverIndex,
|
||||
threshold: anomaly?.threshold ?? null,
|
||||
showYAxis: mode !== 'pie',
|
||||
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
|
||||
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
|
||||
severity: anomaly?.severity || null,
|
||||
emptyMessage: emptyMsg,
|
||||
dimmed: Boolean(card.updating),
|
||||
units,
|
||||
})
|
||||
updateCardTooltip(article, id, card, lines, units)
|
||||
const statsEl = article.querySelector('.metric-card-stats')
|
||||
if (statsEl && article.classList.contains('expanded')) {
|
||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
|
||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units)
|
||||
}
|
||||
if (legend && !card.updating) {
|
||||
const nextHtml = lines
|
||||
const visibleLines = lines.filter((l) => !l.hidden)
|
||||
const show = lines.slice(0, LEGEND_CHIP_CAP)
|
||||
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP)
|
||||
const unitSuffix = units ? ` ${units}` : ''
|
||||
const nextHtml =
|
||||
show
|
||||
.map((line) => {
|
||||
const last = line.values[line.values.length - 1]
|
||||
const text = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
|
||||
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('')
|
||||
.join('') +
|
||||
(extra
|
||||
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
|
||||
: '')
|
||||
void visibleLines
|
||||
if (legend.dataset.sig !== nextHtml) {
|
||||
legend.dataset.sig = nextHtml
|
||||
legend.innerHTML = nextHtml
|
||||
@@ -547,6 +584,93 @@ export function createMetricsDashboard(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -575,13 +699,15 @@ export function createMetricsDashboard(opts) {
|
||||
function cycleChartType(id) {
|
||||
const card = state.cards.get(id)
|
||||
if (!card) return
|
||||
const order = ['line', 'area', 'stacked']
|
||||
const next = order[(order.indexOf(card.mode) + 1) % order.length]
|
||||
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 = next
|
||||
if (typeBtn) {
|
||||
typeBtn.textContent = CHART_MODE_LABEL[next] || next
|
||||
typeBtn.title = chartModeHint(next)
|
||||
}
|
||||
paintCard(id)
|
||||
}
|
||||
|
||||
@@ -662,7 +788,7 @@ export function createMetricsDashboard(opts) {
|
||||
}
|
||||
const frac = maxPoints() > 1 ? state.hoverIndex / (maxPoints() - 1) : 0
|
||||
const age = state.endOffset + state.afterSeconds * (1 - frac)
|
||||
opts.els.hoverReadout.textContent = `cursor · ~${formatDuration(Math.max(0, age))} ago`
|
||||
opts.els.hoverReadout.textContent = `~${formatDuration(Math.max(0, age))} ago`
|
||||
}
|
||||
|
||||
function bindCanvasInteractions(canvas, chartId) {
|
||||
@@ -686,6 +812,8 @@ export function createMetricsDashboard(opts) {
|
||||
canvas.addEventListener(
|
||||
'wheel',
|
||||
(ev) => {
|
||||
const card = state.cards.get(chartId)
|
||||
if (normalizeChartMode(card?.mode) === 'pie') return
|
||||
ev.preventDefault()
|
||||
const factor = ev.deltaY > 0 ? 1.25 : 0.8
|
||||
setWindow(Math.round(state.afterSeconds * factor), state.endOffset)
|
||||
@@ -695,6 +823,8 @@ 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
|
||||
// Drag to pan time (pause + shift window end)
|
||||
canvas.setPointerCapture(ev.pointerId)
|
||||
state.pan = { active: true, startX: ev.clientX, startOffset: state.endOffset }
|
||||
@@ -761,10 +891,8 @@ export function createMetricsDashboard(opts) {
|
||||
pinBtn.className = 'toc-item'
|
||||
pinBtn.innerHTML = `<span>Pinned</span><span class="toc-count">${state.pinned.size}</span>`
|
||||
pinBtn.addEventListener('click', () => {
|
||||
opts.els.wall?.querySelector('[data-section="pinned"]')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
const el = opts.els.wall?.querySelector('[data-section="pinned"]')
|
||||
if (el) smoothScrollIntoView(el, 'start')
|
||||
})
|
||||
toc.appendChild(pinBtn)
|
||||
}
|
||||
@@ -782,7 +910,7 @@ export function createMetricsDashboard(opts) {
|
||||
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' })
|
||||
if (el) smoothScrollIntoView(el, 'start')
|
||||
})
|
||||
toc.appendChild(btn)
|
||||
for (const g of sec.groups.slice(0, 12)) {
|
||||
@@ -799,7 +927,7 @@ export function createMetricsDashboard(opts) {
|
||||
(n) => n.getAttribute('data-family') === g.family
|
||||
)
|
||||
: null
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
if (el) smoothScrollIntoView(el, 'start')
|
||||
})
|
||||
toc.appendChild(sub)
|
||||
}
|
||||
@@ -996,7 +1124,7 @@ export function createMetricsDashboard(opts) {
|
||||
<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="Chart type">${escapeHtml(card.mode)}</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>
|
||||
<span class="metric-card-status muted"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1010,6 +1138,14 @@ export function createMetricsDashboard(opts) {
|
||||
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)
|
||||
@@ -1031,7 +1167,7 @@ export function createMetricsDashboard(opts) {
|
||||
if (article.classList.contains('expanded')) {
|
||||
statsEl.classList.remove('hidden')
|
||||
const { dims, hidden } = sortedDims(card, id)
|
||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
|
||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
|
||||
} else {
|
||||
statsEl.classList.add('hidden')
|
||||
}
|
||||
@@ -1231,7 +1367,7 @@ export function createMetricsDashboard(opts) {
|
||||
function scrollToChart(id) {
|
||||
const el = state.cardEls.get(id)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
smoothScrollIntoView(el, 'center')
|
||||
el.classList.add('flash')
|
||||
setTimeout(() => el.classList.remove('flash'), 1200)
|
||||
return
|
||||
@@ -1241,9 +1377,10 @@ export function createMetricsDashboard(opts) {
|
||||
render()
|
||||
requestAnimationFrame(() => {
|
||||
const again = state.cardEls.get(id)
|
||||
again?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
again?.classList.add('flash')
|
||||
setTimeout(() => again?.classList.remove('flash'), 1200)
|
||||
if (!again) return
|
||||
smoothScrollIntoView(again, 'center')
|
||||
again.classList.add('flash')
|
||||
setTimeout(() => again.classList.remove('flash'), 1200)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1336,7 +1473,8 @@ function formatDuration(sec) {
|
||||
* @param {string[]} dims
|
||||
* @param {Set<string>} hidden
|
||||
*/
|
||||
function renderStatsHtml(card, dims, 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) => {
|
||||
@@ -1352,7 +1490,7 @@ function renderStatsHtml(card, dims, hidden) {
|
||||
}
|
||||
const avg = sum / arr.length
|
||||
const last = arr[arr.length - 1]
|
||||
return `<tr><td>${escapeHtml(d)}</td><td>${formatVal(last)}</td><td>${formatVal(min)}</td><td>${formatVal(avg)}</td><td>${formatVal(max)}</td></tr>`
|
||||
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>'
|
||||
|
||||
+237
-6
@@ -148,6 +148,111 @@ body[data-reduce-motion='1'] *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* ─── Mini scrollbars ─── */
|
||||
:root {
|
||||
--scrollbar-size: 6px;
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.16);
|
||||
--scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 55%, rgba(255, 255, 255, 0.35));
|
||||
}
|
||||
html[data-theme='light'] {
|
||||
--scrollbar-thumb: rgba(15, 23, 42, 0.2);
|
||||
--scrollbar-thumb-hover: color-mix(in srgb, var(--accent-primary) 45%, rgba(15, 23, 42, 0.35));
|
||||
}
|
||||
|
||||
/* Main app pane — slim, styled */
|
||||
#content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
#content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
#content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px 0;
|
||||
}
|
||||
#content::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
min-height: 40px;
|
||||
}
|
||||
#content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
#content::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.metrics-wall,
|
||||
.metrics-toc-nav,
|
||||
.metrics-toc,
|
||||
.metric-card-stats,
|
||||
#sidebar,
|
||||
.fleet-cards,
|
||||
.event-list,
|
||||
.log,
|
||||
.invite-out {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.metrics-wall::-webkit-scrollbar,
|
||||
.metrics-toc-nav::-webkit-scrollbar,
|
||||
.metrics-toc::-webkit-scrollbar,
|
||||
.metric-card-stats::-webkit-scrollbar,
|
||||
#sidebar::-webkit-scrollbar,
|
||||
.fleet-cards::-webkit-scrollbar,
|
||||
.event-list::-webkit-scrollbar,
|
||||
.log::-webkit-scrollbar {
|
||||
width: var(--scrollbar-size);
|
||||
height: var(--scrollbar-size);
|
||||
}
|
||||
|
||||
.metrics-wall::-webkit-scrollbar-track,
|
||||
.metrics-toc-nav::-webkit-scrollbar-track,
|
||||
.metrics-toc::-webkit-scrollbar-track,
|
||||
.metric-card-stats::-webkit-scrollbar-track,
|
||||
#sidebar::-webkit-scrollbar-track,
|
||||
.fleet-cards::-webkit-scrollbar-track,
|
||||
.event-list::-webkit-scrollbar-track,
|
||||
.log::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.metrics-wall::-webkit-scrollbar-thumb,
|
||||
.metrics-toc-nav::-webkit-scrollbar-thumb,
|
||||
.metrics-toc::-webkit-scrollbar-thumb,
|
||||
.metric-card-stats::-webkit-scrollbar-thumb,
|
||||
#sidebar::-webkit-scrollbar-thumb,
|
||||
.fleet-cards::-webkit-scrollbar-thumb,
|
||||
.event-list::-webkit-scrollbar-thumb,
|
||||
.log::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.metrics-wall::-webkit-scrollbar-thumb:hover,
|
||||
.metrics-toc-nav::-webkit-scrollbar-thumb:hover,
|
||||
#sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metrics-wall {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
body[data-reduce-motion='1'] .metrics-wall {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* ─── Titlebar ─── */
|
||||
#titlebar {
|
||||
grid-area: titlebar;
|
||||
@@ -384,8 +489,17 @@ body.sidebar-collapsed #conn-meta {
|
||||
#content {
|
||||
grid-area: content;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--space-lg);
|
||||
padding-right: calc(var(--space-lg) - 2px);
|
||||
min-width: 0;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body[data-reduce-motion='1'] #content {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.view.hidden,
|
||||
@@ -397,6 +511,15 @@ body.sidebar-collapsed #conn-meta {
|
||||
animation: viewIn 0.28s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#content {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
.view {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes viewIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -569,7 +692,12 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
.chart-panel canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
html[data-theme='light'] .chart-panel canvas {
|
||||
background: rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
@@ -1055,8 +1183,10 @@ button.metrics-tf:disabled {
|
||||
.metrics-wall {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
padding-right: 2px;
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.metrics-empty {
|
||||
@@ -1066,6 +1196,7 @@ button.metrics-tf:disabled {
|
||||
|
||||
.metrics-section {
|
||||
margin-bottom: 28px;
|
||||
scroll-margin-top: 8px;
|
||||
}
|
||||
|
||||
.metrics-section.collapsed .metrics-section-body {
|
||||
@@ -1079,13 +1210,14 @@ button.metrics-tf:disabled {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 8px 0;
|
||||
padding: 10px 0 8px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-primary) 70%,
|
||||
color-mix(in srgb, var(--bg-primary) 92%, transparent) 55%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.metrics-section-head h2 {
|
||||
@@ -1134,7 +1266,32 @@ button.metrics-tf:disabled {
|
||||
var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 0;
|
||||
transition: border-color 0.15s ease, opacity 0.2s ease;
|
||||
scroll-margin-top: 52px;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
opacity 0.2s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 28%, var(--border-color));
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.metric-card.is-hovering {
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-color));
|
||||
}
|
||||
|
||||
.metric-card[data-chart-mode='pie'] canvas {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.metric-type-btn {
|
||||
text-transform: lowercase;
|
||||
min-width: 3.4rem !important;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.metric-card--hero {
|
||||
@@ -1227,6 +1384,80 @@ html[data-theme='light'] .metric-card canvas {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.dim-chip-more {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 3px 6px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.metric-card.is-hovering .dim-chip {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.metric-card.is-hovering .dim-chip:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chart-hover-tip {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 12px;
|
||||
z-index: 6;
|
||||
min-width: 140px;
|
||||
max-width: min(280px, 70%);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: var(--shadow-md);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
pointer-events: none;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chart-hover-tip.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chart-hover-tip header {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.chart-hover-tip .tip-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.chart-hover-tip .tip-row i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-hover-tip .tip-row span {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-hover-tip .tip-row strong {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-card.flash {
|
||||
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.55), 0 0 24px rgba(52, 211, 153, 0.18);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user