Add interactive graph editor connections: port-based edges, drag-connect, and detach

- Add visible typed ports on graph nodes (colored dots for each edge type)
- Route edges to specific port positions instead of node center
- Drag from output port to compatible input port to create/change connections
- Right-click edge context menu with Disconnect option
- Delete key detaches selected edge
- Mark nested edges (composite layers, zones) as non-editable with dotted style
- Add resolve_ref helper for empty-string sentinel to clear reference fields
- Apply resolve_ref across all storage stores for consistent detach support
- Add connection mapping module (graph-connections.js) with API field resolution
- Add i18n keys for connection operations (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:15:33 +03:00
parent ff24ec95e6
commit b370bb7d75
17 changed files with 661 additions and 60 deletions

View File

@@ -336,32 +336,40 @@
/* ── Ports ── */
.graph-port {
stroke: var(--bg-color);
stroke-width: 2;
opacity: 0.85;
transition: r 0.15s, opacity 0.15s;
pointer-events: all;
}
.graph-node:hover .graph-port {
r: 5;
opacity: 1;
}
/* Port output cursor: draggable */
.graph-port-out {
cursor: crosshair;
}
.graph-port circle {
fill: var(--bg-secondary);
stroke: var(--text-muted);
stroke-width: 1.5;
r: 5;
transition: fill 0.15s, stroke 0.15s, r 0.15s;
/* Port interaction states during connection drag */
.graph-port-compatible {
r: 6 !important;
opacity: 1 !important;
cursor: pointer;
filter: drop-shadow(0 0 3px currentColor);
}
.graph-port:hover circle {
fill: var(--primary-color);
stroke: var(--primary-color);
r: 6;
.graph-port-incompatible {
opacity: 0.15 !important;
}
.graph-port.connected circle {
fill: var(--text-secondary);
stroke: var(--text-secondary);
}
.graph-port-label {
fill: var(--text-muted);
font-size: 9px;
font-family: 'DM Sans', sans-serif;
.graph-port-drop-target {
r: 7 !important;
stroke: var(--primary-color) !important;
stroke-width: 3 !important;
filter: drop-shadow(0 0 6px var(--primary-color));
}
/* ── Edges ── */
@@ -398,6 +406,12 @@
opacity: 0.12;
}
/* Nested edges (composite layers, zones) — not drag-editable */
.graph-edge-nested {
stroke-dasharray: 2 2;
opacity: 0.4;
}
/* Edge type colors */
.graph-edge-picture { stroke: #42A5F5; color: #42A5F5; }
.graph-edge-colorstrip { stroke: #66BB6A; color: #66BB6A; }
@@ -433,6 +447,47 @@
pointer-events: none;
}
/* ── Edge context menu ── */
.graph-edge-menu {
position: absolute;
z-index: 40;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 16px var(--shadow-color);
padding: 4px;
min-width: 120px;
}
.graph-edge-menu-item {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.85rem;
font-family: inherit;
text-align: left;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.graph-edge-menu-item:hover {
background: var(--bg-secondary);
}
.graph-edge-menu-item.danger {
color: var(--danger-color);
}
.graph-edge-menu-item.danger:hover {
background: var(--danger-color);
color: var(--primary-contrast);
}
/* ── Hover overlay (action buttons) ── */
.graph-node-overlay {

View File

@@ -0,0 +1,133 @@
/**
* Graph connection editing — maps edge types to API fields and endpoints.
* Supports creating, changing, and detaching connections via the graph editor.
*/
import { fetchWithAuth } from './api.js';
import {
streamsCache, colorStripSourcesCache, valueSourcesCache,
audioSourcesCache, outputTargetsCache, automationsCacheObj,
} from './state.js';
/**
* Connection map: for each (targetKind, field) pair, defines:
* - sourceKind: which entity kind(s) can be the source
* - edgeType: the edge type for this connection
* - endpoint: the API endpoint pattern (use {id} for target ID)
* - cache: the DataCache to invalidate after update
* - nested: true if this field is inside a nested structure (not editable via drag)
*/
const CONNECTION_MAP = [
// Picture sources
{ targetKind: 'picture_source', field: 'capture_template_id', sourceKind: 'capture_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache },
{ targetKind: 'picture_source', field: 'source_stream_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/picture-sources/{id}', cache: streamsCache },
{ targetKind: 'picture_source', field: 'postprocessing_template_id', sourceKind: 'pp_template', edgeType: 'template', endpoint: '/picture-sources/{id}', cache: streamsCache },
// Audio sources
{ targetKind: 'audio_source', field: 'audio_template_id', sourceKind: 'audio_template', edgeType: 'audio', endpoint: '/audio-sources/{id}', cache: audioSourcesCache },
{ targetKind: 'audio_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/audio-sources/{id}', cache: audioSourcesCache },
// Value sources
{ targetKind: 'value_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
{ targetKind: 'value_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/value-sources/{id}', cache: valueSourcesCache },
// Color strip sources
{ targetKind: 'color_strip_source', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
{ targetKind: 'color_strip_source', field: 'audio_source_id', sourceKind: 'audio_source', edgeType: 'audio', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
{ targetKind: 'color_strip_source', field: 'clock_id', sourceKind: 'sync_clock', edgeType: 'clock', endpoint: '/color-strip-sources/{id}', cache: colorStripSourcesCache },
// Output targets
{ targetKind: 'output_target', field: 'device_id', sourceKind: 'device', edgeType: 'device', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'color_strip_source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
{ targetKind: 'output_target', field: 'picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', endpoint: '/output-targets/{id}', cache: outputTargetsCache },
// Automations
{ targetKind: 'automation', field: 'scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
{ targetKind: 'automation', field: 'deactivation_scene_preset_id', sourceKind: 'scene_preset', edgeType: 'scene', endpoint: '/automations/{id}', cache: automationsCacheObj },
// ── Nested fields (not drag-editable in V1) ──
{ targetKind: 'color_strip_source', field: 'layer.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
{ targetKind: 'color_strip_source', field: 'layer.brightness_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'color_strip_source', field: 'zone.source_id', sourceKind: 'color_strip_source', edgeType: 'colorstrip', nested: true },
{ targetKind: 'color_strip_source', field: 'calibration.picture_source_id', sourceKind: 'picture_source', edgeType: 'picture', nested: true },
{ targetKind: 'output_target', field: 'settings.pattern_template_id', sourceKind: 'pattern_template', edgeType: 'template', nested: true },
{ targetKind: 'output_target', field: 'settings.brightness_value_source_id', sourceKind: 'value_source', edgeType: 'value', nested: true },
{ targetKind: 'scene_preset', field: 'target_id', sourceKind: 'output_target', edgeType: 'scene', nested: true },
];
/**
* Check if an edge (by field name) is editable via drag-connect.
*/
export function isEditableEdge(field) {
const entry = CONNECTION_MAP.find(c => c.field === field);
return entry ? !entry.nested : false;
}
/**
* Find the connection mapping for a given target kind and source kind.
* Returns the matching entry (or entries) from CONNECTION_MAP.
*/
export function findConnection(targetKind, sourceKind, edgeType) {
return CONNECTION_MAP.filter(c =>
!c.nested &&
c.targetKind === targetKind &&
c.sourceKind === sourceKind &&
(!edgeType || c.edgeType === edgeType)
);
}
/**
* Find compatible input port fields for a given source kind.
* Returns array of { targetKind, field, edgeType }.
*/
export function getCompatibleInputs(sourceKind) {
return CONNECTION_MAP
.filter(c => !c.nested && c.sourceKind === sourceKind)
.map(c => ({ targetKind: c.targetKind, field: c.field, edgeType: c.edgeType }));
}
/**
* Find the connection entry for a specific edge (by target kind and field).
*/
export function getConnectionByField(targetKind, field) {
return CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
}
/**
* Update a connection: set the reference field on the target entity.
* @param {string} targetId - The target entity's ID
* @param {string} targetKind - The target entity's kind
* @param {string} field - The field name to update
* @param {string|null} newSourceId - New source ID, or '' to detach
* @returns {Promise<boolean>} success
*/
export async function updateConnection(targetId, targetKind, field, newSourceId) {
const entry = CONNECTION_MAP.find(c => c.targetKind === targetKind && c.field === field && !c.nested);
if (!entry) return false;
const url = entry.endpoint.replace('{id}', targetId);
const body = { [field]: newSourceId };
try {
const resp = await fetchWithAuth(url, {
method: 'PUT',
body: JSON.stringify(body),
});
if (!resp.ok) return false;
// Invalidate the relevant cache so data refreshes
if (entry.cache) entry.cache.invalidate();
return true;
} catch {
return false;
}
}
/**
* Detach a connection (set field to null via empty-string sentinel).
*/
export async function detachConnection(targetId, targetKind, field) {
return updateConnection(targetId, targetKind, field, '');
}
export { CONNECTION_MAP };

View File

@@ -51,9 +51,16 @@ function _createArrowMarker(type) {
}
function _renderEdge(edge) {
const { from, to, type, points, fromNode, toNode, field } = edge;
const cssClass = `graph-edge graph-edge-${type}`;
const d = points ? _pointsToPath(points) : _defaultBezier(fromNode, toNode);
const { from, to, type, points, 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);
}
const path = svgEl('path', {
class: cssClass,
@@ -104,13 +111,31 @@ function _pointsToPath(points) {
}
/**
* Fallback bezier when no ELK routing is available.
* Adjust ELK-routed start/end points to match port Y positions.
*/
function _defaultBezier(fromNode, toNode) {
function _adjustEndpoints(points, fromNode, toNode, fromPortY, toPortY) {
if (points.length < 2) return points;
const result = points.map(p => ({ ...p }));
if (fromPortY != null) {
result[0].y = fromNode.y + fromPortY;
result[0].x = fromNode.x + fromNode.width;
}
if (toPortY != null) {
result[result.length - 1].y = toNode.y + toPortY;
result[result.length - 1].x = toNode.x;
}
return result;
}
/**
* Fallback bezier when no ELK routing is available.
* Uses port Y offsets when provided, otherwise centers vertically.
*/
function _defaultBezier(fromNode, toNode, fromPortY, toPortY) {
const x1 = fromNode.x + fromNode.width;
const y1 = fromNode.y + fromNode.height / 2;
const y1 = fromNode.y + (fromPortY ?? fromNode.height / 2);
const x2 = toNode.x;
const y2 = toNode.y + toNode.height / 2;
const y2 = toNode.y + (toPortY ?? toNode.height / 2);
const dx = Math.abs(x2 - x1) * 0.4;
return `M ${x1} ${y1} C ${x1 + dx} ${y1} ${x2 - dx} ${y2} ${x2} ${y2}`;
}
@@ -195,7 +220,7 @@ export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) continue;
const d = _defaultBezier(fromNode, toNode);
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => {
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
pathEl.setAttribute('d', d);

View File

@@ -168,7 +168,9 @@ function buildGraph(e) {
nodes.find(n => n.id === to)?.kind,
field
);
edges.push({ from, to, field, label, type });
// Edges with dotted fields are nested (composite layers, zones, etc.) — not drag-editable
const editable = !field.includes('.');
edges.push({ from, to, field, label, type, editable });
}
// 1. Devices
@@ -317,4 +319,66 @@ function buildGraph(e) {
return { nodes, edges };
}
/* ── Port computation ── */
/** Canonical ordering of port types (top → bottom on the node). */
const PORT_TYPE_ORDER = ['template', 'picture', 'colorstrip', 'value', 'audio', 'clock', 'scene', 'device', 'default'];
/**
* Compute input/output port positions on every node from the edge list.
* Mutates edges (adds fromPortY, toPortY) and nodes (adds inputPorts, outputPorts).
*/
export function computePorts(nodeMap, edges) {
// Collect which port types each node needs (keyed by edge type)
const inputTypes = new Map(); // nodeId → Set<edgeType>
const outputTypes = new Map(); // nodeId → Set<edgeType>
for (const edge of edges) {
if (!inputTypes.has(edge.to)) inputTypes.set(edge.to, new Set());
inputTypes.get(edge.to).add(edge.type);
if (!outputTypes.has(edge.from)) outputTypes.set(edge.from, new Set());
outputTypes.get(edge.from).add(edge.type);
}
// Sort port types and assign vertical positions
function assignPorts(typeSet, height) {
const types = [...typeSet].sort((a, b) => {
const ai = PORT_TYPE_ORDER.indexOf(a);
const bi = PORT_TYPE_ORDER.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
const ports = {};
const n = types.length;
types.forEach((t, i) => {
ports[t] = height * (i + 1) / (n + 1);
});
return { types, ports };
}
// Build port maps on nodes
for (const [id, node] of nodeMap) {
const inSet = inputTypes.get(id) || new Set();
const outSet = outputTypes.get(id) || new Set();
node.inputPorts = assignPorts(inSet, node.height);
node.outputPorts = assignPorts(outSet, node.height);
}
// Annotate edges with port Y offsets
for (const edge of edges) {
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (fromNode?.outputPorts?.ports) {
edge.fromPortY = fromNode.outputPorts.ports[edge.type] ?? fromNode.height / 2;
} else {
edge.fromPortY = fromNode ? fromNode.height / 2 : 0;
}
if (toNode?.inputPorts?.ports) {
edge.toPortY = toNode.inputPorts.ports[edge.type] ?? toNode.height / 2;
} else {
edge.toPortY = toNode ? toNode.height / 2 : 0;
}
}
}
export { NODE_WIDTH, NODE_HEIGHT };

View File

@@ -2,7 +2,8 @@
* SVG node rendering for the graph editor.
*/
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT } from './graph-layout.js';
import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js';
import { EDGE_COLORS } from './graph-edges.js';
import * as P from './icon-paths.js';
const SVG_NS = 'http://www.w3.org/2000/svg';
@@ -110,6 +111,46 @@ function renderNode(node, callbacks) {
});
g.appendChild(barCover);
// Input ports (left side)
if (node.inputPorts?.types) {
for (const t of node.inputPorts.types) {
const py = node.inputPorts.ports[t];
const dot = svgEl('circle', {
class: `graph-port graph-port-in graph-port-${t}`,
cx: 0, cy: py, r: 4,
fill: EDGE_COLORS[t] || EDGE_COLORS.default,
'data-node-id': id,
'data-node-kind': kind,
'data-port-type': t,
'data-port-dir': 'in',
});
const tip = svgEl('title');
tip.textContent = t;
dot.appendChild(tip);
g.appendChild(dot);
}
}
// Output ports (right side)
if (node.outputPorts?.types) {
for (const t of node.outputPorts.types) {
const py = node.outputPorts.ports[t];
const dot = svgEl('circle', {
class: `graph-port graph-port-out graph-port-${t}`,
cx: width, cy: py, r: 4,
fill: EDGE_COLORS[t] || EDGE_COLORS.default,
'data-node-id': id,
'data-node-kind': kind,
'data-port-type': t,
'data-port-dir': 'out',
});
const tip = svgEl('title');
tip.textContent = t;
dot.appendChild(tip);
g.appendChild(dot);
}
}
// Entity icon (right side)
const iconPaths = (SUBTYPE_ICONS[kind]?.[subtype]) || KIND_ICONS[kind];
if (iconPaths) {

View File

@@ -3,7 +3,7 @@
*/
import { GraphCanvas } from '../core/graph-canvas.js';
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.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 { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
import {
@@ -16,6 +16,7 @@ import {
import { fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js';
import { t } from '../core/i18n.js';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
let _canvas = null;
let _nodeMap = null;
@@ -42,6 +43,16 @@ let _manualPositions = new Map();
let _rubberBand = null;
let _rubberBandListenersAdded = false;
// Port-drag connection state
let _connectState = null; // { sourceNodeId, sourceKind, portType, startPos, dragPath }
let _connectListenersAdded = false;
// Edge context menu
let _edgeContextMenu = null;
// Selected edge for Delete key detach
let _selectedEdge = null; // { from, to, field, targetKind }
// Minimap position/size persisted in localStorage
const _MM_KEY = 'graph_minimap';
function _loadMinimapRect() {
@@ -86,6 +97,7 @@ export async function loadGraphEditor() {
// Apply manual position overrides from previous drag operations
_applyManualPositions(nodes, edges);
computePorts(nodes, edges);
_nodeMap = nodes;
_edges = edges;
_bounds = _calcBounds(nodes);
@@ -229,6 +241,7 @@ function _renderGraph(container) {
_initMinimap(container.querySelector('.graph-minimap'));
_initToolbarDrag(container.querySelector('.graph-toolbar'));
_initNodeDrag(nodeGroup, edgeGroup);
_initPortDrag(svgEl, nodeGroup, edgeGroup);
_initRubberBand(svgEl);
// Edge click: select edge and its endpoints
@@ -239,6 +252,15 @@ function _renderGraph(container) {
_onEdgeClick(edgePath, nodeGroup, edgeGroup);
});
// Edge right-click: detach connection
edgeGroup.addEventListener('contextmenu', (e) => {
const edgePath = e.target.closest('.graph-edge');
if (!edgePath) return;
e.preventDefault();
e.stopPropagation();
_onEdgeContextMenu(edgePath, e, container);
});
const searchInput = container.querySelector('.graph-search-input');
if (searchInput) {
searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value));
@@ -247,6 +269,7 @@ function _renderGraph(container) {
// Deselect on click on empty space (not after a pan gesture)
svgEl.addEventListener('click', (e) => {
_dismissEdgeContextMenu();
if (_canvas.wasPanning) return;
if (e.shiftKey) return; // Shift+click reserved for rubber-band
if (!e.target.closest('.graph-node')) {
@@ -274,6 +297,7 @@ function _renderGraph(container) {
function _deselect(nodeGroup, edgeGroup) {
_selectedIds.clear();
_selectedEdge = null;
if (nodeGroup) {
updateSelection(nodeGroup, _selectedIds);
nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
@@ -766,11 +790,15 @@ function _onKeydown(e) {
_deselect(ng, eg);
}
}
// Delete key → delete single selected node
if (e.key === 'Delete' && _selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
if (node) _onDeleteNode(node);
// Delete key → detach selected edge or delete single selected node
if (e.key === 'Delete') {
if (_selectedEdge) {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
if (node) _onDeleteNode(node);
}
}
// Ctrl+A → select all
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
@@ -797,6 +825,15 @@ function _selectAll() {
function _onEdgeClick(edgePath, nodeGroup, edgeGroup) {
const fromId = edgePath.getAttribute('data-from');
const toId = edgePath.getAttribute('data-to');
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
const toNode = _nodeMap?.get(toId);
if (toNode && isEditableEdge(field)) {
_selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind };
} else {
_selectedEdge = null;
}
_selectedIds.clear();
_selectedIds.add(fromId);
@@ -827,6 +864,7 @@ function _initNodeDrag(nodeGroup, edgeGroup) {
const nodeEl = e.target.closest('.graph-node');
if (!nodeEl) return;
if (e.target.closest('.graph-node-overlay-btn')) return;
if (e.target.closest('.graph-port-out')) return; // handled by port drag
const nodeId = nodeEl.getAttribute('data-id');
const node = _nodeMap.get(nodeId);
@@ -1089,6 +1127,203 @@ function _escHtml(s) {
return d.innerHTML;
}
/* ── Port drag (connect/reconnect) ── */
const SVG_NS = 'http://www.w3.org/2000/svg';
function _initPortDrag(svgEl, nodeGroup, edgeGroup) {
// Capture-phase on output ports to prevent node drag
nodeGroup.addEventListener('pointerdown', (e) => {
const port = e.target.closest('.graph-port-out');
if (!port || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
// Compute start position in graph coords (output port = right side of node)
const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2;
const startX = sourceNode.x + sourceNode.width;
const startY = sourceNode.y + portY;
// Create temporary drag edge in SVG
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
if (_canvas) _canvas.blockPan = true;
svgEl.classList.add('connecting');
// Highlight compatible input ports
const compatible = getCompatibleInputs(sourceKind);
const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`));
nodeGroup.querySelectorAll('.graph-port-in').forEach(p => {
const nKind = p.getAttribute('data-node-kind');
const pType = p.getAttribute('data-port-type');
const nId = p.getAttribute('data-node-id');
// Don't connect to self
if (nId === sourceNodeId) {
p.classList.add('graph-port-incompatible');
return;
}
if (compatibleSet.has(`${nKind}:${pType}`)) {
p.classList.add('graph-port-compatible');
} else {
p.classList.add('graph-port-incompatible');
}
});
}, true); // capture phase to beat node drag
if (!_connectListenersAdded) {
window.addEventListener('pointermove', _onConnectPointerMove);
window.addEventListener('pointerup', _onConnectPointerUp);
_connectListenersAdded = true;
}
}
function _onConnectPointerMove(e) {
if (!_connectState || !_canvas) return;
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
const { startX, startY, dragPath } = _connectState;
const dx = Math.abs(gp.x - startX) * 0.4;
dragPath.setAttribute('d',
`M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}`
);
// Highlight drop target port
const svgEl = document.querySelector('.graph-svg');
if (!svgEl) return;
const elem = document.elementFromPoint(e.clientX, e.clientY);
const port = elem?.closest?.('.graph-port-compatible');
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
if (port) port.classList.add('graph-port-drop-target');
}
function _onConnectPointerUp(e) {
if (!_connectState) return;
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;
// Clean up drag edge
dragPath.remove();
const svgEl = document.querySelector('.graph-svg');
if (svgEl) svgEl.classList.remove('connecting');
if (_canvas) _canvas.blockPan = false;
// Clean up port highlights
const nodeGroup = document.querySelector('.graph-nodes');
if (nodeGroup) {
nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
});
}
// Check if dropped on a compatible input port
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
const matches = findConnection(targetKind, sourceKind, targetPortType);
if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) {
// Multiple possible fields (e.g., template → picture_source could be capture or pp template)
// Resolve by source kind
const exact = matches.find(m => m.sourceKind === sourceKind);
if (exact) {
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
}
}
}
}
_connectState = null;
}
async function _doConnect(targetId, targetKind, field, sourceId) {
const ok = await updateConnection(targetId, targetKind, field, sourceId);
if (ok) {
showToast(t('graph.connection_updated') || 'Connection updated', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.connection_failed') || 'Failed to update connection', 'error');
}
}
/* ── Edge context menu (right-click to detach) ── */
function _onEdgeContextMenu(edgePath, e, container) {
_dismissEdgeContextMenu();
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
const menu = document.createElement('div');
menu.className = 'graph-edge-menu';
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
const btn = document.createElement('button');
btn.className = 'graph-edge-menu-item danger';
btn.textContent = t('graph.disconnect') || 'Disconnect';
btn.addEventListener('click', async () => {
_dismissEdgeContextMenu();
const ok = await detachConnection(toId, toNode.kind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
_edgeContextMenu = menu;
}
function _dismissEdgeContextMenu() {
if (_edgeContextMenu) {
_edgeContextMenu.remove();
_edgeContextMenu = null;
}
}
async function _detachSelectedEdge() {
if (!_selectedEdge) return;
const { to, field, targetKind } = _selectedEdge;
_selectedEdge = null;
const ok = await detachConnection(to, targetKind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
}
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
document.addEventListener('languageChanged', () => {
if (_initialized && _nodeMap) {

View File

@@ -1397,5 +1397,10 @@
"graph.minimap": "Minimap",
"graph.relayout": "Re-layout",
"graph.empty": "No entities yet",
"graph.empty.hint": "Create devices, sources, and targets to see them here."
"graph.empty.hint": "Create devices, sources, and targets to see them here.",
"graph.disconnect": "Disconnect",
"graph.connection_updated": "Connection updated",
"graph.connection_failed": "Failed to update connection",
"graph.connection_removed": "Connection removed",
"graph.disconnect_failed": "Failed to disconnect"
}

View File

@@ -1397,5 +1397,10 @@
"graph.minimap": "Миникарта",
"graph.relayout": "Перестроить",
"graph.empty": "Ещё нет сущностей",
"graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь."
"graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь.",
"graph.disconnect": "Отключить",
"graph.connection_updated": "Соединение обновлено",
"graph.connection_failed": "Не удалось обновить соединение",
"graph.connection_removed": "Соединение удалено",
"graph.disconnect_failed": "Не удалось отключить"
}

View File

@@ -1397,5 +1397,10 @@
"graph.minimap": "小地图",
"graph.relayout": "重新布局",
"graph.empty": "暂无实体",
"graph.empty.hint": "创建设备、源和目标后即可在此查看。"
"graph.empty.hint": "创建设备、源和目标后即可在此查看。",
"graph.disconnect": "断开连接",
"graph.connection_updated": "连接已更新",
"graph.connection_failed": "更新连接失败",
"graph.connection_removed": "连接已移除",
"graph.disconnect_failed": "断开连接失败"
}

View File

@@ -10,6 +10,7 @@ from wled_controller.storage.audio_source import (
MultichannelAudioSource,
)
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -112,15 +113,18 @@ class AudioSourceStore(BaseJsonStore[AudioSource]):
if is_loopback is not None:
source.is_loopback = bool(is_loopback)
if audio_template_id is not None:
source.audio_template_id = audio_template_id
source.audio_template_id = resolve_ref(audio_template_id, source.audio_template_id)
elif isinstance(source, MonoAudioSource):
if audio_source_id is not None:
parent = self._items.get(audio_source_id)
if not parent:
raise ValueError(f"Parent audio source not found: {audio_source_id}")
if not isinstance(parent, MultichannelAudioSource):
raise ValueError("Mono sources must reference a multichannel source")
source.audio_source_id = audio_source_id
resolved = resolve_ref(audio_source_id, source.audio_source_id)
if resolved is not None:
# Validate parent exists and is multichannel
parent = self._items.get(resolved)
if not parent:
raise ValueError(f"Parent audio source not found: {resolved}")
if not isinstance(parent, MultichannelAudioSource):
raise ValueError("Mono sources must reference a multichannel source")
source.audio_source_id = resolved
if channel is not None:
source.channel = channel

View File

@@ -84,11 +84,11 @@ class AutomationStore(BaseJsonStore[Automation]):
if conditions is not None:
automation.conditions = conditions
if scene_preset_id != "__unset__":
automation.scene_preset_id = scene_preset_id
automation.scene_preset_id = None if scene_preset_id == "" else scene_preset_id
if deactivation_mode is not None:
automation.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
automation.deactivation_scene_preset_id = deactivation_scene_preset_id
automation.deactivation_scene_preset_id = None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id
if tags is not None:
automation.tags = tags

View File

@@ -6,6 +6,7 @@ from typing import List, Optional
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
@@ -390,14 +391,14 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
source.description = description
if clock_id is not None:
source.clock_id = clock_id if clock_id else None
source.clock_id = resolve_ref(clock_id, source.clock_id)
if tags is not None:
source.tags = tags
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
if picture_source_id is not None and isinstance(source, PictureColorStripSource):
source.picture_source_id = picture_source_id
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
if fps is not None:
source.fps = fps
if brightness is not None:
@@ -447,7 +448,7 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]):
if visualization_mode is not None:
source.visualization_mode = visualization_mode
if audio_source_id is not None:
source.audio_source_id = audio_source_id
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
if sensitivity is not None:
source.sensitivity = float(sensitivity)
if smoothing is not None:

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
@dataclass
@@ -105,7 +106,7 @@ class KeyColorsOutputTarget(OutputTarget):
"""Apply mutable field updates for KC targets."""
super().update_fields(name=name, description=description, tags=tags)
if picture_source_id is not None:
self.picture_source_id = picture_source_id
self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id)
if key_colors_settings is not None:
self.settings = key_colors_settings

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional, Set
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.picture_source import (
PictureSource,
ProcessedPictureSource,
@@ -183,7 +184,8 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
stream = self.get(stream_id)
# If changing source_stream_id on a processed stream, check for cycles
if source_stream_id is not None and isinstance(stream, ProcessedPictureSource):
# (skip validation when clearing via empty string)
if source_stream_id is not None and source_stream_id != "" and isinstance(stream, ProcessedPictureSource):
if source_stream_id not in self._items:
raise ValueError(f"Source stream not found: {source_stream_id}")
if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id):
@@ -201,14 +203,14 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
if display_index is not None:
stream.display_index = display_index
if capture_template_id is not None:
stream.capture_template_id = capture_template_id
stream.capture_template_id = resolve_ref(capture_template_id, stream.capture_template_id)
if target_fps is not None:
stream.target_fps = target_fps
elif isinstance(stream, ProcessedPictureSource):
if source_stream_id is not None:
stream.source_stream_id = source_stream_id
stream.source_stream_id = resolve_ref(source_stream_id, stream.source_stream_id)
if postprocessing_template_id is not None:
stream.postprocessing_template_id = postprocessing_template_id
stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id)
elif isinstance(stream, StaticImagePictureSource):
if image_source is not None:
stream.image_source = image_source

View File

@@ -0,0 +1,23 @@
"""Shared utilities for storage layer."""
from typing import Optional
def resolve_ref(new_value: Optional[str], current_value: Optional[str]) -> Optional[str]:
"""Resolve a reference field update.
Handles three cases for nullable reference ID fields:
- new_value == '' -> clear to None (detach)
- new_value is None -> keep current value (no change)
- otherwise -> use new_value
Args:
new_value: The incoming value from the API update request.
current_value: The current value stored on the entity.
Returns:
The resolved value to assign to the field.
"""
if new_value == "":
return None
return new_value if new_value is not None else current_value

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.base_store import BaseJsonStore
from wled_controller.storage.utils import resolve_ref
from wled_controller.storage.value_source import (
AdaptiveValueSource,
AnimatedValueSource,
@@ -179,7 +180,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
source.max_value = max_value
elif isinstance(source, AudioValueSource):
if audio_source_id is not None:
source.audio_source_id = audio_source_id
source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
if mode is not None:
source.mode = mode
if sensitivity is not None:
@@ -198,7 +199,7 @@ class ValueSourceStore(BaseJsonStore[ValueSource]):
raise ValueError("Time of day schedule requires at least 2 points")
source.schedule = schedule
if picture_source_id is not None:
source.picture_source_id = picture_source_id
source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
if scene_behavior is not None:
source.scene_behavior = scene_behavior
if sensitivity is not None:

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.output_target import OutputTarget
from wled_controller.storage.utils import resolve_ref
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
@@ -68,11 +69,11 @@ class WledOutputTarget(OutputTarget):
"""Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description, tags=tags)
if device_id is not None:
self.device_id = device_id
self.device_id = resolve_ref(device_id, self.device_id)
if color_strip_source_id is not None:
self.color_strip_source_id = color_strip_source_id
self.color_strip_source_id = resolve_ref(color_strip_source_id, self.color_strip_source_id)
if brightness_value_source_id is not None:
self.brightness_value_source_id = brightness_value_source_id
self.brightness_value_source_id = resolve_ref(brightness_value_source_id, self.brightness_value_source_id)
if fps is not None:
self.fps = fps
if keepalive_interval is not None: