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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:48:55 +03:00
parent b370bb7d75
commit 5c7c2ad1b2
9 changed files with 446 additions and 22 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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 = `<span class="graph-add-entity-dot" style="background:${color}"></span><span>${label}</span>`;
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() {
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)">
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
</div>
<div class="graph-legend">
@@ -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 += `<rect class="graph-minimap-node" data-id="${node.id}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
}
// 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() {

View File

@@ -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' },

View File

@@ -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"
}

View File

@@ -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": "Цвет узла"
}

View File

@@ -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": "节点颜色"
}