From 5c7c2ad1b23ae4f3aa0641a3f5400b108c189933 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Mar 2026 17:48:55 +0300 Subject: [PATCH] Enhance graph editor: fullscreen bg, add-entity focus, color picker fix, UI polish - Move bg-anim canvas into graph container during fullscreen so dynamic background is visible - Watch for new entity creation from graph add menu and auto-navigate to it after reload - Position color picker at click coordinates instead of 0,0 - Replace test/preview play triangle with eye icon to distinguish from start/stop - Always use port-aware bezier curves for edges instead of ELK routing - Add fullscreen and add-entity buttons to toolbar with keyboard shortcuts (F11, +) - Add confirmation dialog for relayout when manual positions exist - Remove node body stroke, keep only color bar; add per-node color picker - Clamp toolbar position on load to prevent off-screen drift - Add graph tab to getting-started tutorial - Add WASD/arrow spatial navigation, ESC reset, keyboard shortcuts Co-Authored-By: Claude Opus 4.6 --- .../static/css/graph-editor.css | 76 ++++- server/src/wled_controller/static/js/app.js | 3 + .../static/js/core/graph-edges.js | 13 +- .../static/js/core/graph-nodes.js | 67 +++- .../static/js/features/graph-editor.js | 287 +++++++++++++++++- .../static/js/features/tutorials.js | 1 + .../wled_controller/static/locales/en.json | 7 +- .../wled_controller/static/locales/ru.json | 7 +- .../wled_controller/static/locales/zh.json | 7 +- 9 files changed, 446 insertions(+), 22 deletions(-) diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 3dd8f9c..a3f6ddf 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -256,8 +256,7 @@ .graph-node-body { fill: var(--card-bg); - stroke: var(--border-color); - stroke-width: 1; + stroke: none; rx: 8; ry: 8; transition: stroke 0.15s; @@ -265,6 +264,7 @@ .graph-node:hover .graph-node-body { stroke: var(--text-secondary); + stroke-width: 1; } .graph-node.selected .graph-node-body { @@ -561,6 +561,7 @@ .graph-node.orphan .graph-node-body { stroke: var(--warning-color); + stroke-width: 1; stroke-dasharray: 4 3; } @@ -679,6 +680,77 @@ /* ── Loading overlay for relayout ── */ +/* ── Add entity menu ── */ + +.graph-add-entity-menu { + position: absolute; + z-index: 30; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 8px 24px var(--shadow-color); + padding: 6px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + min-width: 280px; +} + +.graph-add-entity-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: none; + background: transparent; + color: var(--text-color); + font-size: 0.8rem; + font-family: inherit; + text-align: left; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; + white-space: nowrap; +} + +.graph-add-entity-item:hover { + background: var(--bg-secondary); +} + +.graph-add-entity-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; +} + +.graph-add-entity-icon { + font-size: 1rem; + flex-shrink: 0; +} + +/* ── Fullscreen mode ── */ + +.graph-container:fullscreen { + background: var(--bg-color); + height: 100vh; +} + +.graph-container:fullscreen #bg-anim-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; +} + +.graph-container:fullscreen .graph-svg { + position: relative; + z-index: 1; +} + +/* ── Loading overlay for relayout ── */ + .graph-loading-overlay { position: absolute; inset: 0; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index a86a6b1..9634973 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -162,6 +162,7 @@ import { loadGraphEditor, openGraphSearch, closeGraphSearch, toggleGraphLegend, toggleGraphMinimap, graphFitAll, graphZoomIn, graphZoomOut, graphRelayout, + graphToggleFullscreen, graphAddEntity, } from './features/graph-editor.js'; // Layer 6: tabs, navigation, command palette, settings @@ -472,6 +473,8 @@ Object.assign(window, { graphZoomIn, graphZoomOut, graphRelayout, + graphToggleFullscreen, + graphAddEntity, // tabs / navigation / command palette switchTab, diff --git a/server/src/wled_controller/static/js/core/graph-edges.js b/server/src/wled_controller/static/js/core/graph-edges.js index 4664d85..9d7d4ac 100644 --- a/server/src/wled_controller/static/js/core/graph-edges.js +++ b/server/src/wled_controller/static/js/core/graph-edges.js @@ -51,16 +51,11 @@ function _createArrowMarker(type) { } function _renderEdge(edge) { - const { from, to, type, points, fromNode, toNode, field, editable } = edge; + const { from, to, type, fromNode, toNode, field, editable } = edge; const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`; - let d; - if (points) { - // Adjust ELK start/end points to match port positions - const adjusted = _adjustEndpoints(points, fromNode, toNode, edge.fromPortY, edge.toPortY); - d = _pointsToPath(adjusted); - } else { - d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY); - } + // Always use port-aware bezier — ELK routes without port knowledge so + // its bend points don't align with actual port positions. + const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY); const path = svgEl('path', { class: cssClass, 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 0b14954..b8cb3b7 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -74,9 +74,30 @@ export function renderNodes(group, nodeMap, callbacks = {}) { /** * Render a single node. */ +// Per-node color overrides (persisted in localStorage) +const _NC_KEY = 'graph_node_colors'; +let _nodeColorOverrides = null; + +function _loadNodeColors() { + if (_nodeColorOverrides) return _nodeColorOverrides; + try { _nodeColorOverrides = JSON.parse(localStorage.getItem(_NC_KEY)) || {}; } catch { _nodeColorOverrides = {}; } + return _nodeColorOverrides; +} + +function _saveNodeColor(nodeId, color) { + const map = _loadNodeColors(); + map[nodeId] = color; + localStorage.setItem(_NC_KEY, JSON.stringify(map)); +} + +export function getNodeColor(nodeId, kind) { + const map = _loadNodeColors(); + return map[nodeId] || ENTITY_COLORS[kind] || '#666'; +} + function renderNode(node, callbacks) { const { id, kind, name, subtype, x, y, width, height, running } = node; - const color = ENTITY_COLORS[kind] || '#666'; + const color = getNodeColor(id, kind); const g = svgEl('g', { class: `graph-node${running ? ' running' : ''}`, @@ -111,6 +132,48 @@ function renderNode(node, callbacks) { }); g.appendChild(barCover); + // Clickable color bar overlay (wider hit area) + const barHit = svgEl('rect', { + class: 'graph-node-color-bar-hit', + x: 0, y: 0, + width: 12, height, + fill: 'transparent', + cursor: 'pointer', + }); + barHit.style.cursor = 'pointer'; + barHit.addEventListener('click', (e) => { + e.stopPropagation(); + // Create temporary color input positioned near the click + const input = document.createElement('input'); + input.type = 'color'; + input.value = color; + input.style.position = 'fixed'; + input.style.left = e.clientX + 'px'; + input.style.top = e.clientY + 'px'; + input.style.width = '0'; + input.style.height = '0'; + input.style.padding = '0'; + input.style.border = 'none'; + input.style.opacity = '0'; + input.style.pointerEvents = 'none'; + document.body.appendChild(input); + input.addEventListener('input', () => { + const c = input.value; + bar.setAttribute('fill', c); + barCover.setAttribute('fill', c); + _saveNodeColor(id, c); + }); + input.addEventListener('change', () => { + input.remove(); + }); + // Fallback remove if user cancels + input.addEventListener('blur', () => { + setTimeout(() => input.remove(), 200); + }); + input.click(); + }); + g.appendChild(barHit); + // Input ports (left side) if (node.inputPorts?.types) { for (const t of node.inputPorts.types) { @@ -242,7 +305,7 @@ function _createOverlay(node, nodeWidth, callbacks) { // Test button for applicable kinds if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) { - btns.push({ icon: '\u25B7', action: 'test', cls: '' }); // ▷ test + btns.push({ icon: '\uD83D\uDC41', action: 'test', cls: '' }); // 👁 test/preview } // Notification test for notification color strip sources 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 afaebac..da2791c 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -4,7 +4,7 @@ import { GraphCanvas } from '../core/graph-canvas.js'; import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; -import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js'; +import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeColor } from '../core/graph-nodes.js'; import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js'; import { devicesCache, captureTemplatesCache, ppTemplatesCache, @@ -14,7 +14,7 @@ import { automationsCacheObj, } from '../core/state.js'; import { fetchWithAuth } from '../core/api.js'; -import { showToast } from '../core/ui.js'; +import { showToast, showConfirm } from '../core/ui.js'; import { t } from '../core/i18n.js'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js'; @@ -149,11 +149,160 @@ export function graphFitAll() { export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); } export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); } +export function graphToggleFullscreen() { + const container = document.querySelector('#graph-editor-content .graph-container'); + if (!container) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + // Move bg-anim canvas into container so it's visible in fullscreen + const bgCanvas = document.getElementById('bg-anim-canvas'); + if (bgCanvas && !container.contains(bgCanvas)) { + container.insertBefore(bgCanvas, container.firstChild); + } + container.requestFullscreen().catch(() => {}); + } +} + +// Restore bg-anim canvas to body when exiting fullscreen +document.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement) { + const bgCanvas = document.getElementById('bg-anim-canvas'); + if (bgCanvas && bgCanvas.parentElement !== document.body) { + document.body.insertBefore(bgCanvas, document.body.firstChild); + } + } +}); + export async function graphRelayout() { + if (_manualPositions.size > 0) { + const ok = await showConfirm(t('graph.relayout_confirm')); + if (!ok) return; + } _manualPositions.clear(); await loadGraphEditor(); } +// Entity kind → window function to open add/create modal +const ADD_ENTITY_MAP = [ + { kind: 'device', fn: () => window.showAddDevice?.() }, + { kind: 'capture_template', fn: () => window.showAddTemplateModal?.() }, + { kind: 'pp_template', fn: () => window.showAddPPTemplateModal?.() }, + { kind: 'audio_template', fn: () => window.showAddAudioTemplateModal?.() }, + { kind: 'picture_source', fn: () => window.showAddStreamModal?.() }, + { kind: 'audio_source', fn: () => window.showAudioSourceModal?.() }, + { kind: 'value_source', fn: () => window.showValueSourceModal?.() }, + { kind: 'color_strip_source', fn: () => window.showCSSEditor?.() }, + { kind: 'output_target', fn: () => window.showTargetEditor?.() }, + { kind: 'automation', fn: () => window.openAutomationEditor?.() }, +]; + +// All caches to watch for new entity creation +const ALL_CACHES = [ + devicesCache, captureTemplatesCache, ppTemplatesCache, + streamsCache, audioSourcesCache, audioTemplatesCache, + valueSourcesCache, colorStripSourcesCache, syncClocksCache, + outputTargetsCache, patternTemplatesCache, scenePresetsCache, + automationsCacheObj, +]; + +let _addEntityMenu = null; + +export function graphAddEntity() { + if (_addEntityMenu) { _dismissAddEntityMenu(); return; } + + const container = document.querySelector('#graph-editor-content .graph-container'); + if (!container) return; + + const toolbar = container.querySelector('.graph-toolbar'); + const menu = document.createElement('div'); + menu.className = 'graph-add-entity-menu'; + + // Position below toolbar + if (toolbar) { + menu.style.left = toolbar.offsetLeft + 'px'; + menu.style.top = (toolbar.offsetTop + toolbar.offsetHeight + 6) + 'px'; + } + + for (const item of ADD_ENTITY_MAP) { + const btn = document.createElement('button'); + btn.className = 'graph-add-entity-item'; + const color = ENTITY_COLORS[item.kind] || '#666'; + const label = ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' '); + btn.innerHTML = `${label}`; + btn.addEventListener('click', () => { + _dismissAddEntityMenu(); + _watchForNewEntity(); + item.fn(); + }); + menu.appendChild(btn); + } + + container.appendChild(menu); + _addEntityMenu = menu; + + // Close on click outside + setTimeout(() => { + document.addEventListener('click', _onAddEntityClickAway, true); + }, 0); +} + +function _onAddEntityClickAway(e) { + if (_addEntityMenu && !_addEntityMenu.contains(e.target)) { + _dismissAddEntityMenu(); + } +} + +function _dismissAddEntityMenu() { + if (_addEntityMenu) { + _addEntityMenu.remove(); + _addEntityMenu = null; + } + document.removeEventListener('click', _onAddEntityClickAway, true); +} + +// Watch for new entity creation after add-entity menu action +let _entityWatchCleanup = null; + +function _watchForNewEntity() { + // Cleanup any previous watcher + if (_entityWatchCleanup) _entityWatchCleanup(); + + // Snapshot all current IDs + const knownIds = new Set(); + for (const cache of ALL_CACHES) { + for (const item of (cache.data || [])) { + if (item.id) knownIds.add(item.id); + } + } + + const handler = (data) => { + if (!Array.isArray(data)) return; + for (const item of data) { + if (item.id && !knownIds.has(item.id)) { + // Found a new entity — reload graph and navigate to it + const newId = item.id; + cleanup(); + loadGraphEditor().then(() => _navigateToNode(newId)); + return; + } + } + }; + + for (const cache of ALL_CACHES) cache.subscribe(handler); + + // Auto-cleanup after 2 minutes (user might cancel the modal) + const timeout = setTimeout(cleanup, 120_000); + + function cleanup() { + clearTimeout(timeout); + for (const cache of ALL_CACHES) cache.unsubscribe(handler); + _entityWatchCleanup = null; + } + + _entityWatchCleanup = cleanup; +} + /* ── Data fetching ── */ async function _fetchAllEntities() { @@ -340,6 +489,13 @@ function _graphHTML() { + + +
@@ -406,7 +562,7 @@ function _initMinimap(mmEl) { let html = ''; for (const node of _nodeMap.values()) { - const color = ENTITY_COLORS[node.kind] || '#666'; + const color = getNodeColor(node.id, node.kind); html += ``; } // Add viewport rect (updated live via _updateMinimapViewport) @@ -547,8 +703,28 @@ function _mmRect(mmEl) { /* ── Toolbar drag ── */ +function _clampToolbar(tbEl) { + if (!tbEl) return; + const container = tbEl.closest('.graph-container'); + if (!container) return; + const cr = container.getBoundingClientRect(); + const tw = tbEl.offsetWidth, th = tbEl.offsetHeight; + if (!tw || !th) return; // not rendered yet + let l = tbEl.offsetLeft, top = tbEl.offsetTop; + const clamped = { + left: Math.max(0, Math.min(cr.width - tw, l)), + top: Math.max(0, Math.min(cr.height - th, top)), + }; + if (clamped.left !== l || clamped.top !== top) { + tbEl.style.left = clamped.left + 'px'; + tbEl.style.top = clamped.top + 'px'; + _saveToolbarPos(clamped); + } +} + function _initToolbarDrag(tbEl) { if (!tbEl) return; + _clampToolbar(tbEl); // ensure saved position is still valid const container = tbEl.closest('.graph-container'); const handle = tbEl.querySelector('.graph-toolbar-drag'); if (!handle) return; @@ -781,7 +957,10 @@ function _onNotificationTest(node) { /* ── Keyboard ── */ function _onKeydown(e) { - if (e.key === '/' && !_searchVisible) { e.preventDefault(); openGraphSearch(); } + // Skip when typing in search input (except Escape/F11) + const inInput = e.target.matches('input, textarea, select'); + + if (e.key === '/' && !_searchVisible && !inInput) { e.preventDefault(); openGraphSearch(); } if (e.key === 'Escape') { if (_searchVisible) { closeGraphSearch(); } else { @@ -791,7 +970,7 @@ function _onKeydown(e) { } } // Delete key → detach selected edge or delete single selected node - if (e.key === 'Delete') { + if (e.key === 'Delete' && !inInput) { if (_selectedEdge) { _detachSelectedEdge(); } else if (_selectedIds.size === 1) { @@ -801,10 +980,106 @@ function _onKeydown(e) { } } // Ctrl+A → select all - if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !inInput) { e.preventDefault(); _selectAll(); } + // F11 → fullscreen + if (e.key === 'F11') { + e.preventDefault(); + graphToggleFullscreen(); + } + // + → add entity + if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) { + graphAddEntity(); + } + // Arrow keys / WASD → spatial navigation between nodes + if (_selectedIds.size <= 1 && !_searchVisible && !inInput) { + const dir = _arrowDir(e); + if (dir) { + e.preventDefault(); + _navigateDirection(dir); + } + } +} + +function _arrowDir(e) { + if (e.ctrlKey || e.metaKey) return null; + switch (e.key) { + case 'ArrowLeft': case 'a': case 'A': return 'left'; + case 'ArrowRight': case 'd': case 'D': return 'right'; + case 'ArrowUp': case 'w': case 'W': return 'up'; + case 'ArrowDown': case 's': case 'S': return 'down'; + default: return null; + } +} + +function _navigateDirection(dir) { + if (!_nodeMap || _nodeMap.size === 0) return; + + // Get current anchor node + let anchor = null; + if (_selectedIds.size === 1) { + anchor = _nodeMap.get([..._selectedIds][0]); + } + if (!anchor) { + // Select first visible node (topmost-leftmost) + let best = null; + for (const n of _nodeMap.values()) { + if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n; + } + if (best) { + _selectedIds.clear(); + _selectedIds.add(best.id); + const ng = document.querySelector('.graph-nodes'); + const eg = document.querySelector('.graph-edges'); + if (ng) updateSelection(ng, _selectedIds); + if (eg) clearEdgeHighlights(eg); + if (_canvas) _canvas.panTo(best.x + best.width / 2, best.y + best.height / 2, true); + } + return; + } + + const cx = anchor.x + anchor.width / 2; + const cy = anchor.y + anchor.height / 2; + let bestNode = null; + let bestDist = Infinity; + + for (const n of _nodeMap.values()) { + if (n.id === anchor.id) continue; + const nx = n.x + n.width / 2; + const ny = n.y + n.height / 2; + const dx = nx - cx; + const dy = ny - cy; + + // Check direction constraint + let valid = false; + if (dir === 'right' && dx > 10) valid = true; + if (dir === 'left' && dx < -10) valid = true; + if (dir === 'down' && dy > 10) valid = true; + if (dir === 'up' && dy < -10) valid = true; + if (!valid) continue; + + // Distance with directional bias (favor the primary axis) + const primaryDist = dir === 'left' || dir === 'right' ? Math.abs(dx) : Math.abs(dy); + const crossDist = dir === 'left' || dir === 'right' ? Math.abs(dy) : Math.abs(dx); + const dist = primaryDist + crossDist * 2; + + if (dist < bestDist) { + bestDist = dist; + bestNode = n; + } + } + + if (bestNode) { + _selectedIds.clear(); + _selectedIds.add(bestNode.id); + const ng = document.querySelector('.graph-nodes'); + const eg = document.querySelector('.graph-edges'); + if (ng) updateSelection(ng, _selectedIds); + if (eg && _edges) highlightChain(eg, bestNode.id, _edges); + if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true); + } } function _selectAll() { diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js index e3d1ff8..298568d 100644 --- a/server/src/wled_controller/static/js/features/tutorials.js +++ b/server/src/wled_controller/static/js/features/tutorials.js @@ -27,6 +27,7 @@ const gettingStartedSteps = [ { selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' }, { selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' }, { selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' }, + { selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, { selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' }, diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8530775..f0dfa3d 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -280,6 +280,7 @@ "tour.dashboard": "Dashboard — live overview of running targets, automations, and device health at a glance.", "tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.", "tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.", + "tour.graph": "Graph — visual overview of all entities and their connections. Drag ports to connect, right-click edges to disconnect.", "tour.automations": "Automations — automate scene switching with time, audio, or value conditions.", "tour.settings": "Settings — backup and restore configuration, manage auto-backups.", "tour.api": "API Docs — interactive REST API documentation powered by Swagger.", @@ -1402,5 +1403,9 @@ "graph.connection_updated": "Connection updated", "graph.connection_failed": "Failed to update connection", "graph.connection_removed": "Connection removed", - "graph.disconnect_failed": "Failed to disconnect" + "graph.disconnect_failed": "Failed to disconnect", + "graph.relayout_confirm": "Reset all manual node positions and re-layout the graph?", + "graph.fullscreen": "Toggle fullscreen", + "graph.add_entity": "Add entity", + "graph.color_picker": "Node color" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 8800487..31143b8 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -280,6 +280,7 @@ "tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.", "tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.", "tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.", + "tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.", "tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.", "tour.settings": "Настройки — резервное копирование и восстановление конфигурации.", "tour.api": "API Документация — интерактивная документация REST API на базе Swagger.", @@ -1402,5 +1403,9 @@ "graph.connection_updated": "Соединение обновлено", "graph.connection_failed": "Не удалось обновить соединение", "graph.connection_removed": "Соединение удалено", - "graph.disconnect_failed": "Не удалось отключить" + "graph.disconnect_failed": "Не удалось отключить", + "graph.relayout_confirm": "Сбросить все ручные позиции узлов и перестроить граф?", + "graph.fullscreen": "Полноэкранный режим", + "graph.add_entity": "Добавить сущность", + "graph.color_picker": "Цвет узла" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index f54af42..7a3b789 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -280,6 +280,7 @@ "tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。", "tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。", "tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。", + "tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。", "tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。", "tour.settings": "设置 — 备份和恢复配置,管理自动备份。", "tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。", @@ -1402,5 +1403,9 @@ "graph.connection_updated": "连接已更新", "graph.connection_failed": "更新连接失败", "graph.connection_removed": "连接已移除", - "graph.disconnect_failed": "断开连接失败" + "graph.disconnect_failed": "断开连接失败", + "graph.relayout_confirm": "重置所有手动节点位置并重新布局图表?", + "graph.fullscreen": "切换全屏", + "graph.add_entity": "添加实体", + "graph.color_picker": "节点颜色" }