From 191c988cf956d7d447bffe007f48128a43c8f176 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 17 Mar 2026 15:45:59 +0300 Subject: [PATCH] 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 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> --- contexts/frontend.md | 25 ++ contexts/graph-editor.md | 8 + .../static/css/graph-editor.css | 59 +++++ .../static/js/core/graph-nodes.js | 7 +- .../static/js/features/graph-editor.js | 241 +++++++++++++++++- .../wled_controller/static/locales/en.json | 3 + .../wled_controller/static/locales/ru.json | 3 + .../wled_controller/static/locales/zh.json | 3 + 8 files changed, 342 insertions(+), 7 deletions(-) diff --git a/contexts/frontend.md b/contexts/frontend.md index 7b8dd83..2ca8e60 100644 --- a/contexts/frontend.md +++ b/contexts/frontend.md @@ -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. diff --git a/contexts/graph-editor.md b/contexts/graph-editor.md index 81968dc..681919a 100644 --- a/contexts/graph-editor.md +++ b/contexts/graph-editor.md @@ -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. diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index ad279a7..a92e3bd 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -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); } +} diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index d871439..2102ea0 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -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); diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index 902ad54..eae59ae 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -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) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index a42e491..46d62a5 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 8b31523..84f606a 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Пресет активирован", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 55d8fe0..cae6b3b 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "预设已激活",