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:
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Пресет активирован",
|
||||
|
||||
@@ -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": "预设已激活",
|
||||
|
||||
Reference in New Issue
Block a user