Update
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
|
||||
import { defaultModeFromMeta } from './shared/chart-types.js'
|
||||
import { createMetricsDashboard } from './ui/dashboard.js'
|
||||
import { getChartFocus } from './ui/chart-focus.js'
|
||||
import {
|
||||
buildFleetRoster,
|
||||
summarizeFleet,
|
||||
@@ -354,6 +355,172 @@ function paint(canvasId, lines, opts = {}) {
|
||||
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
|
||||
/** Coalesce Overview redraws into one frame to avoid spark blink. */
|
||||
function scheduleRedrawAll() {
|
||||
@@ -458,45 +625,21 @@ function anomalyOptsFor(chart) {
|
||||
}
|
||||
|
||||
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) {
|
||||
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`
|
||||
} else if (els.chartCpuLabel) {
|
||||
els.chartCpuLabel.textContent = 'system.cpu'
|
||||
}
|
||||
if (els.legendCpu) {
|
||||
els.legendCpu.innerHTML = lines
|
||||
els.legendCpu.innerHTML = (lines.length ? lines : [{ color: CHART_PALETTE.cpuUser, label: 'used' }])
|
||||
.map(
|
||||
(l) =>
|
||||
`<span style="--swatch:${l.color}">${escapeHtml(l.label || '')}</span>`
|
||||
)
|
||||
.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() {
|
||||
@@ -511,10 +654,7 @@ function redrawAll() {
|
||||
)
|
||||
paint(
|
||||
'chart-net',
|
||||
[
|
||||
{ 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
|
||||
series.netTx.length >= 2
|
||||
? [
|
||||
{ values: series.net, color: CHART_PALETTE.net },
|
||||
{ values: series.netTx, color: CHART_PALETTE.disk },
|
||||
@@ -524,10 +664,7 @@ function redrawAll() {
|
||||
)
|
||||
paint(
|
||||
'chart-io',
|
||||
[
|
||||
{ values: series.io, color: CHART_PALETTE.disk },
|
||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||
].filter((l) => l.values.length >= 2).length
|
||||
series.ioWrite.length >= 2
|
||||
? [
|
||||
{ values: series.io, color: CHART_PALETTE.disk },
|
||||
{ values: series.ioWrite, color: CHART_PALETTE.cpuIowait },
|
||||
@@ -548,6 +685,11 @@ function redrawAll() {
|
||||
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() {
|
||||
@@ -1470,6 +1612,7 @@ async function restorePeersOnBoot() {
|
||||
|
||||
setOnline(false)
|
||||
syncSettingsUi()
|
||||
bindOverviewFocus()
|
||||
showView(loadBookmarks().length ? 'fleet' : 'connect')
|
||||
renderBookmarks()
|
||||
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] 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] 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) |
|
||||
| `server/services/weights.js` | Metric Correlations scoring (ks2 / volume / AR / value) |
|
||||
| `ui/dashboard.js` | Metrics wall controller + Correlate mode |
|
||||
| `ui/chart-focus.js` | Fullscreen chart overlay (shared) |
|
||||
| `ui/charts.js` | Canvas paint + hover helpers |
|
||||
| `ui/data-manager.js` | Settings → Data (retention / prune / usage) |
|
||||
| `ui/logs.js` | System Log tab (journal default) |
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@
|
||||
<div>
|
||||
<p class="dash-kicker">Metrics</p>
|
||||
<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>
|
||||
</header>
|
||||
<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'
|
||||
import { drawChart, hoverIndexFromEvent, padLeftFor, pushDim, seriesColor } from './charts.js'
|
||||
import { chartOptionLabel, metricCardSubtitle } from '../shared/container-names.js'
|
||||
import { getChartFocus } from './chart-focus.js'
|
||||
|
||||
const GROUPS = ['average', 'min', 'max', 'sum']
|
||||
|
||||
@@ -90,6 +91,7 @@ function smoothScrollIntoView(el, block = 'start') {
|
||||
*/
|
||||
export function createMetricsDashboard(opts) {
|
||||
const prefs = () => opts.getPrefs?.() || {}
|
||||
const focus = getChartFocus()
|
||||
|
||||
const state = {
|
||||
filter: '',
|
||||
@@ -532,6 +534,208 @@ export function createMetricsDashboard(opts) {
|
||||
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) {
|
||||
const article = state.cardEls.get(id)
|
||||
if (!article) return
|
||||
@@ -541,8 +745,14 @@ export function createMetricsDashboard(opts) {
|
||||
const legend = article.querySelector('.metric-card-legend')
|
||||
article.classList.toggle('pinned', state.pinned.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')
|
||||
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
|
||||
article.classList.remove('updating')
|
||||
if (statusEl) {
|
||||
@@ -564,64 +774,25 @@ export function createMetricsDashboard(opts) {
|
||||
weightChip.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
if (!card || !canvas) return
|
||||
const model = buildPaintModel(id)
|
||||
if (!card || !canvas || !model) return
|
||||
const height = cardHeightFor(id, card)
|
||||
const heightPx = `${height}px`
|
||||
if (canvas.style.height !== heightPx) canvas.style.height = heightPx
|
||||
updateHighlightBand(id)
|
||||
const { dims, hidden } = sortedDims(card, id)
|
||||
const mode = normalizeChartMode(card.mode || defaultModeFromMeta(card.meta))
|
||||
card.mode = mode
|
||||
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
|
||||
if (model.anomaly?.severity) article.dataset.severity = model.anomaly.severity
|
||||
else delete article.dataset.severity
|
||||
article.dataset.chartMode = mode
|
||||
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'
|
||||
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)
|
||||
article.dataset.chartMode = model.mode
|
||||
paintCanvas(id, canvas, model)
|
||||
updateCardTooltip(article, id, card, model.lines, model.units)
|
||||
const statsEl = article.querySelector('.metric-card-stats')
|
||||
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) {
|
||||
const show = lines.slice(0, LEGEND_CHIP_CAP)
|
||||
const extra = Math.max(0, lines.length - LEGEND_CHIP_CAP)
|
||||
const unitSuffix = units ? ` ${units}` : ''
|
||||
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}` : ''
|
||||
const nextHtml =
|
||||
show
|
||||
.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-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-focus-btn" title="Fullscreen (double-click)" aria-label="Fullscreen">⛶</button>
|
||||
<span class="metric-card-status muted"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1391,6 +1567,7 @@ export function createMetricsDashboard(opts) {
|
||||
<canvas height="${height}"></canvas>
|
||||
<div class="metric-highlight-band hidden" aria-hidden="true"></div>
|
||||
<span class="metric-weight-chip hidden"></span>
|
||||
<button type="button" class="metric-focus-fab" title="Fullscreen" aria-label="Open fullscreen">⛶</button>
|
||||
</div>
|
||||
<div class="metric-card-legend"></div>
|
||||
<div class="metric-card-stats hidden"></div>
|
||||
@@ -1421,6 +1598,12 @@ export function createMetricsDashboard(opts) {
|
||||
ev.stopPropagation()
|
||||
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)
|
||||
article.querySelector('.metric-card-title')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
@@ -1437,14 +1620,9 @@ export function createMetricsDashboard(opts) {
|
||||
})
|
||||
article.addEventListener('dblclick', (ev) => {
|
||||
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-card-actions')) return
|
||||
article.classList.add('expanded')
|
||||
const statsEl = article.querySelector('.metric-card-stats')
|
||||
if (statsEl) {
|
||||
statsEl.classList.remove('hidden')
|
||||
const { dims, hidden } = sortedDims(card, id)
|
||||
statsEl.innerHTML = renderStatsHtml(card, dims, hidden, card.meta?.units || '')
|
||||
}
|
||||
showRelated(id)
|
||||
if (ev.target instanceof HTMLElement && ev.target.closest('.metric-resize')) return
|
||||
ev.preventDefault()
|
||||
openFocus(id)
|
||||
})
|
||||
state.cardEls.set(id, article)
|
||||
state.observer?.observe(article)
|
||||
@@ -1822,6 +2000,17 @@ export function createMetricsDashboard(opts) {
|
||||
}
|
||||
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') {
|
||||
ev.preventDefault()
|
||||
setPlaying(!state.playing)
|
||||
@@ -1930,6 +2119,8 @@ export function createMetricsDashboard(opts) {
|
||||
redrawVisible,
|
||||
scrollToChart,
|
||||
focusChartAt,
|
||||
openFocus,
|
||||
closeFocus: () => focus.close(),
|
||||
showRelated,
|
||||
clearRelated,
|
||||
setBoardOnly,
|
||||
|
||||
+338
-3
@@ -772,13 +772,42 @@ body.is-offline .offline-banner:not(.hidden) {
|
||||
|
||||
.chart-panel header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
@@ -1536,7 +1565,7 @@ button.metrics-tf.thin-history:not(.active) {
|
||||
padding: 12px 14px 8px;
|
||||
border-radius: 14px;
|
||||
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);
|
||||
border: 1px solid var(--border-color);
|
||||
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));
|
||||
}
|
||||
|
||||
.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 {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -1780,7 +1862,14 @@ html[data-theme='light'] .metric-card canvas {
|
||||
.metric-card-actions {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -2827,3 +2916,249 @@ code {
|
||||
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