Update
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
|||||||
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
||||||
import { defaultModeFromMeta } from './shared/chart-types.js'
|
import { defaultModeFromMeta } from './shared/chart-types.js'
|
||||||
import { createMetricsDashboard } from './ui/dashboard.js'
|
import { createMetricsDashboard } from './ui/dashboard.js'
|
||||||
|
import { getChartFocus } from './ui/chart-focus.js'
|
||||||
import {
|
import {
|
||||||
buildFleetRoster,
|
buildFleetRoster,
|
||||||
summarizeFleet,
|
summarizeFleet,
|
||||||
@@ -354,6 +355,172 @@ function paint(canvasId, lines, opts = {}) {
|
|||||||
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
|
drawChart($(canvasId), lines, { ...opts, maxPoints: seriesMax() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overviewFocus = getChartFocus()
|
||||||
|
|
||||||
|
/** @type {Array<{ id: string, title: string, subtitle: string, canvasId: string, lines: () => any[], opts: () => object }>} */
|
||||||
|
const OVERVIEW_FOCUS = [
|
||||||
|
{
|
||||||
|
id: 'system.cpu',
|
||||||
|
title: 'CPU',
|
||||||
|
subtitle: 'system.cpu',
|
||||||
|
canvasId: 'chart-cpu',
|
||||||
|
lines: () => overviewCpuLines().lines,
|
||||||
|
opts: () => ({ ...anomalyOptsFor('system.cpu'), ...overviewCpuLines().opts }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.ram',
|
||||||
|
title: 'Memory',
|
||||||
|
subtitle: 'system.ram',
|
||||||
|
canvasId: 'chart-ram',
|
||||||
|
lines: () => [{ values: series.ram, color: CHART_PALETTE.ram }],
|
||||||
|
opts: () => ({
|
||||||
|
...anomalyOptsFor(anomalyByChart.has('mem.available') ? 'mem.available' : 'system.ram'),
|
||||||
|
mode: 'area',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.net',
|
||||||
|
title: 'Network',
|
||||||
|
subtitle: 'system.net',
|
||||||
|
canvasId: 'chart-net',
|
||||||
|
lines: () =>
|
||||||
|
series.netTx.length >= 2
|
||||||
|
? [
|
||||||
|
{ values: series.net, color: CHART_PALETTE.net, label: 'rx' },
|
||||||
|
{ values: series.netTx, color: CHART_PALETTE.disk, label: 'tx' },
|
||||||
|
]
|
||||||
|
: [{ values: series.net, color: CHART_PALETTE.net, label: 'rx' }],
|
||||||
|
opts: () => ({ ...anomalyOptsFor('system.net'), mode: 'area' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.io',
|
||||||
|
title: 'Disk I/O',
|
||||||
|
subtitle: 'system.io',
|
||||||
|
canvasId: 'chart-io',
|
||||||
|
lines: () =>
|
||||||
|
series.ioWrite.length >= 2
|
||||||
|
? [
|
||||||
|
{ values: series.io, color: CHART_PALETTE.disk, label: 'read' },
|
||||||
|
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait, label: 'write' },
|
||||||
|
]
|
||||||
|
: [{ values: series.io, color: CHART_PALETTE.disk, label: 'io' }],
|
||||||
|
opts: () => ({ ...anomalyOptsFor('system.io'), mode: 'area' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'system.load',
|
||||||
|
title: 'Load',
|
||||||
|
subtitle: 'system.load',
|
||||||
|
canvasId: 'chart-load',
|
||||||
|
lines: () => [{ values: series.load, color: CHART_PALETTE.load }],
|
||||||
|
opts: () => ({ ...anomalyOptsFor('system.load'), mode: 'line' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'explore',
|
||||||
|
title: 'Spotlight',
|
||||||
|
subtitle: () => exploreChartId,
|
||||||
|
canvasId: 'chart-explore',
|
||||||
|
lines: () => [{ values: series.explore, color: CHART_PALETTE.explore }],
|
||||||
|
opts: () => ({
|
||||||
|
...anomalyOptsFor(exploreChartId),
|
||||||
|
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function overviewCpuLines() {
|
||||||
|
if (els.compareToggle?.checked && peerCpu.size > 0) {
|
||||||
|
const lines = [...peerCpu.entries()].slice(0, 5).map(([id, values], i) => ({
|
||||||
|
values,
|
||||||
|
color: CHART_PALETTE.compare[i % CHART_PALETTE.compare.length],
|
||||||
|
label: id.slice(0, 8),
|
||||||
|
fill: false,
|
||||||
|
}))
|
||||||
|
return { lines, opts: { mode: 'line' } }
|
||||||
|
}
|
||||||
|
const lines = [
|
||||||
|
{ values: series.cpuUser.length ? series.cpuUser : series.cpu, color: CHART_PALETTE.cpuUser, label: 'user', fill: false },
|
||||||
|
{ values: series.cpuSystem, color: CHART_PALETTE.cpuSystem, label: 'system', fill: false },
|
||||||
|
{ values: series.cpuIowait, color: CHART_PALETTE.cpuIowait, label: 'iowait', fill: false },
|
||||||
|
].filter((l) => l.values.length >= 2)
|
||||||
|
if (!lines.length) {
|
||||||
|
return {
|
||||||
|
lines: [{ values: series.cpu, color: CHART_PALETTE.cpuUser, label: 'used' }],
|
||||||
|
opts: { mode: 'area' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lines, opts: { mode: 'stacked' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOverviewFocus(focusId) {
|
||||||
|
const idx = OVERVIEW_FOCUS.findIndex((p) => p.id === focusId)
|
||||||
|
if (idx < 0) return
|
||||||
|
const openAt = (i) => {
|
||||||
|
const panel = OVERVIEW_FOCUS[(i + OVERVIEW_FOCUS.length) % OVERVIEW_FOCUS.length]
|
||||||
|
const sub =
|
||||||
|
typeof panel.subtitle === 'function' ? panel.subtitle() : panel.subtitle
|
||||||
|
overviewFocus.open({
|
||||||
|
id: panel.id,
|
||||||
|
title: panel.title,
|
||||||
|
subtitle: sub,
|
||||||
|
paint: (canvas) => {
|
||||||
|
drawChart(canvas, panel.lines(), { ...panel.opts(), maxPoints: seriesMax() })
|
||||||
|
},
|
||||||
|
onPrev: () => openAt(i - 1),
|
||||||
|
onNext: () => openAt(i + 1),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'charts',
|
||||||
|
label: 'Open in Charts',
|
||||||
|
title: 'Jump to Charts wall',
|
||||||
|
onClick: () => {
|
||||||
|
overviewFocus.close()
|
||||||
|
const chartId = panel.id === 'explore' ? exploreChartId : panel.id
|
||||||
|
showView('charts')
|
||||||
|
metricsDashboard.scrollToChart?.(chartId)
|
||||||
|
metricsDashboard.openFocus?.(chartId)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
overviewFocus.refresh()
|
||||||
|
}
|
||||||
|
openAt(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindOverviewFocus() {
|
||||||
|
const panels = [
|
||||||
|
[els.panelCpu, 'system.cpu'],
|
||||||
|
[els.panelRam, 'system.ram'],
|
||||||
|
[els.panelNet, 'system.net'],
|
||||||
|
[els.panelIo, 'system.io'],
|
||||||
|
[els.panelLoad, 'system.load'],
|
||||||
|
[els.panelExplore, 'explore'],
|
||||||
|
]
|
||||||
|
for (const [panel, id] of panels) {
|
||||||
|
if (!panel || panel.dataset.focusBound) continue
|
||||||
|
panel.dataset.focusBound = '1'
|
||||||
|
panel.classList.add('chart-panel--focusable')
|
||||||
|
if (!panel.querySelector('.chart-focus-btn')) {
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.type = 'button'
|
||||||
|
btn.className = 'btn btn-ghost chart-focus-btn'
|
||||||
|
btn.title = 'Fullscreen'
|
||||||
|
btn.setAttribute('aria-label', 'Fullscreen')
|
||||||
|
btn.textContent = '⛶'
|
||||||
|
const header = panel.querySelector('header')
|
||||||
|
header?.appendChild(btn)
|
||||||
|
btn.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
openOverviewFocus(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
panel.addEventListener('dblclick', (ev) => {
|
||||||
|
if (ev.target instanceof HTMLElement && ev.target.closest('select,button,a,label')) return
|
||||||
|
openOverviewFocus(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let overviewPaintRaf = 0
|
let overviewPaintRaf = 0
|
||||||
/** Coalesce Overview redraws into one frame to avoid spark blink. */
|
/** Coalesce Overview redraws into one frame to avoid spark blink. */
|
||||||
function scheduleRedrawAll() {
|
function scheduleRedrawAll() {
|
||||||
@@ -458,45 +625,21 @@ function anomalyOptsFor(chart) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function redrawCpu() {
|
function redrawCpu() {
|
||||||
const opts = anomalyOptsFor('system.cpu')
|
const { lines, opts } = overviewCpuLines()
|
||||||
|
paint('chart-cpu', lines, { ...anomalyOptsFor('system.cpu'), ...opts })
|
||||||
if (els.compareToggle?.checked && peerCpu.size > 0) {
|
if (els.compareToggle?.checked && peerCpu.size > 0) {
|
||||||
const lines = [...peerCpu.entries()].slice(0, 5).map(([id, values], i) => ({
|
|
||||||
values,
|
|
||||||
color: CHART_PALETTE.compare[i % CHART_PALETTE.compare.length],
|
|
||||||
label: id.slice(0, 8),
|
|
||||||
fill: false,
|
|
||||||
}))
|
|
||||||
paint('chart-cpu', lines, opts)
|
|
||||||
if (els.chartCpuLabel) els.chartCpuLabel.textContent = `compare · ${lines.length} peers`
|
if (els.chartCpuLabel) els.chartCpuLabel.textContent = `compare · ${lines.length} peers`
|
||||||
|
} else if (els.chartCpuLabel) {
|
||||||
|
els.chartCpuLabel.textContent = 'system.cpu'
|
||||||
|
}
|
||||||
if (els.legendCpu) {
|
if (els.legendCpu) {
|
||||||
els.legendCpu.innerHTML = lines
|
els.legendCpu.innerHTML = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
|
||||||
.map(
|
.map(
|
||||||
(l) =>
|
(l) =>
|
||||||
`<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>`
|
`<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const lines = [
|
|
||||||
{ values: series.cpuUser.length ? series.cpuUser : series.cpu, color: CHART_PALETTE.cpuUser, label: 'user', fill: false },
|
|
||||||
{ values: series.cpuSystem, color: CHART_PALETTE.cpuSystem, label: 'system', fill: false },
|
|
||||||
{ 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,
|
|
||||||
mode: 'area',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
paint('chart-cpu', lines, { ...opts, mode: 'stacked' })
|
|
||||||
}
|
|
||||||
if (els.chartCpuLabel) els.chartCpuLabel.textContent = 'system.cpu'
|
|
||||||
if (els.legendCpu) {
|
|
||||||
els.legendCpu.innerHTML = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
|
|
||||||
.map((l) => `<span style="--swatch:${l.color}">${l.label}</span>`)
|
|
||||||
.join('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function redrawAll() {
|
function redrawAll() {
|
||||||
@@ -511,10 +654,7 @@ function redrawAll() {
|
|||||||
)
|
)
|
||||||
paint(
|
paint(
|
||||||
'chart-net',
|
'chart-net',
|
||||||
[
|
series.netTx.length >= 2
|
||||||
{ 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 },
|
{ values: series.net, color: CHART_PALETTE.net },
|
||||||
{ values: series.netTx, color: CHART_PALETTE.disk },
|
{ values: series.netTx, color: CHART_PALETTE.disk },
|
||||||
@@ -524,10 +664,7 @@ function redrawAll() {
|
|||||||
)
|
)
|
||||||
paint(
|
paint(
|
||||||
'chart-io',
|
'chart-io',
|
||||||
[
|
series.ioWrite.length >= 2
|
||||||
{ 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 },
|
{ values: series.io, color: CHART_PALETTE.disk },
|
||||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||||
@@ -548,6 +685,11 @@ function redrawAll() {
|
|||||||
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
|
mode: defaultModeFromMeta(chartCatalog[exploreChartId] || { chartType: 'line' }),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// Live-update Overview focus only (Charts wall owns its own paint callback)
|
||||||
|
if (overviewFocus.isOpen()) {
|
||||||
|
const id = overviewFocus.currentId()
|
||||||
|
if (OVERVIEW_FOCUS.some((p) => p.id === id)) overviewFocus.refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActivePeerChip() {
|
function updateActivePeerChip() {
|
||||||
@@ -1470,6 +1612,7 @@ async function restorePeersOnBoot() {
|
|||||||
|
|
||||||
setOnline(false)
|
setOnline(false)
|
||||||
syncSettingsUi()
|
syncSettingsUi()
|
||||||
|
bindOverviewFocus()
|
||||||
showView(loadBookmarks().length ? 'fleet' : 'connect')
|
showView(loadBookmarks().length ? 'fleet' : 'connect')
|
||||||
renderBookmarks()
|
renderBookmarks()
|
||||||
renderPeers()
|
renderPeers()
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Do not name third-party products in code, commits, or user-facing copy.
|
|||||||
- [x] Related ranking boosted by alert weights when no MC window
|
- [x] Related ranking boosted by alert weights when no MC window
|
||||||
- [x] Drag-reorder pinned charts
|
- [x] Drag-reorder pinned charts
|
||||||
- [x] Keyboard shortcuts on Charts (`Space` play, `1–5` presets, `/` search, `f` force, `b` board, `c` correlate, `r` reset, `Esc` clear)
|
- [x] Keyboard shortcuts on Charts (`Space` play, `1–5` presets, `/` search, `f` force, `b` board, `c` correlate, `r` reset, `Esc` clear)
|
||||||
|
- [x] Fullscreen chart focus (Charts wall + Overview) — double-click / ⛶, `Esc` / `←` `→`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ Do not name third-party products in code, commits, or user-facing copy.
|
|||||||
| `shared/related-metrics.js` | Related-chart ranking (taxonomy + Pearson) |
|
| `shared/related-metrics.js` | Related-chart ranking (taxonomy + Pearson) |
|
||||||
| `server/services/weights.js` | Metric Correlations scoring (ks2 / volume / AR / value) |
|
| `server/services/weights.js` | Metric Correlations scoring (ks2 / volume / AR / value) |
|
||||||
| `ui/dashboard.js` | Metrics wall controller + Correlate mode |
|
| `ui/dashboard.js` | Metrics wall controller + Correlate mode |
|
||||||
|
| `ui/chart-focus.js` | Fullscreen chart overlay (shared) |
|
||||||
| `ui/charts.js` | Canvas paint + hover helpers |
|
| `ui/charts.js` | Canvas paint + hover helpers |
|
||||||
| `ui/data-manager.js` | Settings → Data (retention / prune / usage) |
|
| `ui/data-manager.js` | Settings → Data (retention / prune / usage) |
|
||||||
| `ui/logs.js` | System Log tab (journal default) |
|
| `ui/logs.js` | System Log tab (journal default) |
|
||||||
|
|||||||
+1
-1
@@ -182,7 +182,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="dash-kicker">Metrics</p>
|
<p class="dash-kicker">Metrics</p>
|
||||||
<h1 class="dash-title">Charts</h1>
|
<h1 class="dash-title">Charts</h1>
|
||||||
<p class="page-subtitle">Scroll freely · <kbd>⌃</kbd>/<kbd>⇧</kbd>+scroll zooms · <kbd>Space</kbd> · <kbd>/</kbd> · <kbd>1–5</kbd></p>
|
<p class="page-subtitle">Double-click or <span class="page-hint-ico">⛶</span> for fullscreen · <kbd>Space</kbd> · <kbd>/</kbd> · <kbd>1–5</kbd> · <kbd>Esc</kbd></p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="metrics-toolbar" id="metrics-timebar">
|
<div class="metrics-toolbar" id="metrics-timebar">
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Fullscreen chart focus overlay — shared by Charts wall + Overview sparks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* id?: string,
|
||||||
|
* title?: string,
|
||||||
|
* subtitle?: string,
|
||||||
|
* units?: string,
|
||||||
|
* status?: string,
|
||||||
|
* paint: (canvas: HTMLCanvasElement) => void,
|
||||||
|
* onClose?: () => void,
|
||||||
|
* onPrev?: (() => void)|null,
|
||||||
|
* onNext?: (() => void)|null,
|
||||||
|
* actions?: Array<{ id: string, label: string, title?: string, active?: boolean, onClick: () => void }>,
|
||||||
|
* }} FocusOpts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{
|
||||||
|
* open: (opts: FocusOpts) => void,
|
||||||
|
* close: () => void,
|
||||||
|
* isOpen: () => boolean,
|
||||||
|
* currentId: () => string,
|
||||||
|
* refresh: () => void,
|
||||||
|
* updateChrome: (patch: Partial<FocusOpts>) => void,
|
||||||
|
* setLegend: (html: string) => void,
|
||||||
|
* el: HTMLElement,
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
export function createChartFocus() {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.id = 'chart-focus'
|
||||||
|
root.className = 'chart-focus hidden'
|
||||||
|
root.tabIndex = -1
|
||||||
|
root.setAttribute('role', 'dialog')
|
||||||
|
root.setAttribute('aria-modal', 'true')
|
||||||
|
root.setAttribute('aria-labelledby', 'chart-focus-title')
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="chart-focus-backdrop" data-focus-close="1"></div>
|
||||||
|
<div class="chart-focus-stage">
|
||||||
|
<header class="chart-focus-head">
|
||||||
|
<div class="chart-focus-titles">
|
||||||
|
<p class="chart-focus-kicker muted" id="chart-focus-sub"></p>
|
||||||
|
<h2 id="chart-focus-title">Chart</h2>
|
||||||
|
<p class="chart-focus-meta muted">
|
||||||
|
<span id="chart-focus-units"></span>
|
||||||
|
<span id="chart-focus-status"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="chart-focus-actions" id="chart-focus-actions"></div>
|
||||||
|
<button type="button" class="btn btn-ghost chart-focus-close" data-focus-close="1" title="Close (Esc)" aria-label="Close fullscreen">✕</button>
|
||||||
|
</header>
|
||||||
|
<div class="chart-focus-body">
|
||||||
|
<button type="button" class="chart-focus-nav chart-focus-prev" id="chart-focus-prev" title="Previous chart (←)" aria-label="Previous chart">‹</button>
|
||||||
|
<div class="chart-focus-canvas-wrap">
|
||||||
|
<canvas id="chart-focus-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="chart-focus-nav chart-focus-next" id="chart-focus-next" title="Next chart (→)" aria-label="Next chart">›</button>
|
||||||
|
</div>
|
||||||
|
<footer class="chart-focus-foot">
|
||||||
|
<div id="chart-focus-legend" class="chart-focus-legend"></div>
|
||||||
|
<p class="chart-focus-hint muted"><kbd>Esc</kbd> close · <kbd>←</kbd><kbd>→</kbd> navigate · <kbd>Space</kbd> pause · <kbd>1</kbd>–<kbd>5</kbd> window</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.body.appendChild(root)
|
||||||
|
|
||||||
|
const titleEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-title'))
|
||||||
|
const subEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-sub'))
|
||||||
|
const unitsEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-units'))
|
||||||
|
const statusEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-status'))
|
||||||
|
const actionsEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-actions'))
|
||||||
|
const legendEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-legend'))
|
||||||
|
const canvas = /** @type {HTMLCanvasElement} */ (root.querySelector('#chart-focus-canvas'))
|
||||||
|
const prevBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#chart-focus-prev'))
|
||||||
|
const nextBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#chart-focus-next'))
|
||||||
|
|
||||||
|
/** @type {FocusOpts|null} */
|
||||||
|
let current = null
|
||||||
|
let resizeObs = /** @type {ResizeObserver|null} */ (null)
|
||||||
|
let openRaf = 0
|
||||||
|
|
||||||
|
function paintNow() {
|
||||||
|
if (!current?.paint || !canvas) return
|
||||||
|
try {
|
||||||
|
current.paint(canvas)
|
||||||
|
} catch {
|
||||||
|
// ignore paint errors in focus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePaint() {
|
||||||
|
if (openRaf) return
|
||||||
|
openRaf = requestAnimationFrame(() => {
|
||||||
|
openRaf = 0
|
||||||
|
paintNow()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNav() {
|
||||||
|
const hasPrev = Boolean(current?.onPrev)
|
||||||
|
const hasNext = Boolean(current?.onNext)
|
||||||
|
prevBtn.classList.toggle('hidden', !hasPrev)
|
||||||
|
nextBtn.classList.toggle('hidden', !hasNext)
|
||||||
|
prevBtn.disabled = !hasPrev
|
||||||
|
nextBtn.disabled = !hasNext
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActions() {
|
||||||
|
const actions = current?.actions || []
|
||||||
|
actionsEl.innerHTML = ''
|
||||||
|
for (const a of actions) {
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.type = 'button'
|
||||||
|
btn.className = 'btn btn-ghost' + (a.active ? ' is-active' : '')
|
||||||
|
btn.dataset.focusAction = a.id
|
||||||
|
btn.textContent = a.label
|
||||||
|
if (a.title) btn.title = a.title
|
||||||
|
btn.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
a.onClick()
|
||||||
|
})
|
||||||
|
actionsEl.appendChild(btn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Partial<FocusOpts>} patch
|
||||||
|
*/
|
||||||
|
function updateChrome(patch = {}) {
|
||||||
|
if (!current) return
|
||||||
|
current = { ...current, ...patch }
|
||||||
|
titleEl.textContent = current.title || current.id || 'Chart'
|
||||||
|
subEl.textContent = current.subtitle || current.id || ''
|
||||||
|
unitsEl.textContent = current.units ? String(current.units) : ''
|
||||||
|
statusEl.textContent = current.status ? String(current.status) : ''
|
||||||
|
syncNav()
|
||||||
|
if (patch.actions) syncActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional legend HTML from the host (dimension chips).
|
||||||
|
* @param {string} html
|
||||||
|
*/
|
||||||
|
function setLegend(html) {
|
||||||
|
legendEl.innerHTML = html || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FocusOpts} opts
|
||||||
|
*/
|
||||||
|
function open(opts) {
|
||||||
|
current = { ...opts }
|
||||||
|
updateChrome(opts)
|
||||||
|
syncActions()
|
||||||
|
root.classList.remove('hidden')
|
||||||
|
requestAnimationFrame(() => root.classList.add('is-open'))
|
||||||
|
document.body.classList.add('chart-focus-open')
|
||||||
|
schedulePaint()
|
||||||
|
if (!resizeObs && typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObs = new ResizeObserver(() => schedulePaint())
|
||||||
|
const wrap = root.querySelector('.chart-focus-canvas-wrap')
|
||||||
|
if (wrap) resizeObs.observe(wrap)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
root.focus({ preventScroll: true })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!current) return
|
||||||
|
const onClose = current.onClose
|
||||||
|
current = null
|
||||||
|
root.classList.remove('is-open')
|
||||||
|
const hide = () => {
|
||||||
|
root.classList.add('hidden')
|
||||||
|
legendEl.innerHTML = ''
|
||||||
|
document.body.classList.remove('chart-focus-open')
|
||||||
|
}
|
||||||
|
// Allow exit animation; fall back if reduced motion
|
||||||
|
let done = false
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
root.addEventListener('transitionend', finish, { once: true })
|
||||||
|
setTimeout(finish, 220)
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen() {
|
||||||
|
return Boolean(current) && !root.classList.contains('hidden')
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentId() {
|
||||||
|
return current?.id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!isOpen()) return
|
||||||
|
schedulePaint()
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener('click', (ev) => {
|
||||||
|
const t = /** @type {HTMLElement} */ (ev.target)
|
||||||
|
if (t?.closest?.('[data-focus-close]')) {
|
||||||
|
ev.preventDefault()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
current?.onPrev?.()
|
||||||
|
})
|
||||||
|
nextBtn.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
current?.onNext?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (ev) => {
|
||||||
|
if (!isOpen()) return
|
||||||
|
const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || ''
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
ev.preventDefault()
|
||||||
|
ev.stopPropagation()
|
||||||
|
close()
|
||||||
|
} else if (ev.key === 'ArrowLeft') {
|
||||||
|
if (current?.onPrev) {
|
||||||
|
ev.preventDefault()
|
||||||
|
current.onPrev()
|
||||||
|
}
|
||||||
|
} else if (ev.key === 'ArrowRight') {
|
||||||
|
if (current?.onNext) {
|
||||||
|
ev.preventDefault()
|
||||||
|
current.onNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep canvas sharp on DPR / window resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (isOpen()) schedulePaint()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
isOpen,
|
||||||
|
currentId,
|
||||||
|
refresh,
|
||||||
|
updateChrome,
|
||||||
|
setLegend,
|
||||||
|
el: root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {ReturnType<typeof createChartFocus>|null} */
|
||||||
|
let sharedFocus = null
|
||||||
|
|
||||||
|
/** App-wide singleton so Overview + Charts share one overlay. */
|
||||||
|
export function getChartFocus() {
|
||||||
|
if (!sharedFocus) sharedFocus = createChartFocus()
|
||||||
|
return sharedFocus
|
||||||
|
}
|
||||||
+248
-57
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../shared/chart-types.js'
|
} from '../shared/chart-types.js'
|
||||||
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
|
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
|
||||||
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
|
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
|
||||||
|
import { getChartFocus } from './chart-focus.js'
|
||||||
|
|
||||||
const GROUPS = ['average', 'min', 'max', 'sum']
|
const GROUPS = ['average', 'min', 'max', 'sum']
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ function smoothScrollIntoView(el, block = 'start') {
|
|||||||
*/
|
*/
|
||||||
export function createMetricsDashboard(opts) {
|
export function createMetricsDashboard(opts) {
|
||||||
const prefs = () => opts.getPrefs?.() || {}
|
const prefs = () => opts.getPrefs?.() || {}
|
||||||
|
const focus = getChartFocus()
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
filter: '',
|
filter: '',
|
||||||
@@ -532,6 +534,208 @@ export function createMetricsDashboard(opts) {
|
|||||||
return { dims, hidden }
|
return { dims, hidden }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build drawable series + draw opts for a chart card.
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
function buildPaintModel(id) {
|
||||||
|
const card = state.cards.get(id)
|
||||||
|
if (!card) return null
|
||||||
|
const { dims, hidden } = sortedDims(card, id)
|
||||||
|
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
|
||||||
|
card.mode = mode
|
||||||
|
/** @type {Array<{ values: number[], color: string, label: string, hidden: boolean, fill?: boolean }>} */
|
||||||
|
const lines = []
|
||||||
|
let i = 0
|
||||||
|
for (const dim of dims) {
|
||||||
|
const values = card.dims.get(dim) || []
|
||||||
|
lines.push({
|
||||||
|
values,
|
||||||
|
color: seriesColor(i),
|
||||||
|
label: dim,
|
||||||
|
hidden: hidden.has(dim),
|
||||||
|
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const anomaly = opts.getAnomaly?.(id) || null
|
||||||
|
const emptyMsg =
|
||||||
|
card.status === 'loading'
|
||||||
|
? card.emptyReason || 'Loading…'
|
||||||
|
: card.status === 'error'
|
||||||
|
? card.emptyReason || 'Error'
|
||||||
|
: card.status === 'empty'
|
||||||
|
? card.emptyReason || 'No data'
|
||||||
|
: 'No data'
|
||||||
|
const units = card.meta?.units || ''
|
||||||
|
const showY = mode !== 'pie'
|
||||||
|
return {
|
||||||
|
card,
|
||||||
|
dims,
|
||||||
|
hidden,
|
||||||
|
mode,
|
||||||
|
lines,
|
||||||
|
anomaly,
|
||||||
|
units,
|
||||||
|
emptyMsg,
|
||||||
|
drawOpts: {
|
||||||
|
mode,
|
||||||
|
maxPoints: maxPoints(),
|
||||||
|
showYAxis: showY,
|
||||||
|
padLeft: padLeftFor(units, showY),
|
||||||
|
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
|
||||||
|
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
|
||||||
|
severity: anomaly?.severity || null,
|
||||||
|
emptyMessage: emptyMsg,
|
||||||
|
dimmed: false,
|
||||||
|
units,
|
||||||
|
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
|
||||||
|
endOffset: state.endOffset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered chart ids currently on the wall (for focus prev/next). */
|
||||||
|
function wallChartIds() {
|
||||||
|
const ids = []
|
||||||
|
for (const el of opts.els.wall?.querySelectorAll('[data-chart-id]') || []) {
|
||||||
|
const id = el.getAttribute('data-chart-id')
|
||||||
|
if (id) ids.push(id)
|
||||||
|
}
|
||||||
|
return ids.length ? ids : [...state.cardEls.keys()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {ReturnType<typeof buildPaintModel>} model
|
||||||
|
*/
|
||||||
|
function paintCanvas(id, canvas, model) {
|
||||||
|
if (!model || !canvas) return
|
||||||
|
drawChart(canvas, model.lines, model.drawOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusLegendHtml(model) {
|
||||||
|
if (!model) return ''
|
||||||
|
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
|
||||||
|
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
|
||||||
|
const unitSuffix = model.units ? ` ${model.units}` : ''
|
||||||
|
return (
|
||||||
|
show
|
||||||
|
.map((line) => {
|
||||||
|
const last = line.values[line.values.length - 1]
|
||||||
|
const text = `${line.label}${last != null ? ` ${formatVal(last)}${unitSuffix}` : ''}`
|
||||||
|
return `<span class="dim-chip${line.hidden ? ' off' : ''}" style="--dim-color:${line.color}">${escapeHtml(text)}</span>`
|
||||||
|
})
|
||||||
|
.join('') +
|
||||||
|
(extra ? `<span class="dim-chip-more muted">+${extra}</span>` : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFocusChrome(id) {
|
||||||
|
if (!focus.isOpen() || focus.currentId() !== id) return
|
||||||
|
const model = buildPaintModel(id)
|
||||||
|
const catalog = opts.getCatalog() || {}
|
||||||
|
const meta = catalog[id] || state.cards.get(id)?.meta || {}
|
||||||
|
const status =
|
||||||
|
model?.card?.status === 'ok' && model.card.source && model.card.source !== 'memory'
|
||||||
|
? model.card.source
|
||||||
|
: model?.card?.status === 'ok'
|
||||||
|
? ''
|
||||||
|
: model?.card?.status || ''
|
||||||
|
focus.updateChrome({
|
||||||
|
id,
|
||||||
|
title: meta.title || id,
|
||||||
|
subtitle: metricCardSubtitle(id, meta),
|
||||||
|
units: meta.units || model?.units || '',
|
||||||
|
status,
|
||||||
|
actions: focusActionsFor(id),
|
||||||
|
onPrev: () => focusStep(-1),
|
||||||
|
onNext: () => focusStep(1),
|
||||||
|
})
|
||||||
|
focus.setLegend(focusLegendHtml(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusActionsFor(id) {
|
||||||
|
const card = state.cards.get(id)
|
||||||
|
const mode = card?.mode || 'line'
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'pin',
|
||||||
|
label: state.pinned.has(id) ? '★ Pin' : '☆ Pin',
|
||||||
|
title: 'Pin to board',
|
||||||
|
active: state.pinned.has(id),
|
||||||
|
onClick: () => {
|
||||||
|
togglePin(id)
|
||||||
|
syncFocusChrome(id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'type',
|
||||||
|
label: CHART_MODE_LABEL[mode] || mode,
|
||||||
|
title: chartModeHint(mode),
|
||||||
|
onClick: () => {
|
||||||
|
cycleChartType(id)
|
||||||
|
syncFocusChrome(id)
|
||||||
|
focus.refresh()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'related',
|
||||||
|
label: 'Related',
|
||||||
|
title: 'Find related charts',
|
||||||
|
onClick: () => {
|
||||||
|
focus.close()
|
||||||
|
showRelated(id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFocus(id) {
|
||||||
|
if (!id) return
|
||||||
|
ensureCard(id, (opts.getCatalog() || {})[id] || {})
|
||||||
|
if (!state.cards.get(id)?.dims?.size) {
|
||||||
|
fetchChart(id).catch(() => {})
|
||||||
|
}
|
||||||
|
const catalog = opts.getCatalog() || {}
|
||||||
|
const meta = catalog[id] || {}
|
||||||
|
focus.open({
|
||||||
|
id,
|
||||||
|
title: meta.title || id,
|
||||||
|
subtitle: metricCardSubtitle(id, meta),
|
||||||
|
units: meta.units || '',
|
||||||
|
paint: (canvas) => {
|
||||||
|
const model = buildPaintModel(id)
|
||||||
|
paintCanvas(id, canvas, model)
|
||||||
|
},
|
||||||
|
onPrev: () => focusStep(-1),
|
||||||
|
onNext: () => focusStep(1),
|
||||||
|
actions: focusActionsFor(id),
|
||||||
|
onClose: () => {
|
||||||
|
const el = state.cardEls.get(id)
|
||||||
|
el?.classList.remove('is-focused')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (const el of state.cardEls.values()) el.classList.remove('is-focused')
|
||||||
|
state.cardEls.get(id)?.classList.add('is-focused')
|
||||||
|
syncFocusChrome(id)
|
||||||
|
focus.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} delta */
|
||||||
|
function focusStep(delta) {
|
||||||
|
const ids = wallChartIds()
|
||||||
|
if (!ids.length) return
|
||||||
|
const cur = focus.currentId()
|
||||||
|
let idx = ids.indexOf(cur)
|
||||||
|
if (idx < 0) idx = 0
|
||||||
|
const next = ids[(idx + delta + ids.length) % ids.length]
|
||||||
|
openFocus(next)
|
||||||
|
const el = state.cardEls.get(next)
|
||||||
|
if (el && !state.visible.has(next)) smoothScrollIntoView(el, 'center')
|
||||||
|
}
|
||||||
|
|
||||||
function paintCard(id) {
|
function paintCard(id) {
|
||||||
const article = state.cardEls.get(id)
|
const article = state.cardEls.get(id)
|
||||||
if (!article) return
|
if (!article) return
|
||||||
@@ -541,8 +745,14 @@ export function createMetricsDashboard(opts) {
|
|||||||
const legend = article.querySelector('.metric-card-legend')
|
const legend = article.querySelector('.metric-card-legend')
|
||||||
article.classList.toggle('pinned', state.pinned.has(id))
|
article.classList.toggle('pinned', state.pinned.has(id))
|
||||||
article.classList.toggle('related', state.relatedIds.has(id))
|
article.classList.toggle('related', state.relatedIds.has(id))
|
||||||
|
article.classList.toggle('is-focused', focus.isOpen() && focus.currentId() === id)
|
||||||
const pinBtn = article.querySelector('.metric-pin-btn')
|
const pinBtn = article.querySelector('.metric-pin-btn')
|
||||||
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
|
if (pinBtn) pinBtn.textContent = state.pinned.has(id) ? '★' : '☆'
|
||||||
|
const typeBtn = article.querySelector('.metric-type-btn')
|
||||||
|
if (typeBtn && card) {
|
||||||
|
typeBtn.textContent = CHART_MODE_LABEL[card.mode] || card.mode
|
||||||
|
typeBtn.title = chartModeHint(card.mode)
|
||||||
|
}
|
||||||
// Never dim the card during SWR — opacity/filter changes look like a blink
|
// Never dim the card during SWR — opacity/filter changes look like a blink
|
||||||
article.classList.remove('updating')
|
article.classList.remove('updating')
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
@@ -564,64 +774,25 @@ export function createMetricsDashboard(opts) {
|
|||||||
weightChip.classList.add('hidden')
|
weightChip.classList.add('hidden')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!card || !canvas) return
|
const model = buildPaintModel(id)
|
||||||
|
if (!card || !canvas || !model) return
|
||||||
const height = cardHeightFor(id, card)
|
const height = cardHeightFor(id, card)
|
||||||
const heightPx = `${height}px`
|
const heightPx = `${height}px`
|
||||||
if (canvas.style.height !== heightPx) canvas.style.height = heightPx
|
if (canvas.style.height !== heightPx) canvas.style.height = heightPx
|
||||||
updateHighlightBand(id)
|
updateHighlightBand(id)
|
||||||
const { dims, hidden } = sortedDims(card, id)
|
if (model.anomaly?.severity) article.dataset.severity = model.anomaly.severity
|
||||||
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) || []
|
|
||||||
lines.push({
|
|
||||||
values,
|
|
||||||
color: seriesColor(i),
|
|
||||||
label: dim,
|
|
||||||
hidden: hidden.has(dim),
|
|
||||||
fill: mode === 'area' ? true : mode === 'line' ? false : undefined,
|
|
||||||
})
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
const anomaly = opts.getAnomaly?.(id) || null
|
|
||||||
if (anomaly?.severity) article.dataset.severity = anomaly.severity
|
|
||||||
else delete article.dataset.severity
|
else delete article.dataset.severity
|
||||||
article.dataset.chartMode = mode
|
article.dataset.chartMode = model.mode
|
||||||
const emptyMsg =
|
paintCanvas(id, canvas, model)
|
||||||
card.status === 'loading'
|
updateCardTooltip(article, id, card, model.lines, model.units)
|
||||||
? card.emptyReason || 'Loading…'
|
|
||||||
: card.status === 'error'
|
|
||||||
? card.emptyReason || 'Error'
|
|
||||||
: card.status === 'empty'
|
|
||||||
? card.emptyReason || 'No data'
|
|
||||||
: 'No data'
|
|
||||||
const units = card.meta?.units || ''
|
|
||||||
const showY = mode !== 'pie'
|
|
||||||
drawChart(canvas, lines, {
|
|
||||||
mode,
|
|
||||||
maxPoints: maxPoints(),
|
|
||||||
showYAxis: showY,
|
|
||||||
padLeft: padLeftFor(units, showY),
|
|
||||||
hoverIndex: mode === 'pie' ? null : state.hoverIndex,
|
|
||||||
threshold: mode === 'pie' ? null : anomaly?.threshold ?? null,
|
|
||||||
severity: anomaly?.severity || null,
|
|
||||||
emptyMessage: emptyMsg,
|
|
||||||
dimmed: false,
|
|
||||||
units,
|
|
||||||
windowSeconds: mode === 'pie' ? null : state.afterSeconds,
|
|
||||||
endOffset: state.endOffset,
|
|
||||||
})
|
|
||||||
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, units)
|
statsEl.innerHTML = renderStatsHtml(card, model.dims, model.hidden, model.units)
|
||||||
}
|
}
|
||||||
if (legend && !card.updating) {
|
if (legend && !card.updating) {
|
||||||
const show = lines.slice(0, LEGEND_CHIP_CAP)
|
const show = model.lines.slice(0, LEGEND_CHIP_CAP)
|
||||||
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP)
|
const extra = Math.max(0, model.lines.length - LEGEND_CHIP_CAP)
|
||||||
const unitSuffix = units ? ` ${units}` : ''
|
const unitSuffix = model.units ? ` ${model.units}` : ''
|
||||||
const nextHtml =
|
const nextHtml =
|
||||||
show
|
show
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
@@ -644,6 +815,10 @@ export function createMetricsDashboard(opts) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (focus.isOpen() && focus.currentId() === id) {
|
||||||
|
syncFocusChrome(id)
|
||||||
|
focus.refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1384,6 +1559,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
<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="${escapeAttr(chartModeHint(card.mode))}">${escapeHtml(CHART_MODE_LABEL[card.mode] || 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>
|
||||||
|
<button type="button" class="btn btn-ghost metric-focus-btn" title="Fullscreen (double-click)" aria-label="Fullscreen">⛶</button>
|
||||||
<span class="metric-card-status muted"></span>
|
<span class="metric-card-status muted"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1391,6 +1567,7 @@ export function createMetricsDashboard(opts) {
|
|||||||
<canvas height="${height}"></canvas>
|
<canvas height="${height}"></canvas>
|
||||||
<div class="metric-highlight-band hidden" aria-hidden="true"></div>
|
<div class="metric-highlight-band hidden" aria-hidden="true"></div>
|
||||||
<span class="metric-weight-chip hidden"></span>
|
<span class="metric-weight-chip hidden"></span>
|
||||||
|
<button type="button" class="metric-focus-fab" title="Fullscreen" aria-label="Open fullscreen">⛶</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card-legend"></div>
|
<div class="metric-card-legend"></div>
|
||||||
<div class="metric-card-stats hidden"></div>
|
<div class="metric-card-stats hidden"></div>
|
||||||
@@ -1421,6 +1598,12 @@ export function createMetricsDashboard(opts) {
|
|||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
cycleChartType(id)
|
cycleChartType(id)
|
||||||
})
|
})
|
||||||
|
const openFocusHandler = (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
openFocus(id)
|
||||||
|
}
|
||||||
|
article.querySelector('.metric-focus-btn')?.addEventListener('click', openFocusHandler)
|
||||||
|
article.querySelector('.metric-focus-fab')?.addEventListener('click', openFocusHandler)
|
||||||
bindResizeHandle(article)
|
bindResizeHandle(article)
|
||||||
article.querySelector('.metric-card-title')?.addEventListener('click', (ev) => {
|
article.querySelector('.metric-card-title')?.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
@@ -1437,14 +1620,9 @@ export function createMetricsDashboard(opts) {
|
|||||||
})
|
})
|
||||||
article.addEventListener('dblclick', (ev) => {
|
article.addEventListener('dblclick', (ev) => {
|
||||||
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return
|
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return
|
||||||
article.classList.add('expanded')
|
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-resize')) return
|
||||||
const statsEl = article.querySelector('.metric-card-stats')
|
ev.preventDefault()
|
||||||
if (statsEl) {
|
openFocus(id)
|
||||||
statsEl.classList.remove('hidden')
|
|
||||||
const { dims, hidden } = sortedDims(card, id)
|
|
||||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
|
|
||||||
}
|
|
||||||
showRelated(id)
|
|
||||||
})
|
})
|
||||||
state.cardEls.set(id, article)
|
state.cardEls.set(id, article)
|
||||||
state.observer?.observe(article)
|
state.observer?.observe(article)
|
||||||
@@ -1822,6 +2000,17 @@ export function createMetricsDashboard(opts) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (focus.isOpen()) {
|
||||||
|
if (ev.key === ' ' || ev.code === 'Space') {
|
||||||
|
ev.preventDefault()
|
||||||
|
setPlaying(!state.playing)
|
||||||
|
} else if (ev.key >= '1' && ev.key <= '5') {
|
||||||
|
const presets = ['1m', '5m', '15m', '1h', '6h']
|
||||||
|
setPreset(presets[Number(ev.key) - 1])
|
||||||
|
}
|
||||||
|
// Esc / arrows handled by chart-focus
|
||||||
|
return
|
||||||
|
}
|
||||||
if (ev.key === ' ' || ev.code === 'Space') {
|
if (ev.key === ' ' || ev.code === 'Space') {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
setPlaying(!state.playing)
|
setPlaying(!state.playing)
|
||||||
@@ -1930,6 +2119,8 @@ export function createMetricsDashboard(opts) {
|
|||||||
redrawVisible,
|
redrawVisible,
|
||||||
scrollToChart,
|
scrollToChart,
|
||||||
focusChartAt,
|
focusChartAt,
|
||||||
|
openFocus,
|
||||||
|
closeFocus: () => focus.close(),
|
||||||
showRelated,
|
showRelated,
|
||||||
clearRelated,
|
clearRelated,
|
||||||
setBoardOnly,
|
setBoardOnly,
|
||||||
|
|||||||
+338
-3
@@ -772,13 +772,42 @@ body.is-offline .offline-banner:not(.hidden) {
|
|||||||
|
|
||||||
.chart-panel header {
|
.chart-panel header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-panel--focusable {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-panel--focusable:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 32%, var(--border-color));
|
||||||
|
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-panel .chart-focus-btn {
|
||||||
|
opacity: 0;
|
||||||
|
min-width: 1.8rem !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-panel--focusable:hover .chart-focus-btn,
|
||||||
|
.chart-panel--focusable:focus-within .chart-focus-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-panel h3 {
|
.chart-panel h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1536,7 +1565,7 @@ button.metrics-tf.thin-history:not(.active) {
|
|||||||
padding: 12px 14px 8px;
|
padding: 12px 14px 8px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 48%),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 42%),
|
||||||
var(--bg-secondary);
|
var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1557,6 +1586,59 @@ button.metrics-tf.thin-history:not(.active) {
|
|||||||
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-color));
|
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-card.is-focused {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 55%, var(--border-color));
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-focus-fab {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 4;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2px);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
transition:
|
||||||
|
opacity 0.16s ease,
|
||||||
|
transform 0.16s ease,
|
||||||
|
color 0.16s ease,
|
||||||
|
border-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover .metric-focus-fab,
|
||||||
|
.metric-card:focus-within .metric-focus-fab {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-focus-fab:hover {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-focus-btn {
|
||||||
|
min-width: 1.8rem !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover .metric-focus-btn,
|
||||||
|
.metric-card:focus-within .metric-focus-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-card[data-chart-mode='pie'] canvas {
|
.metric-card[data-chart-mode='pie'] canvas {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
@@ -1780,7 +1862,14 @@ html[data-theme='light'] .metric-card canvas {
|
|||||||
.metric-card-actions {
|
.metric-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card-actions .btn {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 2px 7px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card canvas.panning {
|
.metric-card canvas.panning {
|
||||||
@@ -2827,3 +2916,249 @@ code {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Fullscreen chart focus ─── */
|
||||||
|
body.chart-focus-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right))
|
||||||
|
max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(52, 211, 153, 0.08), transparent 55%),
|
||||||
|
rgba(4, 6, 10, 0.72);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='light'] .chart-focus-backdrop {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(52, 211, 153, 0.1), transparent 55%),
|
||||||
|
rgba(243, 245, 248, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-stage {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1280px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
margin: auto;
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 36%),
|
||||||
|
var(--bg-secondary);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 0 1px rgba(52, 211, 153, 0.08);
|
||||||
|
transform: translateY(10px) scale(0.985);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus.is-open .chart-focus-stage {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-titles {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-kicker {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(18px, 2.4vw, 26px);
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 14px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-actions .btn.is-active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-close {
|
||||||
|
min-width: 2.2rem !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr 40px;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-canvas-wrap {
|
||||||
|
min-height: clamp(280px, 58vh, 720px);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='light'] .chart-focus-canvas-wrap {
|
||||||
|
background: rgba(15, 23, 42, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-canvas-wrap canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-nav {
|
||||||
|
align-self: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 64px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 80%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-nav:hover:not(:disabled) {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-nav:disabled,
|
||||||
|
.chart-focus-nav.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 22px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-focus-hint kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint-ico {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.chart-focus-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.chart-focus-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.chart-focus-stage {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.chart-focus-hint {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.chart-panel .chart-focus-btn,
|
||||||
|
.metric-focus-fab,
|
||||||
|
.metric-focus-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chart-focus,
|
||||||
|
.chart-focus-stage,
|
||||||
|
.metric-focus-fab,
|
||||||
|
.metric-card,
|
||||||
|
.chart-panel--focusable {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user