Update
This commit is contained in:
@@ -22,7 +22,8 @@ import {
|
|||||||
saveSettings,
|
saveSettings,
|
||||||
applySettingsToDom,
|
applySettingsToDom,
|
||||||
} from './client/settings.js'
|
} 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 { createMetricsDashboard } from './ui/dashboard.js'
|
||||||
import {
|
import {
|
||||||
buildFleetRoster,
|
buildFleetRoster,
|
||||||
@@ -266,7 +267,7 @@ function pushPeerCpu(peerId, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paint(canvasId, lines, opts = {}) {
|
function paint(canvasId, lines, opts = {}) {
|
||||||
drawMultiChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
|
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
|
||||||
}
|
}
|
||||||
|
|
||||||
function panelForChart(chart) {
|
function panelForChart(chart) {
|
||||||
@@ -388,9 +389,12 @@ function redrawCpu() {
|
|||||||
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
|
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
|
||||||
].filter((l) => l.values.length >= 2)
|
].filter((l) => l.values.length >= 2)
|
||||||
if (!lines.length) {
|
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 {
|
} 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.chartCpuLabel) els.chartCpuLabel.textContent = 'system.cpu'
|
||||||
if (els.legendCpu) {
|
if (els.legendCpu) {
|
||||||
@@ -405,38 +409,49 @@ function redrawAll() {
|
|||||||
applyAnomalyHighlights()
|
applyAnomalyHighlights()
|
||||||
redrawCpu()
|
redrawCpu()
|
||||||
const ramChart = anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'
|
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(
|
paint(
|
||||||
'chart-net',
|
'chart-net',
|
||||||
[
|
[
|
||||||
{ values: series.net, color: CHART_PALETTE.net, label: 'rx', fill: false },
|
{ values: series.net, color: CHART_PALETTE.net, label: 'rx' },
|
||||||
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx', fill: false },
|
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx' },
|
||||||
].filter((l) => l.values.length >= 2).length
|
].filter((l) => l.values.length >= 2).length
|
||||||
? [
|
? [
|
||||||
{ values: series.net, color: CHART_PALETTE.net, fill: false },
|
{ values: series.net, color: CHART_PALETTE.net },
|
||||||
{ values: series.netTx, color: CHART_PALETTE.disk, fill: false },
|
{ values: series.netTx, color: CHART_PALETTE.disk },
|
||||||
]
|
]
|
||||||
: [{ values: series.net, color: CHART_PALETTE.net }],
|
: [{ values: series.net, color: CHART_PALETTE.net }],
|
||||||
anomalyOptsFor('system.net')
|
{ ...anomalyOptsFor('system.net'), mode: 'area' }
|
||||||
)
|
)
|
||||||
paint(
|
paint(
|
||||||
'chart-io',
|
'chart-io',
|
||||||
[
|
[
|
||||||
{ values: series.io, color: CHART_PALETTE.disk, fill: false },
|
{ values: series.io, color: CHART_PALETTE.disk },
|
||||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, fill: false },
|
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||||
].filter((l) => l.values.length >= 2).length
|
].filter((l) => l.values.length >= 2).length
|
||||||
? [
|
? [
|
||||||
{ values: series.io, color: CHART_PALETTE.disk, fill: false },
|
{ values: series.io, color: CHART_PALETTE.disk },
|
||||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, fill: false },
|
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||||
]
|
]
|
||||||
: [{ values: series.io, color: CHART_PALETTE.disk }],
|
: [{ 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(
|
paint(
|
||||||
'chart-explore',
|
'chart-explore',
|
||||||
[{ values: series.explore, color: CHART_PALETTE.explore }],
|
[{ values: series.explore, color: CHART_PALETTE.explore }],
|
||||||
anomalyOptsFor(exploreChartId)
|
{
|
||||||
|
...anomalyOptsFor(exploreChartId),
|
||||||
|
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if (selectedCatalogChart && series.detail.length) {
|
if (selectedCatalogChart && series.detail.length) {
|
||||||
paint('chart-detail', [{ values: series.detail, color: CHART_PALETTE.cpuUser }])
|
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()
|
if (i === 0) series.detail = values.slice()
|
||||||
}
|
}
|
||||||
paint('chart-detail', lines, {
|
paint('chart-detail', lines, {
|
||||||
stacked: String(meta.chartType || '').toLowerCase() === 'stacked',
|
mode: defaultModeFromMeta(meta),
|
||||||
showYAxis: true,
|
showYAxis: defaultModeFromMeta(meta) !== 'pie',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
for (const row of q.data || []) pushPoint('detail', row[1] ?? 0)
|
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] Pan / zoom / reset on a card (updates shared window)
|
||||||
- [x] Synced crosshair / shared hover time across visible cards
|
- [x] Synced crosshair / shared hover time across visible cards
|
||||||
- [x] Dimension show/hide + sort by name / latest value
|
- [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] Resize card height; persist prefs
|
||||||
- [x] Section overview KPI strip (latest values) above detail charts
|
- [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'))
|
||||||
|
})
|
||||||
+435
-128
@@ -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 {HTMLCanvasElement|null} canvas
|
||||||
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
|
* @param {Array<{ values: number[], color: string, label?: string, fill?: boolean, hidden?: boolean }>} lines
|
||||||
* @param {{
|
* @param {{
|
||||||
|
* mode?: string,
|
||||||
* threshold?: number|null,
|
* threshold?: number|null,
|
||||||
* severity?: string|null,
|
* severity?: string|null,
|
||||||
* stacked?: boolean,
|
* stacked?: boolean,
|
||||||
@@ -19,6 +21,25 @@
|
|||||||
* dimmed?: boolean,
|
* dimmed?: boolean,
|
||||||
* }} [opts]
|
* }} [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 = {}) {
|
export function drawMultiChart(canvas, lines, opts = {}) {
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
@@ -31,161 +52,445 @@ export function drawMultiChart(canvas, lines, opts = {}) {
|
|||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
ctx.clearRect(0, 0, w, h)
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
const mode = normalizeChartMode(opts.mode || (opts.stacked ? 'stacked' : 'line'))
|
||||||
const maxPoints = opts.maxPoints || 90
|
const maxPoints = opts.maxPoints || 90
|
||||||
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
|
const units = opts.units ? String(opts.units) : ''
|
||||||
const padR = 8
|
const padL = opts.padLeft ?? (opts.showYAxis !== false ? (units.length > 6 ? 52 : 46) : 0)
|
||||||
const padT = 6
|
const padR = 10
|
||||||
const padB = 6
|
const padT = 12
|
||||||
|
const padB = 10
|
||||||
const plotW = Math.max(1, w - padL - padR)
|
const plotW = Math.max(1, w - padL - padR)
|
||||||
const plotH = Math.max(1, h - padT - padB)
|
const plotH = Math.max(1, h - padT - padB)
|
||||||
|
|
||||||
if (opts.grid !== false) {
|
const prepared = prepareSeries(lines, maxPoints)
|
||||||
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)
|
|
||||||
if (!prepared.length) {
|
if (!prepared.length) {
|
||||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.55)'
|
drawEmpty(ctx, padL, padT, opts.emptyMessage)
|
||||||
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)
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.dimmed) ctx.globalAlpha = 0.72
|
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 max = 1
|
||||||
let min = 0
|
let min = 0
|
||||||
if (opts.stacked) {
|
if (stacked) {
|
||||||
const len = Math.max(...prepared.map((l) => l.values.length))
|
const len = Math.max(...prepared.map((l) => l.values.length))
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
for (const l of prepared) sum += l.values[i] ?? 0
|
for (const l of prepared) sum += l.values[i] ?? 0
|
||||||
max = Math.max(max, sum)
|
max = Math.max(max, sum)
|
||||||
}
|
}
|
||||||
|
min = 0
|
||||||
} else {
|
} else {
|
||||||
const all = prepared.flatMap((l) => l.values)
|
const all = prepared.flatMap((l) => l.values)
|
||||||
max = Math.max(...all, 1e-9)
|
max = Math.max(...all, 1e-9)
|
||||||
min = Math.min(...all, 0)
|
min = Math.min(...all, 0)
|
||||||
}
|
}
|
||||||
if (opts.threshold != null && Number.isFinite(opts.threshold)) {
|
if (threshold != null && Number.isFinite(threshold)) {
|
||||||
max = Math.max(max, opts.threshold)
|
max = Math.max(max, threshold)
|
||||||
min = Math.min(min, opts.threshold)
|
min = Math.min(min, threshold)
|
||||||
}
|
}
|
||||||
|
if (max > 0) max *= 1.05
|
||||||
const span = max - min || 1
|
const span = max - min || 1
|
||||||
|
return { min, max, span }
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.showYAxis) {
|
function drawGrid(ctx, padL, padT, plotW, plotH, min, max, yAt) {
|
||||||
ctx.fillStyle = 'rgba(154, 168, 188, 0.65)'
|
for (let i = 0; i <= 4; i++) {
|
||||||
ctx.font = '10px ui-monospace, SFMono-Regular, Menlo, monospace'
|
const y = padT + (plotH * i) / 4
|
||||||
ctx.textAlign = 'right'
|
ctx.strokeStyle = i === 4 ? 'rgba(154, 168, 188, 0.18)' : 'rgba(154, 168, 188, 0.1)'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.lineWidth = 1
|
||||||
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 = 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.beginPath()
|
||||||
ctx.moveTo(padL, y)
|
ctx.moveTo(padL, y)
|
||||||
ctx.lineTo(padL + plotW, y)
|
ctx.lineTo(padL + plotW, y)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
ctx.setLineDash([])
|
|
||||||
}
|
}
|
||||||
|
if (min <= 0 && max > 0) {
|
||||||
const xAt = (i, len) => padL + (i / Math.max(1, Math.max(maxPoints, len) - 1)) * plotW
|
const y0 = yAt(0)
|
||||||
const yAt = (v) => padT + plotH - ((v - min) / span) * plotH
|
ctx.strokeStyle = 'rgba(154, 168, 188, 0.32)'
|
||||||
|
|
||||||
if (opts.stacked) {
|
|
||||||
const len = Math.max(...prepared.map((l) => l.values.length))
|
|
||||||
/** @type {number[]} */
|
|
||||||
const acc = new Array(len).fill(0)
|
|
||||||
for (const line of prepared) {
|
|
||||||
ctx.beginPath()
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const v = (line.values[i] ?? 0) + acc[i]
|
|
||||||
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 = xAt(i, len)
|
|
||||||
const y = yAt(v)
|
|
||||||
ctx.lineTo(x, y)
|
|
||||||
}
|
|
||||||
ctx.closePath()
|
|
||||||
ctx.fillStyle = withAlpha(line.color, 0.55)
|
|
||||||
ctx.fill()
|
|
||||||
for (let i = 0; i < len; i++) acc[i] += line.values[i] ?? 0
|
|
||||||
}
|
|
||||||
} 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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
ctx.beginPath()
|
||||||
ctx.moveTo(x, padT)
|
ctx.moveTo(padL, y0)
|
||||||
ctx.lineTo(x, padT + plotH)
|
ctx.lineTo(padL + plotW, y0)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1
|
}
|
||||||
|
|
||||||
|
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 - 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawThreshold(ctx, padL, plotW, y, severity) {
|
||||||
|
ctx.strokeStyle =
|
||||||
|
severity === 'critical' ? 'rgba(248, 113, 113, 0.85)' : 'rgba(251, 191, 36, 0.85)'
|
||||||
|
ctx.setLineDash([5, 4])
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(padL, y)
|
||||||
|
ctx.lineTo(padL + plotW, y)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
for (const line of prepared) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for (let i = len - 1; i >= 0; i--) {
|
||||||
|
ctx.lineTo(xAt(i, Math.max(maxPoints, len)), yAt(acc[i]))
|
||||||
|
}
|
||||||
|
ctx.closePath()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
const y = yAt(v)
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
})
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHoverCrosshair(ctx, prepared, hoverIndex, xAt, yAt, padT, plotH, maxPoints) {
|
||||||
|
const len = Math.max(...prepared.map((l) => l.values.length))
|
||||||
|
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)
|
||||||
|
ctx.lineTo(x, padT + plotH)
|
||||||
|
ctx.stroke()
|
||||||
|
for (const line of prepared) {
|
||||||
|
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.25, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {}) {
|
export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
|
||||||
const rect = canvas.getBoundingClientRect()
|
const rect = canvas.getBoundingClientRect()
|
||||||
const w = rect.width || canvas.clientWidth || 320
|
const w = rect.width || canvas.clientWidth || 320
|
||||||
const padL = opts.padLeft ?? (opts.showYAxis ? 44 : 0)
|
const padL = opts.padLeft ?? (opts.showYAxis !== false ? 46 : 0)
|
||||||
const padR = 8
|
const padR = 10
|
||||||
const plotW = Math.max(1, w - padL - padR)
|
const plotW = Math.max(1, w - padL - padR)
|
||||||
const x = clientX - rect.left - padL
|
const x = clientX - rect.left - padL
|
||||||
if (x < 0 || x > plotW) return null
|
if (x < 0 || x > plotW) return null
|
||||||
@@ -209,14 +514,16 @@ export function hoverIndexFromEvent(canvas, clientX, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param {number} v */
|
/** @param {number} v */
|
||||||
function formatAxis(v) {
|
export function formatAxis(v) {
|
||||||
const a = Math.abs(v)
|
const a = Math.abs(v)
|
||||||
if (a >= 1e9) return (v / 1e9).toFixed(1) + 'G'
|
if (a >= 1e9) return (v / 1e9).toFixed(1) + 'G'
|
||||||
if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M'
|
if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M'
|
||||||
if (a >= 1e3) return (v / 1e3).toFixed(1) + 'k'
|
if (a >= 1e3) return (v / 1e3).toFixed(1) + 'k'
|
||||||
if (a >= 10) return v.toFixed(0)
|
if (a >= 100) return v.toFixed(0)
|
||||||
if (a >= 1) return v.toFixed(1)
|
if (a >= 10) return v.toFixed(1)
|
||||||
return v.toFixed(2)
|
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 */
|
/** @param {string} color @param {number} alpha */
|
||||||
|
|||||||
+175
-37
@@ -3,7 +3,14 @@
|
|||||||
*/
|
*/
|
||||||
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
|
import { groupCatalog, TIME_PRESETS } from '../shared/taxonomy.js'
|
||||||
import { rankRelatedCharts } from '../shared/related-metrics.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']
|
const GROUPS = ['average', 'min', 'max', 'sum']
|
||||||
|
|
||||||
@@ -11,6 +18,23 @@ const MIN_WINDOW = 30
|
|||||||
const MAX_WINDOW = 21600
|
const MAX_WINDOW = 21600
|
||||||
const DEFAULT_HEIGHT = 148
|
const DEFAULT_HEIGHT = 148
|
||||||
const HERO_HEIGHT = 200
|
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 {{
|
* @typedef {{
|
||||||
@@ -109,15 +133,14 @@ export function createMetricsDashboard(opts) {
|
|||||||
|
|
||||||
function ensureCard(id, meta = {}) {
|
function ensureCard(id, meta = {}) {
|
||||||
let card = state.cards.get(id)
|
let card = state.cards.get(id)
|
||||||
const defaultMode =
|
const defaultMode = defaultModeFromMeta(meta)
|
||||||
String(meta.chartType || '').toLowerCase() === 'stacked' ? 'stacked' : 'line'
|
|
||||||
if (!card) {
|
if (!card) {
|
||||||
card = {
|
card = {
|
||||||
dims: new Map(),
|
dims: new Map(),
|
||||||
labels: [],
|
labels: [],
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
meta: meta || {},
|
meta: meta || {},
|
||||||
mode: state.chartTypes.get(id) || defaultMode,
|
mode: normalizeChartMode(state.chartTypes.get(id) || defaultMode),
|
||||||
source: '',
|
source: '',
|
||||||
}
|
}
|
||||||
state.cards.set(id, card)
|
state.cards.set(id, card)
|
||||||
@@ -487,23 +510,25 @@ export function createMetricsDashboard(opts) {
|
|||||||
const height = cardHeightFor(id, card)
|
const height = cardHeightFor(id, card)
|
||||||
canvas.style.height = `${height}px`
|
canvas.style.height = `${height}px`
|
||||||
const { dims, hidden } = sortedDims(card, id)
|
const { dims, hidden } = sortedDims(card, id)
|
||||||
|
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
|
||||||
|
card.mode = mode
|
||||||
const lines = []
|
const lines = []
|
||||||
let i = 0
|
let i = 0
|
||||||
for (const dim of dims) {
|
for (const dim of dims) {
|
||||||
const values = card.dims.get(dim) || []
|
const values = card.dims.get(dim) || []
|
||||||
const mode = card.mode || 'line'
|
|
||||||
lines.push({
|
lines.push({
|
||||||
values,
|
values,
|
||||||
color: seriesColor(i),
|
color: seriesColor(i),
|
||||||
label: dim,
|
label: dim,
|
||||||
hidden: hidden.has(dim),
|
hidden: hidden.has(dim),
|
||||||
fill: mode === 'area' || mode === 'stacked' ? true : mode === 'line' ? false : undefined,
|
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
|
||||||
})
|
})
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
const anomaly = opts.getAnomaly?.(id) || null
|
const anomaly = opts.getAnomaly?.(id) || null
|
||||||
if (anomaly?.severity) article.dataset.severity = anomaly.severity
|
if (anomaly?.severity) article.dataset.severity = anomaly.severity
|
||||||
else delete article.dataset.severity
|
else delete article.dataset.severity
|
||||||
|
article.dataset.chartMode = mode
|
||||||
const emptyMsg =
|
const emptyMsg =
|
||||||
card.status === 'loading'
|
card.status === 'loading'
|
||||||
? card.emptyReason || 'Loading…'
|
? card.emptyReason || 'Loading…'
|
||||||
@@ -512,28 +537,40 @@ export function createMetricsDashboard(opts) {
|
|||||||
: card.status === 'empty'
|
: card.status === 'empty'
|
||||||
? card.emptyReason || 'No data'
|
? card.emptyReason || 'No data'
|
||||||
: 'No data'
|
: 'No data'
|
||||||
drawMultiChart(canvas, lines, {
|
const units = card.meta?.units || ''
|
||||||
|
drawChart(canvas, lines, {
|
||||||
|
mode,
|
||||||
maxPoints: maxPoints(),
|
maxPoints: maxPoints(),
|
||||||
stacked: card.mode === 'stacked',
|
showYAxis: mode !== 'pie',
|
||||||
showYAxis: true,
|
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
|
||||||
hoverIndex: state.hoverIndex,
|
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
|
||||||
threshold: anomaly?.threshold ?? null,
|
|
||||||
severity: anomaly?.severity || null,
|
severity: anomaly?.severity || null,
|
||||||
emptyMessage: emptyMsg,
|
emptyMessage: emptyMsg,
|
||||||
dimmed: Boolean(card.updating),
|
dimmed: Boolean(card.updating),
|
||||||
|
units,
|
||||||
})
|
})
|
||||||
|
updateCardTooltip(article, id, card, lines, units)
|
||||||
const statsEl = article.querySelector('.metric-card-stats')
|
const statsEl = article.querySelector('.metric-card-stats')
|
||||||
if (statsEl && article.classList.contains('expanded')) {
|
if (statsEl && article.classList.contains('expanded')) {
|
||||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
|
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, units)
|
||||||
}
|
}
|
||||||
if (legend && !card.updating) {
|
if (legend && !card.updating) {
|
||||||
const nextHtml = lines
|
const visibleLines = lines.filter((l) => !l.hidden)
|
||||||
.map((line) => {
|
const show = lines.slice(0, LEGEND_CHIP_CAP)
|
||||||
const last = line.values[line.values.length - 1]
|
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP)
|
||||||
const text = `${line.label}${last != null ? ` ${formatVal(last)}` : ''}`
|
const unitSuffix = units ? ` ${units}` : ''
|
||||||
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>`
|
const nextHtml =
|
||||||
})
|
show
|
||||||
.join('')
|
.map((line) => {
|
||||||
|
const last = line.values[line.values.length - 1]
|
||||||
|
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
|
||||||
|
return `<button type="button" class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}" data-dim="${escapeAttr(line.label)}" title="Toggle dimension">${escapeHtml(text)}</button>`
|
||||||
|
})
|
||||||
|
.join('') +
|
||||||
|
(extra
|
||||||
|
? `<span class="dim-chip-more muted" title="${extra} more dimensions">+${extra}</span>`
|
||||||
|
: '')
|
||||||
|
void visibleLines
|
||||||
if (legend.dataset.sig !== nextHtml) {
|
if (legend.dataset.sig !== nextHtml) {
|
||||||
legend.dataset.sig = nextHtml
|
legend.dataset.sig = nextHtml
|
||||||
legend.innerHTML = 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) {
|
function cardHeightFor(id, card) {
|
||||||
const base = state.cardHeight || DEFAULT_HEIGHT
|
const base = state.cardHeight || DEFAULT_HEIGHT
|
||||||
if (state.pinned.has(id)) return Math.max(base, HERO_HEIGHT)
|
if (state.pinned.has(id)) return Math.max(base, HERO_HEIGHT)
|
||||||
@@ -575,13 +699,15 @@ export function createMetricsDashboard(opts) {
|
|||||||
function cycleChartType(id) {
|
function cycleChartType(id) {
|
||||||
const card = state.cards.get(id)
|
const card = state.cards.get(id)
|
||||||
if (!card) return
|
if (!card) return
|
||||||
const order = ['line', 'area', 'stacked']
|
const next = nextChartMode(card.mode, { ...card.meta, id, dimensions: card.labels })
|
||||||
const next = order[(order.indexOf(card.mode) + 1) % order.length]
|
|
||||||
card.mode = next
|
card.mode = next
|
||||||
state.chartTypes.set(id, next)
|
state.chartTypes.set(id, next)
|
||||||
persistPrefs({ chartTypes: Object.fromEntries(state.chartTypes) })
|
persistPrefs({ chartTypes: Object.fromEntries(state.chartTypes) })
|
||||||
const typeBtn = state.cardEls.get(id)?.querySelector('.metric-type-btn')
|
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)
|
paintCard(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +788,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
}
|
}
|
||||||
const frac = maxPoints() > 1 ? state.hoverIndex / (maxPoints() - 1) : 0
|
const frac = maxPoints() > 1 ? state.hoverIndex / (maxPoints() - 1) : 0
|
||||||
const age = state.endOffset + state.afterSeconds * (1 - frac)
|
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) {
|
function bindCanvasInteractions(canvas, chartId) {
|
||||||
@@ -686,6 +812,8 @@ export function createMetricsDashboard(opts) {
|
|||||||
canvas.addEventListener(
|
canvas.addEventListener(
|
||||||
'wheel',
|
'wheel',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
|
const card = state.cards.get(chartId)
|
||||||
|
if (normalizeChartMode(card?.mode) === 'pie') return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
const factor = ev.deltaY > 0 ? 1.25 : 0.8
|
const factor = ev.deltaY > 0 ? 1.25 : 0.8
|
||||||
setWindow(Math.round(state.afterSeconds * factor), state.endOffset)
|
setWindow(Math.round(state.afterSeconds * factor), state.endOffset)
|
||||||
@@ -695,6 +823,8 @@ export function createMetricsDashboard(opts) {
|
|||||||
canvas.addEventListener('pointerdown', (ev) => {
|
canvas.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0) return
|
if (ev.button !== 0) return
|
||||||
if (state.forcePlay) 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)
|
// Drag to pan time (pause + shift window end)
|
||||||
canvas.setPointerCapture(ev.pointerId)
|
canvas.setPointerCapture(ev.pointerId)
|
||||||
state.pan = { active: true, startX: ev.clientX, startOffset: state.endOffset }
|
state.pan = { active: true, startX: ev.clientX, startOffset: state.endOffset }
|
||||||
@@ -761,10 +891,8 @@ export function createMetricsDashboard(opts) {
|
|||||||
pinBtn.className = 'toc-item'
|
pinBtn.className = 'toc-item'
|
||||||
pinBtn.innerHTML = `<span>Pinned</span><span class="toc-count">${state.pinned.size}</span>`
|
pinBtn.innerHTML = `<span>Pinned</span><span class="toc-count">${state.pinned.size}</span>`
|
||||||
pinBtn.addEventListener('click', () => {
|
pinBtn.addEventListener('click', () => {
|
||||||
opts.els.wall?.querySelector('[data-section="pinned"]')?.scrollIntoView({
|
const el = opts.els.wall?.querySelector('[data-section="pinned"]')
|
||||||
behavior: 'smooth',
|
if (el) smoothScrollIntoView(el, 'start')
|
||||||
block: 'start',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
toc.appendChild(pinBtn)
|
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.innerHTML = `<span>${escapeHtml(sec.title)}</span><span class="toc-count">${sec.count}</span>`
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const el = opts.els.wall?.querySelector(`[data-section="${sec.id}"]`)
|
const el = opts.els.wall?.querySelector(`[data-section="${sec.id}"]`)
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
if (el) smoothScrollIntoView(el, 'start')
|
||||||
})
|
})
|
||||||
toc.appendChild(btn)
|
toc.appendChild(btn)
|
||||||
for (const g of sec.groups.slice(0, 12)) {
|
for (const g of sec.groups.slice(0, 12)) {
|
||||||
@@ -799,7 +927,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
(n) => n.getAttribute('data-family') === g.family
|
(n) => n.getAttribute('data-family') === g.family
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
if (el) smoothScrollIntoView(el, 'start')
|
||||||
})
|
})
|
||||||
toc.appendChild(sub)
|
toc.appendChild(sub)
|
||||||
}
|
}
|
||||||
@@ -996,7 +1124,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
<div class="metric-card-actions">
|
<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-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-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>
|
<span class="metric-card-status muted"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1010,6 +1138,14 @@ export function createMetricsDashboard(opts) {
|
|||||||
canvas.style.height = `${height}px`
|
canvas.style.height = `${height}px`
|
||||||
bindCanvasInteractions(canvas, id)
|
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) => {
|
article.querySelector('.metric-pin-btn')?.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
togglePin(id)
|
togglePin(id)
|
||||||
@@ -1031,7 +1167,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
if (article.classList.contains('expanded')) {
|
if (article.classList.contains('expanded')) {
|
||||||
statsEl.classList.remove('hidden')
|
statsEl.classList.remove('hidden')
|
||||||
const { dims, hidden } = sortedDims(card, id)
|
const { dims, hidden } = sortedDims(card, id)
|
||||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden)
|
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
|
||||||
} else {
|
} else {
|
||||||
statsEl.classList.add('hidden')
|
statsEl.classList.add('hidden')
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1367,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
function scrollToChart(id) {
|
function scrollToChart(id) {
|
||||||
const el = state.cardEls.get(id)
|
const el = state.cardEls.get(id)
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
smoothScrollIntoView(el, 'center')
|
||||||
el.classList.add('flash')
|
el.classList.add('flash')
|
||||||
setTimeout(() => el.classList.remove('flash'), 1200)
|
setTimeout(() => el.classList.remove('flash'), 1200)
|
||||||
return
|
return
|
||||||
@@ -1241,9 +1377,10 @@ export function createMetricsDashboard(opts) {
|
|||||||
render()
|
render()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const again = state.cardEls.get(id)
|
const again = state.cardEls.get(id)
|
||||||
again?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
if (!again) return
|
||||||
again?.classList.add('flash')
|
smoothScrollIntoView(again, 'center')
|
||||||
setTimeout(() => again?.classList.remove('flash'), 1200)
|
again.classList.add('flash')
|
||||||
|
setTimeout(() => again.classList.remove('flash'), 1200)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,7 +1473,8 @@ function formatDuration(sec) {
|
|||||||
* @param {string[]} dims
|
* @param {string[]} dims
|
||||||
* @param {Set<string>} hidden
|
* @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
|
const rows = dims
|
||||||
.filter((d) => !hidden.has(d))
|
.filter((d) => !hidden.has(d))
|
||||||
.map((d) => {
|
.map((d) => {
|
||||||
@@ -1352,7 +1490,7 @@ function renderStatsHtml(card, dims, hidden) {
|
|||||||
}
|
}
|
||||||
const avg = sum / arr.length
|
const avg = sum / arr.length
|
||||||
const last = arr[arr.length - 1]
|
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)
|
.filter(Boolean)
|
||||||
if (!rows.length) return '<p class="muted">No dimension stats</p>'
|
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;
|
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 ─── */
|
||||||
#titlebar {
|
#titlebar {
|
||||||
grid-area: titlebar;
|
grid-area: titlebar;
|
||||||
@@ -384,8 +489,17 @@ body.sidebar-collapsed #conn-meta {
|
|||||||
#content {
|
#content {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
|
padding-right: calc(var(--space-lg) - 2px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-reduce-motion='1'] #content {
|
||||||
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view.hidden,
|
.view.hidden,
|
||||||
@@ -397,6 +511,15 @@ body.sidebar-collapsed #conn-meta {
|
|||||||
animation: viewIn 0.28s ease;
|
animation: viewIn 0.28s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#content {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
.view {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes viewIn {
|
@keyframes viewIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -569,7 +692,12 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
.chart-panel canvas {
|
.chart-panel canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
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 {
|
.chart-legend {
|
||||||
@@ -1055,8 +1183,10 @@ button.metrics-tf:disabled {
|
|||||||
.metrics-wall {
|
.metrics-wall {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 4px;
|
padding-right: 2px;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-empty {
|
.metrics-empty {
|
||||||
@@ -1066,6 +1196,7 @@ button.metrics-tf:disabled {
|
|||||||
|
|
||||||
.metrics-section {
|
.metrics-section {
|
||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
|
scroll-margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-section.collapsed .metrics-section-body {
|
.metrics-section.collapsed .metrics-section-body {
|
||||||
@@ -1079,13 +1210,14 @@ button.metrics-tf:disabled {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: 8px 0;
|
padding: 10px 0 8px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
var(--bg-primary) 70%,
|
color-mix(in srgb, var(--bg-primary) 92%, transparent) 55%,
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-section-head h2 {
|
.metrics-section-head h2 {
|
||||||
@@ -1134,7 +1266,32 @@ button.metrics-tf:disabled {
|
|||||||
var(--bg-secondary);
|
var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
min-height: 0;
|
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 {
|
.metric-card--hero {
|
||||||
@@ -1227,6 +1384,80 @@ html[data-theme='light'] .metric-card canvas {
|
|||||||
text-decoration: line-through;
|
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 {
|
.metric-card.flash {
|
||||||
box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.55), 0 0 24px rgba(52, 211, 153, 0.18);
|
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