feat(ui): Lumenworks treatment for Inputs / Integrations / Graph tabs

Brings the remaining tabs in line with the Channels-tab visual language:

- .template-card now mirrors .card and .dashboard-target — channel stripe
  on the left edge with glow, silkscreened corner bracket top-right,
  hairline border on --lux-bg-1, hover lift + stripe widen-and-glow.
  Covers streams, capture / pp / cspt / pattern / audio templates and
  every Integrations card (HA / MQTT / weather / value / sync clocks /
  game integrations).

- Channel mapping extended in cards.css. Direct attribute hooks for the
  per-domain ids; section-scoped hooks via [data-card-section="…"] for
  the cards that share a generic data-id (HA / MQTT / weather / value
  → cyan, game-integrations → amber, sync-clocks → violet,
  HA-light-targets → signal). No JS changes — uses the section markup
  CardSection.render already emits.

- Graph editor nodes pick up the studio-console palette: --lux-bg-1
  fill with hairline stroke, hover bold-line, selected/running stroke
  --ch-signal with drop-shadow glow. Title font moved off Big Shoulders
  Display (which read as "stretched" at 12 px) onto --font-body
  (Manrope); subtitle keeps the mono-uppercase caption treatment with a
  conservative letter-spacing. Running gradient now rides the channel
  palette (signal → cyan → signal) rather than the legacy primary /
  success colours. Port labels and grid dots adopt --lux-line tokens.

- Graph node titles get real text-overflow:ellipsis behaviour. SVG
  <text> can't do that natively, so renderNodes runs a post-mount fit
  pass that binary-searches the longest character prefix that fits
  inside the clip rect (with 2 px slack), suffixed with "…". Trailing
  whitespace is stripped before the ellipsis so we never get "Foo …".
  Full text is stashed on data-full-text so the fit can be re-run on
  re-renders.

Also bundles two perf-charts fixes from the same session:

- Hover regression — listener was bound to .perf-charts-grid, which
  rerenderPerfGrid() replaces. Moved to document.body with a guard, and
  the cursor → sample math now uses the same sliceN as the spark
  rendering so the tooltip stays accurate when the user changes the
  window setting.

- Color picker on every perf cell. Patches / Total FPS / Devices now
  expose the same color picker as the spark cells; defaults added to
  METRIC_CSS_VARS. Each card gets an inline --perf-accent on render so
  saved colours apply immediately, including across rerenderPerfGrid.
