Graph node FPS hover tooltip, full names, no native SVG tooltips

Graph editor:
- Floating FPS tooltip on hover over running output_target nodes (300ms delay)
- Shows errors, uptime, and FPS sparkline seeded from server metrics history
- Tooltip positioned below node with fade-in/out animation
- Uses pointerover/pointerout with relatedTarget check to prevent flicker
- Fixed-width tooltip (200px) with monospace values to prevent layout shift
- Node titles show full names (removed truncate), no native SVG <title> tooltips

Documentation:
- Added duration/numeric formatting conventions to contexts/frontend.md
- Added node hover tooltip docs to contexts/graph-editor.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 15:45:59 +03:00
parent afd4a3bc05
commit 191c988cf9
8 changed files with 342 additions and 7 deletions

View File

@@ -219,6 +219,31 @@ When adding a new JS dependency: `npm install <pkg>` in `server/`, then `import`
See [`contexts/chrome-tools.md`](chrome-tools.md) for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
## Duration & Numeric Formatting
### Uptime / duration values
Use `formatUptime(seconds)` from `core/ui.js`. Outputs `{s}s`, `{m}m {s}s`, or `{h}h {m}m` via i18n keys `time.seconds`, `time.minutes_seconds`, `time.hours_minutes`.
### Large numbers
Use `formatCompact(n)` from `core/ui.js`. Outputs `1.2K`, `3.5M` etc. Set `element.title` to the exact value for hover detail.
### Preventing layout shift
Numeric/duration values that update frequently (FPS, uptime, frame counts) **must** use fixed-width styling to prevent layout reflow:
- `font-family: var(--font-mono, monospace)` — equal-width characters
- `font-variant-numeric: tabular-nums` — equal-width digits in proportional fonts
- Fixed `width` or `min-width` on the value container
- `text-align: right` to anchor the growing edge
Reference: `.dashboard-metric-value` in `dashboard.css` uses `font-family: var(--font-mono)`, `font-weight: 600`, `min-width: 48px`.
### FPS sparkline charts
Use `createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget)` from `core/chart-utils.js`. Wrap the canvas in `.target-fps-sparkline` (36px height, `position: relative`, `overflow: hidden`). Show the value in `.target-fps-label` with `.metric-value` and `.target-fps-avg`.
## Visual Graph Editor
See [`contexts/graph-editor.md`](graph-editor.md) for full graph editor architecture and conventions.

View File

@@ -88,6 +88,14 @@ The filter bar (toggled with F or toolbar button) filters nodes by name/kind/sub
Rendered as a small SVG with colored rects for each node and a viewport rect. Supports drag-to-pan, resize handles, and position persistence in localStorage.
## Node hover FPS tooltip
Running `output_target` nodes show a floating HTML tooltip on hover (300ms delay). The tooltip is an absolutely-positioned `<div class="graph-node-tooltip">` inside `.graph-container` (not SVG — needed for Chart.js canvas). It displays errors, uptime, and a FPS sparkline (reusing `createFpsSparkline` from `core/chart-utils.js`). The sparkline is seeded from `/api/v1/system/metrics-history` for instant context.
**Hover events** use `pointerover`/`pointerout` with `relatedTarget` check to prevent flicker when the cursor moves between child SVG elements within the same `<g>` node.
**Node titles** display the full entity name (no truncation). Native SVG `<title>` tooltips are omitted on nodes to avoid conflict with the custom tooltip.
## New entity focus
When a user adds an entity via the graph's + menu, a watcher subscribes to all caches, detects the new ID, reloads the graph, and uses `zoomToPoint()` to smoothly fly to the new node with zoom + highlight animation.

View File

