diff --git a/server/src/ledgrab/static/css/automations.css b/server/src/ledgrab/static/css/automations.css index 3b3a909..f284250 100644 --- a/server/src/ledgrab/static/css/automations.css +++ b/server/src/ledgrab/static/css/automations.css @@ -1,19 +1,23 @@ /* ===== AUTOMATIONS ===== */ .badge-automation-active { - background: var(--success-color); - color: #fff; + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent); + color: var(--ch-signal, var(--primary-color)); + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); } .badge-automation-inactive { - background: var(--border-color); - color: var(--text-color); + background: transparent; + border-color: var(--lux-line, var(--border-color)); + color: var(--lux-ink-dim, var(--text-color)); } .badge-automation-disabled { - background: var(--border-color); - color: var(--text-muted); - opacity: 0.7; + background: transparent; + border-color: var(--lux-line, var(--border-color)); + color: var(--lux-ink-mute, var(--text-muted)); + opacity: 0.8; } .automation-status-disabled { diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 4322bde..789af28 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -582,18 +582,21 @@ body.cs-drag-active .card-drag-handle { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 10px; + margin-bottom: 12px; padding-right: 60px; } .card-title { + font-family: var(--font-body, inherit); font-size: 1.05rem; - font-weight: 600; + font-weight: 700; + letter-spacing: -0.01em; min-width: 0; display: flex; align-items: center; gap: 8px; overflow: hidden; + color: var(--lux-ink, var(--text-color)); } .card-title-text { @@ -613,17 +616,18 @@ body.cs-drag-active .card-drag-handle { .device-url-badge { display: inline-flex; align-items: center; - gap: 4px; - font-size: 0.7rem; - font-weight: 400; - color: var(--text-secondary); - background: var(--border-color); + gap: 5px; + font-size: 0.68rem; + font-weight: 500; + color: var(--lux-ink-dim, var(--text-secondary)); + background: var(--lux-bg-0, var(--border-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); padding: 2px 8px; - border-radius: 10px; - letter-spacing: 0.03em; - font-family: monospace; + border-radius: 2px; + letter-spacing: 0.04em; + font-family: var(--font-mono, monospace); text-decoration: none; - transition: background 0.2s; + transition: background 0.2s, border-color 0.2s, color 0.2s; white-space: nowrap; flex-shrink: 1; overflow: hidden; @@ -636,7 +640,9 @@ body.cs-drag-active .card-drag-handle { } .device-url-badge:hover { - background: var(--text-muted); + background: var(--lux-bg-2, var(--text-muted)); + border-color: var(--lux-line-bold, var(--border-color)); + color: var(--lux-ink, var(--text-color)); } .device-url-icon { @@ -650,17 +656,19 @@ body.cs-drag-active .card-drag-handle { .card-subtitle { display: flex; align-items: center; - gap: 12px; - margin-bottom: 15px; + gap: 8px; + margin-bottom: 14px; flex-wrap: wrap; } .card-meta { - font-size: 0.8rem; - color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + color: var(--lux-ink-mute, var(--text-secondary)); display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; + letter-spacing: 0.04em; } .card-meta .icon { diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 3ed35e5..c9dc248 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -23,16 +23,17 @@ .card-actions { display: flex; - gap: 8px; + gap: 6px; margin-top: auto; padding-top: 12px; - border-top: 1px solid var(--border-color); + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); align-items: center; } .card-actions .btn-icon { - padding: 6px 8px; - font-size: 1.1rem; + padding: 7px 10px; + min-width: 36px; + font-size: 0.95rem; } .btn { @@ -95,14 +96,51 @@ .btn-icon { min-width: auto; - padding: 8px 12px; - font-size: 1.2rem; + padding: 7px 10px; + font-size: 1rem; flex: 0 0 auto; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + background: transparent; + color: var(--lux-ink-dim, var(--text-color)); + transition: color 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s; } .btn-icon:hover { - transform: scale(1.1); + transform: none; opacity: 1; + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-2, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line-bold, var(--border-color))); + filter: none; + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); +} + +/* Variant: warning / success for enable/disable action buttons. Keep + flat hairline borders; just shift the color + hover glow. */ +.btn-icon.btn-warning { + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent); + background: transparent; + box-shadow: none; +} +.btn-icon.btn-warning:hover { + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent); + color: var(--ch-amber, var(--warning-color)); + border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent); + box-shadow: 0 0 10px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 25%, transparent); +} + +.btn-icon.btn-success { + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); + background: transparent; + box-shadow: none; +} +.btn-icon.btn-success:hover { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent); + color: var(--ch-signal, var(--primary-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent); + box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent); } .btn-icon:active:not(:disabled) { diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index 82ee954..41573b4 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -961,6 +961,91 @@ line-height: 1.1; } +/* Hide the pill when there's nothing to show (host-only metrics, or a + mode/state that has no app variant). Avoids a ghost bordered box in + the top-right corner. */ +.perf-chart-app:empty { display: none; } + +/* ── Spark hover tooltip (single floating element, reused across all + cards; positioned via JS; inline layout reads like an instrument + readout). ── */ +.perf-chart-tooltip { + position: fixed; + display: none; + z-index: var(--z-toast, 3500); + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 8px 24px rgba(0, 0, 0, 0.4); + padding: 8px 10px 6px; + pointer-events: none; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + color: var(--lux-ink, var(--text-color)); + letter-spacing: 0.02em; + line-height: 1.3; + min-width: 110px; +} + +.perf-chart-tooltip .perf-tip-row { + display: flex; + align-items: center; + gap: 8px; +} +.perf-chart-tooltip .perf-tip-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + box-shadow: 0 0 4px currentColor; +} +.perf-chart-tooltip .perf-tip-dot-app { + background: transparent; + border: var(--lux-hairline, 1px) solid currentColor; + box-shadow: none; +} +.perf-chart-tooltip .perf-tip-k { + color: var(--lux-ink-mute, var(--text-secondary)); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-right: auto; +} +.perf-chart-tooltip .perf-tip-v { + color: var(--lux-ink, var(--text-color)); + font-variant-numeric: tabular-nums; + font-weight: 700; +} +.perf-chart-tooltip .perf-tip-app .perf-tip-v { + color: var(--lux-ink-dim, var(--text-secondary)); + font-weight: 500; +} +.perf-chart-tooltip .perf-tip-age { + margin-top: 4px; + padding-top: 4px; + border-top: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + font-size: 0.6rem; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.14em; + text-transform: uppercase; + text-align: right; +} + +/* Vertical marker line over the spark at cursor x. */ +.perf-chart-tooltip-marker { + position: fixed; + display: none; + width: 1px; + pointer-events: none; + background: var(--marker-color, var(--ch-signal, var(--primary-color))); + opacity: 0.55; + z-index: calc(var(--z-toast, 3500) - 1); + box-shadow: 0 0 6px var(--marker-color, var(--ch-signal, var(--primary-color))); +} + /* Hide the idle corner bracket on perf cards — the APP tag now owns that slot in 'both' mode. */ .perf-chart-card::after { @@ -1015,16 +1100,22 @@ bottom. `margin-top: auto` pushes it to the bottom so the spark baseline aligns across cells regardless of subtitle presence — cells with CPU/GPU model names, FPS min/max etc. no longer have a - higher spark than cells without a subtitle. */ + higher spark than cells without a subtitle. + Pointer events stay enabled so hover tooltips work; the SVG itself + is non-interactive via `perf-chart-svg` below. */ .perf-chart-spark { position: relative; margin-top: auto; height: 42px; padding: 0 18px 14px; - pointer-events: none; + cursor: crosshair; filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent)); } +.perf-chart-spark .perf-chart-svg { + pointer-events: none; +} + .perf-chart-spark .perf-chart-svg { width: 100%; height: 100%; @@ -1214,6 +1305,19 @@ align-self: center; } +/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized + down + muted so the live value remains the primary reading. Matches + the "/ 12" style from the Active Patches cell. */ +.perf-chart-card[data-metric="fps"] .perf-fps-ceiling { + font-family: var(--font-mono, monospace); + font-size: 0.38em; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-secondary)); + letter-spacing: 0.04em; + margin-left: 4px; + align-self: center; +} + /* Hint mode — the card is revealed with an explanatory message instead of a live metric (e.g. Windows without LibreHardwareMonitor for CPU temp). Neutralizes the big display font + hides the sparkline so the diff --git a/server/src/ledgrab/static/css/patterns.css b/server/src/ledgrab/static/css/patterns.css index 5ecc5b2..5d483a8 100644 --- a/server/src/ledgrab/static/css/patterns.css +++ b/server/src/ledgrab/static/css/patterns.css @@ -42,21 +42,30 @@ } .stream-card-prop { - display: inline-block; - font-size: 0.75rem; - color: var(--text-secondary); - background: var(--border-color); - padding: 2px 8px; - border-radius: 10px; + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono, monospace); + font-size: 0.68rem; + color: var(--lux-ink-dim, var(--text-secondary)); + background: var(--lux-bg-0, var(--border-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 3px 8px; + border-radius: 2px; + letter-spacing: 0.04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 180px; + max-width: 220px; vertical-align: middle; + line-height: 1.3; } .stream-card-prop .icon { - color: var(--primary-text-color); + color: var(--ch-signal, var(--primary-color)); + width: 11px; + height: 11px; + flex-shrink: 0; } .stream-card-prop-full { @@ -65,18 +74,19 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: 0.7rem; + font-size: 0.66rem; } .stream-card-link { cursor: pointer; text-decoration: none; - transition: background 0.2s, color 0.2s; + transition: background 0.2s, color 0.2s, border-color 0.2s; } .stream-card-link:hover { - background: var(--primary-color); - color: var(--primary-contrast); + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 15%, transparent); + color: var(--lux-ink, var(--text-color)); + border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, var(--lux-line, var(--border-color))); } .stream-card-link:hover .icon { @@ -84,15 +94,31 @@ } @keyframes cardHighlight { - 0%, 100% { box-shadow: none; } - 25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); } + 0%, 100% { + box-shadow: + 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent), + 0 0 0 0 transparent; + } + 25%, 75% { + box-shadow: + 0 0 0 2px var(--ch-signal, var(--primary-color)), + 0 0 32px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent), + 0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 80%, transparent); + } } .card-highlight, -.template-card.card-highlight { - animation: cardHighlight 2s ease-in-out; +.template-card.card-highlight, +.dashboard-target.card-highlight { + animation: cardHighlight 2.2s ease-in-out; position: relative; z-index: 11; + /* Nudge the card forward during the highlight so the outer glow + isn't clipped by a containing overflow: hidden (strip cells, + tree-nav panels). Box-shadow is never clipped by the element's + own overflow but *is* clipped by parent overflow in stacking + contexts where the card doesn't escape. */ + isolation: isolate; } /* Dim overlay behind highlighted card */ diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index 95c8e4a..f565b74 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -93,13 +93,19 @@ } .badge { - padding: 4px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-weight: bold; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 2px; + border: var(--lux-hairline, 1px) solid transparent; + font-family: var(--font-mono, inherit); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; + line-height: 1.4; } .template-description { diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 756d7c7..0bc0469 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -599,9 +599,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) fpsTargetSum += tgt; } const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null; const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null; - updateTotalFps(fpsSum, fpsMin, fpsMax); + updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum); // Check if we can do an in-place metrics update (same targets, not first load) const newRunningIds = running.map(t => t.id).sort().join(','); diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index 0edb0ef..4dd7d83 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -51,6 +51,9 @@ let _appHistory: Record = { cpu: [], ram: [], gpu: [], temp: [ /** Peak FPS observed during the session — used as the y-axis ceiling for * the FPS sparkline so slow targets look proportional to fast ones. */ let _fpsPeak = 60; +/** Sum of fps_target across running targets — rendered as a dashed + * reference line on the FPS spark ("max achievable throughput"). */ +let _fpsTargetSum = 0; let _hasGpu: boolean | null = null; let _hasTemp: boolean | null = null; let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both'; @@ -156,7 +159,6 @@ export function renderPerfSection(): string {
${t('dashboard.perf.total_fps') || 'Total FPS'} -
@@ -234,16 +236,28 @@ function escapeText(s: string): string { /** Total FPS cell — pushed a new sample each dashboard refresh cycle. * `totalFps` is the sum of fps_actual across running targets; `minFps` - * / `maxFps` are the live extremes shown as a subdued subtitle. */ -export function updateTotalFps(totalFps: number, minFps: number | null, maxFps: number | null): void { + * / `maxFps` are the live extremes shown as a subdued subtitle; + * `targetSum` is the sum of each running target's fps_target and is + * drawn as a dashed "max" reference line on the spark. */ +export function updateTotalFps( + totalFps: number, + minFps: number | null, + maxFps: number | null, + targetSum: number = 0, +): void { const fps = Math.max(0, totalFps); _history.fps.push(fps); if (_history.fps.length > MAX_SAMPLES) _history.fps.shift(); if (fps > _fpsPeak) _fpsPeak = fps; + _fpsTargetSum = Math.max(0, targetSum || 0); const valEl = document.getElementById('perf-fps-value'); if (valEl) { - valEl.innerHTML = `${fps.toFixed(fps < 10 ? 1 : 0)}fps`; + const fpsText = fps.toFixed(fps < 10 ? 1 : 0); + const ceilingSuffix = _fpsTargetSum > 0 + ? `/ ${Math.round(_fpsTargetSum)}` + : ''; + valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`; } const subEl = document.getElementById('perf-fps-sub'); if (subEl) { @@ -310,15 +324,25 @@ function _renderChartSvg(key: string): void { const showSystem = _mode === 'system' || _mode === 'both'; const showApp = !isHostOnly && (_mode === 'app' || _mode === 'both'); - // Scale y per metric — temp varies 20..90°C; fps uses a session peak - // with a 60 floor so a 30 FPS signal fills ~half the cell; others - // are 0..100 %. + // Scale y per metric — temp varies 20..90°C; fps uses whichever is + // larger of the session peak or the target-sum ceiling with some + // headroom; others are 0..100 %. const yMin = key === 'temp' ? 20 : 0; const yMax = key === 'temp' ? 100 - : key === 'fps' ? Math.max(60, _fpsPeak * 1.1) + : key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1) : 100; const paths: string[] = []; + + // FPS-only: dashed "target ceiling" reference line at the sum of + // fps_target across running targets, so the spark reads as "live + // throughput relative to max achievable." + if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) { + const span = yMax - yMin || 1; + const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 1; + paths.push(``); + } + if (showSystem && sys.length > 1) { paths.push(_pathFor(sys, yMin, yMax, color, 'sys')); } @@ -585,9 +609,129 @@ async function _seedFromServer(): Promise { } } -/** Initialize perf section — paint from server-side history. */ +/** Initialize perf section — paint from server-side history and wire up + * spark hover tooltips. */ export async function initPerfCharts(): Promise { await _seedFromServer(); + _initSparkTooltip(); +} + +// ─── Spark hover tooltip ───────────────────────────────────────── + +/** Single shared tooltip + marker element lazy-created on first hover. */ +let _tooltipEl: HTMLDivElement | null = null; +let _tooltipMarkerEl: HTMLDivElement | null = null; + +function _ensureTooltip(): HTMLDivElement { + if (_tooltipEl) return _tooltipEl; + const el = document.createElement('div'); + el.className = 'perf-chart-tooltip'; + el.setAttribute('aria-hidden', 'true'); + document.body.appendChild(el); + _tooltipEl = el; + const marker = document.createElement('div'); + marker.className = 'perf-chart-tooltip-marker'; + marker.setAttribute('aria-hidden', 'true'); + document.body.appendChild(marker); + _tooltipMarkerEl = marker; + return el; +} + +/** Format a sampled value per metric for the tooltip line. */ +function _formatSampleValue(key: string, v: number): string { + if (key === 'temp') return `${v.toFixed(1)}°C`; + if (key === 'fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`; + return `${v.toFixed(1)}%`; +} + +function _metricLabel(key: string): string { + if (key === 'cpu') return 'CPU'; + if (key === 'ram') return 'RAM'; + if (key === 'gpu') return 'GPU'; + if (key === 'temp') return 'Temp'; + if (key === 'fps') return 'Total FPS'; + return key.toUpperCase(); +} + +function _initSparkTooltip(): void { + const intervalMs = dashboardPollInterval || 2000; + // Event-delegate from .perf-charts-grid so re-renders of the perf + // section don't require re-binding per spark. + const grid = document.querySelector('.perf-charts-grid'); + if (!grid) return; + + grid.addEventListener('mousemove', (rawEv) => { + const ev = rawEv as MouseEvent; + const target = ev.target as HTMLElement; + const spark = target.closest('.perf-chart-spark') as HTMLElement | null; + if (!spark) { _hideTooltip(); return; } + const card = spark.closest('.perf-chart-card') as HTMLElement | null; + if (!card) { _hideTooltip(); return; } + const key = card.dataset.metric; + if (!key || !_history[key]) { _hideTooltip(); return; } + + const rect = spark.getBoundingClientRect(); + const sys = _history[key]; + const app = _appHistory[key]; + if (sys.length < 2) { _hideTooltip(); return; } + + // Samples right-align in the spark (new tick arrives at the right + // edge), so cursor x → index in the last-N window. + const relX = Math.max(0, Math.min(rect.width, ev.clientX - rect.left)); + const fraction = rect.width > 0 ? relX / rect.width : 0; + // The visible series maps to the rightmost sys.length samples in + // a MAX_SAMPLES-wide viewBox — compute which actual sample the + // cursor x corresponds to. + const visibleStart = MAX_SAMPLES - sys.length; + const globalIdx = Math.round(fraction * (MAX_SAMPLES - 1)); + const localIdx = Math.max(0, Math.min(sys.length - 1, globalIdx - visibleStart)); + const sysValue = sys[localIdx]; + const appValue = app && app.length > localIdx ? app[localIdx] : null; + + // "-Ns ago" based on sample age (newest is rightmost). + const ageSecs = Math.round((sys.length - 1 - localIdx) * (intervalMs / 1000)); + + const tip = _ensureTooltip(); + const color = _getColor(key); + const sysLine = ` + ${_metricLabel(key)} + ${_formatSampleValue(key, sysValue)}`; + const appLine = (appValue != null) + ? `
+ + App + ${_formatSampleValue(key, appValue)} +
` + : ''; + const ageLine = `
${ageSecs === 0 ? 'now' : `−${ageSecs}s`}
`; + tip.innerHTML = `
${sysLine}
${appLine}${ageLine}`; + tip.style.display = 'block'; + + // Position tip above cursor, clamped to viewport. + const tipRect = tip.getBoundingClientRect(); + let tipLeft = ev.clientX - tipRect.width / 2; + let tipTop = rect.top - tipRect.height - 10; + if (tipTop < 6) tipTop = rect.bottom + 10; // flip below if no room above + tipLeft = Math.max(6, Math.min(window.innerWidth - tipRect.width - 6, tipLeft)); + tip.style.left = `${tipLeft}px`; + tip.style.top = `${tipTop}px`; + + // Vertical marker line over the spark at cursor x. + if (_tooltipMarkerEl) { + _tooltipMarkerEl.style.display = 'block'; + _tooltipMarkerEl.style.left = `${ev.clientX}px`; + _tooltipMarkerEl.style.top = `${rect.top}px`; + _tooltipMarkerEl.style.height = `${rect.height}px`; + _tooltipMarkerEl.style.setProperty('--marker-color', color); + } + }); + + grid.addEventListener('mouseleave', _hideTooltip); +} + +function _hideTooltip(): void { + if (_tooltipEl) _tooltipEl.style.display = 'none'; + if (_tooltipMarkerEl) _tooltipMarkerEl.style.display = 'none'; } export function startPerfPolling(): void {