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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user