@@ -1172,3 +1172,62 @@ html:has(#tab-graph.active) {
background: var(--border-color);
margin: 4px 0;
}
/* ── Node hover FPS tooltip ── */
.graph-node-tooltip {
position: absolute;
z-index: 50;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 6px);
box-shadow: 0 4px 14px var(--shadow-color, rgba(0,0,0,0.25));
padding: 8px 12px;
pointer-events: none;
font-size: 0.8rem;
width: 200px;
color: var(--text-color);
}
.graph-node-tooltip .gnt-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
line-height: 1.6;
}
.graph-node-tooltip .gnt-label {
color: var(--text-muted);
white-space: nowrap;
}
.graph-node-tooltip .gnt-value {
font-variant-numeric: tabular-nums;
font-weight: 500;
text-align: right;
min-width: 72px;
display: inline-block;
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
}
.graph-node-tooltip .gnt-fps-row {
margin-top: 4px;
padding: 2px 0;
background: transparent;
}
.graph-node-tooltip.gnt-fade-in {
animation: gntFadeIn 0.15s ease-out forwards;
}
.graph-node-tooltip.gnt-fade-out {
animation: gntFadeOut 0.12s ease-in forwards;
}
@keyframes gntFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes gntFadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(4px); }
}

View File

@@ -292,7 +292,7 @@ function renderNode(node, callbacks) {
class: 'graph-node-title',
x: 16, y: 24,
});
title.textContent = truncate(name, 18);
title.textContent = name;
g.appendChild(title);
// Subtitle (type)
@@ -305,11 +305,6 @@ function renderNode(node, callbacks) {
g.appendChild(sub);
}
// Tooltip
const tip = svgEl('title');
tip.textContent = `${name} (${kind.replace(/_/g, ' ')})`;
g.appendChild(tip);
// Hover overlay (action buttons)
const overlay = _createOverlay(node, width, callbacks);
g.appendChild(overlay);

View File

