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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user