This commit is contained in:
2026-04-25 02:27:38 +03:00
parent 56853b7123
commit b43e1cf375
7 changed files with 269 additions and 71 deletions
+23
View File
@@ -114,6 +114,29 @@ Phases are independent and CSS-only where possible — backend untouched.
tabs.
- [x] Graph editor — toolbar gets a gradient background + hairline +
rack shadow + backdrop blur. Canvas and nodes untouched.
- [x] `.template-card` — Lumenworks treatment (channel stripe on left,
corner bracket top-right, hairline border, hover lift + stripe
glow). Brings Inputs (streams / capture / pp / cspt / pattern
templates) and Integrations (HA / MQTT / weather / value /
sync-clock / game-integration cards) up to the same visual
language as `.card` and `.dashboard-target`.
- [x] `cards.css` — channel mapping extended to `.template-card`.
Direct attr hooks for `data-stream-id`/`data-template-id`/`data-pp-template-id`
(cyan), `data-cspt-id`/`data-pattern-template-id` (signal),
`data-audio-template-id`/`data-apt-id` (magenta). Section-scoped
hooks via `[data-card-section="…"]` for cards that share a
generic `data-id` (HA / MQTT / weather / value → cyan;
game-integrations → amber; sync-clocks → violet; HA-light-targets
→ signal). No JS changes — uses the section markup `CardSection`
already emits.
- [x] Graph editor nodes — body fill `--lux-bg-1` with hairline stroke,
hover bold-line, selected/running stroke `--ch-signal` with
drop-shadow glow. Title font switched from DM Sans to
`--font-display`; subtitle to mono uppercase wide-tracking.
Port-drop-target glow recoloured to `--ch-signal`. Port labels
adopt the mono caption treatment. Grid dots use `--lux-line`.
Running gradient stops switched from `--primary-color`/`--success-color`
to channel palette (signal → cyan → signal).
### Phase 5 — Modal restyle
+40
View File
@@ -207,6 +207,46 @@ section {
.card[data-card-type="offline"],
.card.ch-coral { --ch: var(--ch-coral, var(--danger-color)); }
/* ── Channel mapping for `.template-card` ──
* Cards rendered by `wrapCard({ type: 'template-card' })` are used by the
* Inputs and Integrations tabs (plus a few other CardSection consumers).
* Many of those use a generic `data-id` attribute, so we scope by the
* parent section's `data-card-section` instead of relying on a unique
* data-attr per row. Direct attribute hooks come first for the cards that
* already carry a domain-specific id.
*/
/* Direct attribute hooks (Inputs tab — known per-domain attrs) */
.template-card[data-stream-id],
.template-card[data-template-id],
.template-card[data-pp-template-id] { --ch: var(--ch-cyan, var(--info-color)); }
.template-card[data-cspt-id],
.template-card[data-pattern-template-id] { --ch: var(--ch-signal, var(--primary-color)); }
.template-card[data-audio-template-id],
.template-card[data-apt-id] { --ch: var(--ch-magenta, #ff4ade); }
/* Section-scoped hooks (cards that share `data-id` and need their channel
* resolved via the surrounding section). Matches `<div class="subtab-section"
* data-card-section="…">` emitted by `CardSection.render`. */
/* Network / data-input integrations → cyan (input language) */
[data-card-section="ha-sources"] .template-card[data-id],
[data-card-section="mqtt-sources"] .template-card[data-id],
[data-card-section="weather-sources"] .template-card[data-id],
[data-card-section="value-sources"] .template-card[data-id] { --ch: var(--ch-cyan, var(--info-color)); }
/* Game integrations → amber (events / surfaces) */
[data-card-section="game-integrations"] .template-card[data-id],
.template-card[data-gi-id] { --ch: var(--ch-amber, var(--warning-color)); }
/* Sync clocks → violet (timing / orchestration, mirrors automation/scenes) */
[data-card-section="sync-clocks"] .template-card[data-id] { --ch: var(--ch-violet, #8b7eff); }
/* HA light targets → signal (output target, mirrors led-targets) */
[data-card-section="ha-light-targets"] .template-card[data-ha-target-id] { --ch: var(--ch-signal, var(--primary-color)); }
/* ── Card glare effect ── */
.card-glare::after,
.template-card.card-glare::after,
+39 -25
View File
@@ -410,8 +410,8 @@ html:has(#tab-graph.active) {
/* ── Grid background ── */
.graph-grid-dot {
fill: var(--border-color);
opacity: 0.3;
fill: var(--lux-line, var(--border-color));
opacity: 0.32;
}
/* ── Node styles ── */
@@ -430,21 +430,24 @@ html:has(#tab-graph.active) {
}
.graph-node-body {
fill: var(--card-bg);
stroke: none;
rx: 8;
ry: 8;
transition: stroke 0.15s;
fill: var(--lux-bg-1, var(--card-bg));
stroke: var(--lux-line, var(--border-color));
stroke-width: 1;
rx: 6;
ry: 6;
transition: stroke 0.15s, stroke-width 0.15s, filter 0.2s ease;
}
.graph-node:hover .graph-node-body {
stroke: var(--text-secondary);
stroke: var(--lux-line-bold, var(--text-secondary));
stroke-width: 1;
filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.25));
}
.graph-node.selected .graph-node-body {
stroke: var(--primary-color);
stroke: var(--ch-signal, var(--primary-color));
stroke-width: 2;
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
}
.graph-node-color-bar {
@@ -459,37 +462,45 @@ html:has(#tab-graph.active) {
}
.graph-node-title {
fill: var(--text-color);
fill: var(--lux-ink, var(--text-color));
font-size: 12px;
font-weight: 600;
font-family: 'DM Sans', sans-serif;
/* Body font, not display — Big Shoulders is condensed and reads as
* "stretched" at 12 px in a node label. Display font is for hero
* headers only. */
font-family: var(--font-body, 'Manrope', 'DM Sans', sans-serif);
letter-spacing: 0;
}
.graph-node-subtitle {
fill: var(--text-secondary);
font-size: 10px;
font-family: 'DM Sans', sans-serif;
fill: var(--lux-ink-dim, var(--text-secondary));
font-size: 9.5px;
font-weight: 600;
font-family: var(--font-mono, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.graph-node-icon {
stroke: var(--text-muted);
stroke: var(--lux-ink-mute, var(--text-muted));
fill: none;
stroke-width: 2;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.5;
opacity: 0.55;
}
.graph-node.running .graph-node-icon {
stroke: var(--primary-color);
opacity: 0.85;
stroke: var(--ch-signal, var(--primary-color));
opacity: 0.95;
}
/* ── Running indicator (animated gradient border) ── */
/* ── Running indicator (animated gradient border + signal-flow glow) ── */
.graph-node.running .graph-node-body {
stroke: url(#running-gradient);
stroke-width: 2;
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 50%, transparent));
}
@keyframes graph-running-rotate {
@@ -534,13 +545,16 @@ html:has(#tab-graph.active) {
/* Port labels — hidden by default, shown on node hover, positioned outside node */
.graph-port-label {
font-size: 9px;
font-weight: 600;
fill: var(--text-color);
font-weight: 700;
font-family: var(--font-mono, monospace);
letter-spacing: 0.08em;
text-transform: uppercase;
fill: var(--lux-ink-dim, var(--text-color));
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
paint-order: stroke fill;
stroke: var(--bg-color);
stroke: var(--lux-bg-0, var(--bg-color));
stroke-width: 3px;
stroke-linejoin: round;
}
@@ -569,9 +583,9 @@ html:has(#tab-graph.active) {
.graph-port-drop-target {
r: 7 !important;
stroke: var(--primary-color) !important;
stroke: var(--ch-signal, var(--primary-color)) !important;
stroke-width: 3 !important;
filter: drop-shadow(0 0 6px var(--primary-color));
filter: drop-shadow(0 0 6px var(--ch-signal, var(--primary-color)));
}
/* ── Edges ── */
+40 -6
View File
@@ -9,19 +9,53 @@
}
.template-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
--ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, var(--radius-md));
padding: 18px 20px 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* Channel stripe on left edge — colour-coded per entity type via --ch override */
.template-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--ch);
box-shadow: 0 0 10px color-mix(in srgb, var(--ch) 40%, transparent);
pointer-events: none;
z-index: 1;
transition: width 0.2s ease, box-shadow 0.2s ease;
}
/* Corner bracket — silkscreened panel feel in the top-right */
.template-card::after {
content: '';
position: absolute;
top: 8px; right: 8px;
width: 12px; height: 12px;
border-top: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-right: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
pointer-events: none;
opacity: 0.7;
z-index: 1;
}
.template-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
box-shadow: var(--lux-shadow-rack, 0 8px 24px var(--shadow-color));
transform: translateY(-2px);
border-color: var(--lux-line-bold, var(--border-color));
}
.template-card:hover::before {
width: 4px;
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent);
}
.add-template-card {
@@ -133,9 +133,54 @@ export function renderNodes(group: SVGGElement, nodeMap: Map<string, GraphNode>,
for (const node of nodeMap.values()) {
const g = renderNode(node, callbacks);
group.appendChild(g);
// Now that the <g> is in the live SVG, `getComputedTextLength()`
// returns real values — fit the title/subtitle to the visible
// text area and append "…" if they overflow.
_fitNodeText(g, node.width);
}
}
/** Available text width per node — clip rect is x=14..(width-48) wide and
* text starts at x=16, so the usable run is `width - 50`. The 2 px slack
* on the right keeps the ellipsis from kissing the clip edge. */
function _availableTextWidth(nodeWidth: number): number {
return Math.max(0, nodeWidth - 52);
}
/** Replace the text of an SVG `<text>` element with the longest prefix of
* its `data-full-text` that fits within `maxWidth`, suffixed with "…".
* No-op if the full text already fits. */
function _fitTextToWidth(el: SVGTextElement, maxWidth: number): void {
const full = el.getAttribute('data-full-text') || el.textContent || '';
el.textContent = full;
if (maxWidth <= 0) { el.textContent = ''; return; }
let len = 0;
try { len = el.getComputedTextLength(); } catch { return; }
if (len <= maxWidth) return;
// Binary search for the longest character prefix that fits with "…".
let lo = 0, hi = full.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
el.textContent = full.slice(0, mid).trimEnd() + '…';
try {
if (el.getComputedTextLength() <= maxWidth) lo = mid;
else hi = mid - 1;
} catch {
return;
}
}
el.textContent = (full.slice(0, lo).trimEnd() || '') + '…';
}
function _fitNodeText(nodeG: Element, nodeWidth: number): void {
const maxW = _availableTextWidth(nodeWidth);
const title = nodeG.querySelector<SVGTextElement>('.graph-node-title');
const subtitle = nodeG.querySelector<SVGTextElement>('.graph-node-subtitle');
if (title) _fitTextToWidth(title, maxW);
if (subtitle) _fitTextToWidth(subtitle, maxW);
}
/**
* Render a single node.
*/
@@ -342,23 +387,30 @@ function renderNode(node: GraphNode, callbacks: NodeCallbacks): SVGElement {
clipPath.appendChild(svgEl('rect', { x: 14, y: 0, width: width - 48, height }));
g.appendChild(clipPath);
// Title (shift left edge for icon to have room)
// Title (shift left edge for icon to have room).
// Full text is stashed on `data-full-text` so the post-mount fit pass
// can measure with `getComputedTextLength()` and binary-search the
// longest prefix that fits, appending "…" instead of relying on the
// clip-path (which silently chops mid-glyph with no ellipsis cue).
const title = svgEl('text', {
class: 'graph-node-title',
x: 16, y: 24,
'clip-path': `url(#${clipId})`,
'data-full-text': name,
});
title.textContent = name;
g.appendChild(title);
// Subtitle (type)
if (subtype) {
const subText = subtype.replace(/_/g, ' ');
const sub = svgEl('text', {
class: 'graph-node-subtitle',
x: 16, y: 42,
'clip-path': `url(#${clipId})`,
'data-full-text': subText,
});
sub.textContent = subtype.replace(/_/g, ' ');
sub.textContent = subText;
g.appendChild(sub);
}
@@ -1140,9 +1140,9 @@ function _graphHTML(): string {
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--primary-color)"/>
<stop offset="50%" stop-color="var(--success-color)"/>
<stop offset="100%" stop-color="var(--primary-color)"/>
<stop offset="0%" stop-color="var(--ch-signal, var(--primary-color))"/>
<stop offset="50%" stop-color="var(--ch-cyan, var(--info-color))"/>
<stop offset="100%" stop-color="var(--ch-signal, var(--primary-color))"/>
</linearGradient>
</defs>
<rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
@@ -17,6 +17,10 @@ import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardL
const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps'] as const;
/** Every cell key the user can color-customize, including the
* patches / devices cells that don't have sparklines but still
* carry a header accent stripe. */
const ALL_COLORABLE_KEYS = ['patches', 'fps', 'devices', 'cpu', 'ram', 'gpu', 'temp'] as const;
const PERF_MODE_KEY = 'perfMetricsMode';
const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio)
const SPARK_H = 64;
@@ -28,20 +32,24 @@ const HOST_ONLY_KEYS = new Set(['temp', 'fps']);
perf cards share the same language as the rest of the app. Overrides
per-user in localStorage still honoured by `_getColor`. */
const METRIC_CSS_VARS: Record<string, string> = {
cpu: '--ch-coral',
ram: '--ch-violet',
gpu: '--ch-signal',
temp: '--ch-amber',
fps: '--ch-cyan',
patches: '--ch-magenta',
fps: '--ch-cyan',
devices: '--ch-signal',
cpu: '--ch-coral',
ram: '--ch-violet',
gpu: '--ch-signal',
temp: '--ch-amber',
};
/** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */
const METRIC_FALLBACK: Record<string, string> = {
cpu: '#FF6B6B',
ram: '#A855F7',
gpu: '#10B981',
temp: '#FCD34D',
fps: '#00D8FF',
patches: '#EC4899',
fps: '#00D8FF',
devices: '#10B981',
cpu: '#FF6B6B',
ram: '#A855F7',
gpu: '#10B981',
temp: '#FCD34D',
};
type PerfMode = 'system' | 'app' | 'both';
@@ -142,14 +150,25 @@ export function setPerfMode(mode: PerfMode): void {
/** Returns the static HTML for the perf section. */
export function renderPerfSection(): string {
_syncMode();
for (const key of CHART_KEYS) {
for (const key of ALL_COLORABLE_KEYS) {
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
}
/** Color-picker widget rendered next to each cell's label. Even
* cells without sparklines (patches/devices) get one — it drives
* the card's `--perf-accent` CSS var for the header stripe. */
const colorWidget = (key: string) => createColorPicker({
id: `perf-${key}`,
currentColor: _getColor(key),
onPick: undefined,
anchor: 'left',
showReset: true,
});
const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => `
<div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}"${hiddenByEnv ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}>
<div class="perf-chart-card" data-metric="${key}" data-perf-mode="${_mode}" style="--perf-accent:${_getColor(key)}"${hiddenByEnv ? ' hidden' : ''}${key === 'gpu' || key === 'temp' ? ` id="perf-${key}-card"` : ''}>
<div class="perf-chart-header">
<span class="perf-chart-label">${t(labelKey)} ${createColorPicker({ id: `perf-${key}`, currentColor: _getColor(key), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-label">${t(labelKey)} ${colorWidget(key)}</span>
<span class="perf-chart-app" id="perf-${key}-app" aria-hidden="true"></span>
</div>
<div class="perf-chart-body">
@@ -162,9 +181,9 @@ export function renderPerfSection(): string {
</div>`;
const patchesCell = `
<div class="perf-chart-card perf-patches-cell" data-metric="patches" data-perf-mode="${_mode}">
<div class="perf-chart-card perf-patches-cell" data-metric="patches" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('patches')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.active_patches') || 'Active Patches'}</span>
<span class="perf-chart-label">${t('dashboard.perf.active_patches') || 'Active Patches'} ${colorWidget('patches')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
@@ -177,9 +196,9 @@ export function renderPerfSection(): string {
</div>`;
const fpsCell = `
<div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}">
<div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('fps')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'}</span>
<span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'} ${colorWidget('fps')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
@@ -191,9 +210,9 @@ export function renderPerfSection(): string {
</div>`;
const devicesCell = `
<div class="perf-chart-card perf-devices-cell" data-metric="devices">
<div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.devices') || 'Devices'}</span>
<span class="perf-chart-label">${t('dashboard.perf.devices') || 'Devices'} ${colorWidget('devices')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
@@ -790,16 +809,19 @@ function _metricLabel(key: string): string {
return key.toUpperCase();
}
let _tooltipBound = false;
function _initSparkTooltip(): void {
if (_tooltipBound) return;
_tooltipBound = true;
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) => {
// Bound on `document.body` instead of `.perf-charts-grid` so the
// listener survives `rerenderPerfGrid()` replacing the grid element.
// The handler bails out unless the cursor is actually over a spark,
// so the hot-path cost is just one `closest()` call per mousemove.
document.body.addEventListener('mousemove', (rawEv) => {
const ev = rawEv as MouseEvent;
const target = ev.target as HTMLElement;
if (!target || !target.closest) { _hideTooltip(); return; }
const spark = target.closest('.perf-chart-spark') as HTMLElement | null;
if (!spark) { _hideTooltip(); return; }
const card = spark.closest('.perf-chart-card') as HTMLElement | null;
@@ -808,19 +830,30 @@ function _initSparkTooltip(): void {
if (!key || !_history[key]) { _hideTooltip(); return; }
const rect = spark.getBoundingClientRect();
const sys = _history[key];
const app = _appHistory[key];
if (sys.length < 2) { _hideTooltip(); return; }
const sysFull = _history[key];
const appFull = _appHistory[key];
if (sysFull.length < 2) { _hideTooltip(); return; }
// Tooltip must read from the same slice the spark draws — otherwise
// the cursor x → sample mapping skews after the user changes the
// window setting. Effective window in seconds × samples-per-sec
// (clamped to MAX_SAMPLES) gives the visible slice length.
const cfg = (() => { try { return getGlobalConfig(); } catch { return null; } })();
const winSec = (() => { try { return effectivePerfWindow(key); } catch { return 120; } })();
const samplesPerSec = cfg ? Math.max(0.5, 1000 / Math.max(50, cfg.pollMs)) : 1;
const sliceN = Math.min(MAX_SAMPLES, Math.max(2, Math.round(winSec * samplesPerSec)));
const sys = sysFull.slice(-sliceN);
const app = appFull.slice(-sliceN);
// 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));
// The visible series maps to the rightmost `sys.length` samples in
// a `sliceN`-wide spark — compute which actual sample the cursor x
// corresponds to.
const visibleStart = sliceN - sys.length;
const globalIdx = Math.round(fraction * (sliceN - 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;
@@ -862,8 +895,10 @@ function _initSparkTooltip(): void {
_tooltipMarkerEl.style.setProperty('--marker-color', color);
}
});
grid.addEventListener('mouseleave', _hideTooltip);
// Hide whenever the mouse leaves the body (out of viewport) — when
// moving between sparks within the body, the mousemove handler above
// re-positions the tip on the next sample.
document.body.addEventListener('mouseleave', _hideTooltip);
}
function _hideTooltip(): void {