@@ -14,7 +14,8 @@ import {
automationsCacheObj, csptCache,
} from '../core/state.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast, showConfirm } from '../core/ui.js';
import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { t } from '../core/i18n.js';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
import { showTypePicker } from '../core/icon-select.js';
@@ -691,6 +692,7 @@ function _renderGraph(container) {
_initToolbarDrag(container.querySelector('.graph-toolbar'));
_initResizeClamp(container);
_initNodeDrag(nodeGroup, edgeGroup);
_initNodeHoverTooltip(nodeGroup, container);
_initPortDrag(svgEl, nodeGroup, edgeGroup);
_initRubberBand(svgEl);
@@ -2238,6 +2240,243 @@ async function _detachSelectedEdge() {
}
}
/* ── Node hover FPS tooltip ── */
let _hoverTooltip = null; // the <div> element, created once per graph render
let _hoverTooltipChart = null; // Chart.js instance
let _hoverTimer = null; // 300ms delay timer
let _hoverPollInterval = null; // 1s polling interval
let _hoverNodeId = null; // currently shown node id
let _hoverFpsHistory = []; // rolling fps_actual samples
let _hoverFpsCurrentHistory = []; // rolling fps_current samples
const HOVER_DELAY_MS = 300;
const HOVER_HISTORY_LEN = 20;
function _initNodeHoverTooltip(nodeGroup, container) {
// Create or reset the tooltip element
container.querySelector('.graph-node-tooltip')?.remove();
const tip = document.createElement('div');
tip.className = 'graph-node-tooltip';
tip.style.display = 'none';
tip.innerHTML = `
<div class="gnt-row"><span class="gnt-label">${t('graph.tooltip.errors')}</span><span class="gnt-value" data-gnt="errors">—</span></div>
<div class="gnt-row"><span class="gnt-label">${t('graph.tooltip.uptime')}</span><span class="gnt-value" data-gnt="uptime">—</span></div>
<div class="target-fps-row gnt-fps-row">
<div class="target-fps-sparkline"><canvas id="gnt-sparkline-canvas"></canvas></div>
<div class="target-fps-label">
<span class="metric-value" data-gnt="fps">—</span>
<span class="target-fps-avg" data-gnt="fps-avg"></span>
</div>
</div>
`;
container.appendChild(tip);
_hoverTooltip = tip;
_hoverTooltipChart = null;
nodeGroup.addEventListener('pointerover', (e) => {
const nodeEl = e.target.closest('.graph-node.running[data-kind="output_target"]');
if (!nodeEl) return;
const nodeId = nodeEl.getAttribute('data-id');
if (!nodeId) return;
// Already showing for this node — nothing to do
if (_hoverNodeId === nodeId && tip.style.display !== 'none') return;
clearTimeout(_hoverTimer);
_hoverTimer = setTimeout(() => {
_showNodeTooltip(nodeId, nodeEl, container);
}, HOVER_DELAY_MS);
});
nodeGroup.addEventListener('pointerout', (e) => {
const nodeEl = e.target.closest('.graph-node');
if (!nodeEl) return;
// Ignore if pointer moved to another child of the same node
const related = e.relatedTarget;
if (related && nodeEl.contains(related)) return;
clearTimeout(_hoverTimer);
_hoverTimer = null;
const nodeId = nodeEl.getAttribute('data-id');
if (nodeId === _hoverNodeId) {
_hideNodeTooltip();
}
}, true);
}
function _positionTooltip(nodeEl, container) {
if (!_canvas || !_hoverTooltip) return;
const node = _nodeMap?.get(_hoverNodeId);
if (!node) return;
// Convert graph-coordinate node origin to container-relative CSS pixels
const cssX = (node.x - _canvas.viewX) * _canvas.zoom;
const cssY = (node.y - _canvas.viewY) * _canvas.zoom;
const cssW = node.width * _canvas.zoom;
const tipW = _hoverTooltip.offsetWidth || 180;
const tipH = _hoverTooltip.offsetHeight || 120;
const contW = container.offsetWidth;
const contH = container.offsetHeight;
const cssH = node.height * _canvas.zoom;
// Position below the node, centered horizontally
let left = cssX + (cssW - tipW) / 2;
left = Math.max(8, Math.min(left, contW - tipW - 8));
let top = cssY + cssH + 8;
// If no room below, show above
if (top + tipH > contH - 8) {
top = cssY - tipH - 8;
}
top = Math.max(8, top);
_hoverTooltip.style.left = `${left}px`;
_hoverTooltip.style.top = `${top}px`;
}
async function _showNodeTooltip(nodeId, nodeEl, container) {
if (!_hoverTooltip) return;
_hoverNodeId = nodeId;
_hoverFpsHistory = [];
_hoverFpsCurrentHistory = [];
// Destroy previous chart
if (_hoverTooltipChart) {
_hoverTooltipChart.destroy();
_hoverTooltipChart = null;
}
// Seed from server-side metrics history (non-blocking)
try {
const histResp = await fetchWithAuth('/system/metrics-history');
if (_hoverNodeId !== nodeId) return; // user moved away during fetch
if (histResp.ok) {
const hist = await histResp.json();
const samples = hist.targets?.[nodeId] || [];
for (const s of samples) {
if (s.fps != null) _hoverFpsHistory.push(s.fps);
if (s.fps_current != null) _hoverFpsCurrentHistory.push(s.fps_current);
}
// Trim to max length
if (_hoverFpsHistory.length > HOVER_HISTORY_LEN)
_hoverFpsHistory.splice(0, _hoverFpsHistory.length - HOVER_HISTORY_LEN);
if (_hoverFpsCurrentHistory.length > HOVER_HISTORY_LEN)
_hoverFpsCurrentHistory.splice(0, _hoverFpsCurrentHistory.length - HOVER_HISTORY_LEN);
}
} catch (_) { /* ignore — will populate from polls */ }
if (_hoverNodeId !== nodeId) return;
_hoverTooltip.style.display = '';
_hoverTooltip.classList.remove('gnt-fade-out');
_hoverTooltip.classList.add('gnt-fade-in');
_positionTooltip(nodeEl, container);
// Immediate first fetch (also creates the chart with seeded history)
_fetchTooltipMetrics(nodeId, container, nodeEl);
// Poll every 1s
clearInterval(_hoverPollInterval);
_hoverPollInterval = setInterval(() => {
_fetchTooltipMetrics(nodeId, container, nodeEl);
}, 1000);
}
function _hideNodeTooltip() {
clearInterval(_hoverPollInterval);
_hoverPollInterval = null;
_hoverNodeId = null;
if (_hoverTooltipChart) {
_hoverTooltipChart.destroy();
_hoverTooltipChart = null;
}
if (_hoverTooltip) {
_hoverTooltip.classList.remove('gnt-fade-in');
_hoverTooltip.classList.add('gnt-fade-out');
_hoverTooltip.addEventListener('animationend', () => {
if (_hoverTooltip.classList.contains('gnt-fade-out')) {
_hoverTooltip.style.display = 'none';
}
}, { once: true });
}
}
async function _fetchTooltipMetrics(nodeId, container, nodeEl) {
if (_hoverNodeId !== nodeId) return;
try {
const [metricsResp, stateResp] = await Promise.all([
fetchWithAuth(`/output-targets/${nodeId}/metrics`),
fetchWithAuth(`/output-targets/${nodeId}/state`),
]);
if (_hoverNodeId !== nodeId) return; // node changed while fetching
const metrics = metricsResp.ok ? await metricsResp.json() : {};
const state = stateResp.ok ? await stateResp.json() : {};
const fpsActual = state.fps_actual ?? 0;
const fpsTarget = state.fps_target ?? 30;
const fpsCurrent = state.fps_current ?? 0;
const errorsCount = metrics.errors_count ?? 0;
const uptimeSec = metrics.uptime_seconds ?? 0;
// Update text rows
const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]');
const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]');
const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]');
if (fpsEl) fpsEl.innerHTML = `${fpsCurrent}<span class="target-fps-target">/${fpsTarget}</span>`;
const avgEl = _hoverTooltip.querySelector('[data-gnt="fps-avg"]');
if (avgEl && _hoverFpsHistory.length > 0) {
const avg = _hoverFpsHistory.reduce((a, b) => a + b, 0) / _hoverFpsHistory.length;
avgEl.textContent = `avg ${avg.toFixed(1)}`;
}
if (errorsEl) errorsEl.textContent = formatCompact(errorsCount);
if (uptimeEl) uptimeEl.textContent = formatUptime(uptimeSec);
// Push sparkline history
_hoverFpsHistory.push(fpsActual);
_hoverFpsCurrentHistory.push(fpsCurrent);
if (_hoverFpsHistory.length > HOVER_HISTORY_LEN) _hoverFpsHistory.shift();
if (_hoverFpsCurrentHistory.length > HOVER_HISTORY_LEN) _hoverFpsCurrentHistory.shift();
// Update or create chart
if (_hoverTooltipChart) {
_hoverTooltipChart.data.labels = _hoverFpsHistory.map(() => '');
_hoverTooltipChart.data.datasets[0].data = [..._hoverFpsHistory];
_hoverTooltipChart.data.datasets[1].data = [..._hoverFpsCurrentHistory];
_hoverTooltipChart.options.scales.y.max = fpsTarget * 1.15;
_hoverTooltipChart.update('none');
} else {
// Seed history arrays with the first value so chart renders immediately
const seedActual = _hoverFpsHistory.slice();
const seedCurrent = _hoverFpsCurrentHistory.slice();
_hoverTooltipChart = createFpsSparkline(
'gnt-sparkline-canvas',
seedActual,
seedCurrent,
fpsTarget,
);
}
// Re-position in case tooltip changed size
_positionTooltip(nodeEl, container);
} catch (_) {
// Silently ignore fetch errors — tooltip will retry on next interval
}
}
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
document.addEventListener('languageChanged', () => {
if (_initialized && _nodeMap) {

View File

@@ -1656,6 +1656,9 @@
"graph.help.drag_port_desc": "Connect entities",
"graph.help.right_click": "Right-click edge",
"graph.help.right_click_desc": "Detach connection",
"graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Errors",
"graph.tooltip.uptime": "Uptime",
"automation.enabled": "Automation enabled",
"automation.disabled": "Automation disabled",
"scene_preset.activated": "Preset activated",

View File

@@ -1656,6 +1656,9 @@
"graph.help.drag_port_desc": "Соединить сущности",
"graph.help.right_click": "ПКМ по связи",
"graph.help.right_click_desc": "Отсоединить связь",
"graph.tooltip.fps": "FPS",
"graph.tooltip.errors": "Ошибки",
"graph.tooltip.uptime": "Время работы",
"automation.enabled": "Автоматизация включена",
"automation.disabled": "Автоматизация выключена",
"scene_preset.activated": "Пресет активирован",

View File

@@ -1656,6 +1656,9 @@
"graph.help.drag_port_desc": "连接实体",
"graph.help.right_click": "右键边线",
"graph.help.right_click_desc": "断开连接",
"graph.tooltip.fps": "帧率",
"graph.tooltip.errors": "错误",
"graph.tooltip.uptime": "运行时间",
"automation.enabled": "自动化已启用",
"automation.disabled": "自动化已禁用",
"scene_preset.activated": "预设已激活",