diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 00a36f6..c6548c4 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -245,6 +245,15 @@ cursor: pointer; } +.graph-node.dragging { + cursor: grabbing; + opacity: 0.85; +} + +.graph-node.dragging .graph-node-overlay { + display: none !important; +} + .graph-node-body { fill: var(--card-bg); stroke: var(--border-color); @@ -393,11 +402,13 @@ .graph-edge-flow { fill: none; stroke-width: 0; + pointer-events: none; } .graph-edge-flow circle { r: 3; - opacity: 0.8; + opacity: 0.85; + filter: drop-shadow(0 0 2px currentColor); } /* ── Drag connection preview ── */ 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 e80d41f..b95f3c2 100644 --- a/server/src/wled_controller/static/js/core/graph-edges.js +++ b/server/src/wled_controller/static/js/core/graph-edges.js @@ -167,3 +167,102 @@ export function clearEdgeHighlights(edgeGroup) { path.classList.remove('highlighted', 'dimmed'); }); } + +/* ── Edge colors (matching CSS) ── */ + +const EDGE_COLORS = { + picture: '#42A5F5', + colorstrip: '#66BB6A', + value: '#FFA726', + device: '#78909C', + clock: '#26C6DA', + audio: '#EF5350', + template: '#AB47BC', + scene: '#CE93D8', + default: '#999', +}; + +export { EDGE_COLORS }; + +/** + * Update edge paths connected to a specific node (e.g. after dragging). + * Falls back to default bezier since ELK routing points are no longer valid. + */ +export function updateEdgesForNode(group, nodeId, nodeMap, edges) { + for (const edge of edges) { + if (edge.from !== nodeId && edge.to !== nodeId) continue; + const fromNode = nodeMap.get(edge.from); + const toNode = nodeMap.get(edge.to); + if (!fromNode || !toNode) continue; + + const d = _defaultBezier(fromNode, toNode); + 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); + } + }); + } +} + +/** + * Render animated flow dots on edges leading to running nodes. + * @param {SVGGElement} group - the edges group + * @param {Array} edges + * @param {Set} runningIds - IDs of currently running nodes + */ +export function renderFlowDots(group, edges, runningIds) { + group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove()); + if (!runningIds || runningIds.size === 0) return; + + // Collect all upstream edges that feed into running nodes (full chain) + const activeEdges = new Set(); + const visited = new Set(); + const stack = [...runningIds]; + while (stack.length) { + const cur = stack.pop(); + if (visited.has(cur)) continue; + visited.add(cur); + for (let i = 0; i < edges.length; i++) { + if (edges[i].to === cur) { + activeEdges.add(i); + stack.push(edges[i].from); + } + } + } + + for (const idx of activeEdges) { + const edge = edges[idx]; + const pathEl = group.querySelector( + `.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"][data-field="${edge.field || ''}"]` + ); + if (!pathEl) continue; + const d = pathEl.getAttribute('d'); + if (!d) continue; + + const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default; + const flowG = svgEl('g', { class: 'graph-edge-flow' }); + + // Two dots staggered for smoother visual flow + for (const beginFrac of ['0s', '1s']) { + const circle = svgEl('circle', { fill: color, opacity: '0.85' }); + circle.setAttribute('r', '3'); + const anim = document.createElementNS(SVG_NS, 'animateMotion'); + anim.setAttribute('dur', '2s'); + anim.setAttribute('repeatCount', 'indefinite'); + anim.setAttribute('begin', beginFrac); + anim.setAttribute('path', d); + circle.appendChild(anim); + flowG.appendChild(circle); + } + group.appendChild(flowG); + } +} + +/** + * Update flow dot paths for edges connected to a node (after drag). + */ +export function updateFlowDotsForNode(group, nodeId, nodeMap, edges) { + // Just remove and let caller re-render if needed; or update paths + // For simplicity, remove all flow dots — they'll be re-added on next render cycle + group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove()); +} 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 ea57be3..464c5fd 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -5,7 +5,7 @@ import { GraphCanvas } from '../core/graph-canvas.js'; import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js'; -import { renderEdges, highlightChain, clearEdgeHighlights } from '../core/graph-edges.js'; +import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js'; import { devicesCache, captureTemplatesCache, ppTemplatesCache, streamsCache, audioSourcesCache, audioTemplatesCache, @@ -28,6 +28,14 @@ let _searchIndex = -1; let _searchItems = []; let _loading = false; +// Node drag state +let _dragState = null; // { nodeId, el, startClient, startNode, dragging } +let _justDragged = false; +let _dragListenersAdded = false; + +// Manual position overrides (persisted in memory; cleared on relayout) +let _manualPositions = new Map(); + // Minimap position/size persisted in localStorage const _MM_KEY = 'graph_minimap'; function _loadMinimapRect() { @@ -68,9 +76,13 @@ export async function loadGraphEditor() { try { const entities = await _fetchAllEntities(); const { nodes, edges, bounds } = await computeLayout(entities); + + // Apply manual position overrides from previous drag operations + _applyManualPositions(nodes, edges); + _nodeMap = nodes; _edges = edges; - _bounds = bounds; + _bounds = _calcBounds(nodes); _renderGraph(container); } finally { _loading = false; @@ -120,6 +132,7 @@ export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); } export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); } export async function graphRelayout() { + _manualPositions.clear(); await loadGraphEditor(); } @@ -147,6 +160,9 @@ async function _fetchAllEntities() { /* ── Rendering ── */ function _renderGraph(container) { + // Destroy previous canvas to clean up window event listeners + if (_canvas) { _canvas.destroy(); _canvas = null; } + container.innerHTML = _graphHTML(); const svgEl = container.querySelector('.graph-svg'); @@ -164,6 +180,13 @@ function _renderGraph(container) { }); markOrphans(nodeGroup, _nodeMap, _edges); + // Animated flow dots for running nodes + const runningIds = new Set(); + for (const node of _nodeMap.values()) { + if (node.running) runningIds.add(node.id); + } + renderFlowDots(edgeGroup, _edges, runningIds); + // Set bounds for view clamping, then fit if (_bounds) _canvas.setBounds(_bounds); requestAnimationFrame(() => { @@ -182,6 +205,7 @@ function _renderGraph(container) { _renderLegend(container.querySelector('.graph-legend')); _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); + _initNodeDrag(nodeGroup, edgeGroup); const searchInput = container.querySelector('.graph-search-input'); if (searchInput) { @@ -325,7 +349,7 @@ function _initMinimap(mmEl) { let html = ''; for (const node of _nodeMap.values()) { const color = ENTITY_COLORS[node.kind] || '#666'; - html += ``; + html += ``; } // Add viewport rect (updated live via _updateMinimapViewport) html += ``; @@ -556,6 +580,8 @@ function _navigateToNode(nodeId) { /* ── Node callbacks ── */ function _onNodeClick(node, e) { + if (_justDragged) return; // suppress click after node drag + const nodeGroup = document.querySelector('.graph-nodes'); const edgeGroup = document.querySelector('.graph-edges'); @@ -639,6 +665,150 @@ function _onKeydown(e) { } } +/* ── Node dragging ── */ + +const DRAG_DEAD_ZONE = 4; + +function _initNodeDrag(nodeGroup, edgeGroup) { + // Event delegation on node group for pointerdown + nodeGroup.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + const nodeEl = e.target.closest('.graph-node'); + if (!nodeEl) return; + // Don't start drag from overlay buttons + if (e.target.closest('.graph-node-overlay-btn')) return; + + const nodeId = nodeEl.getAttribute('data-id'); + const node = _nodeMap.get(nodeId); + if (!node) return; + + _dragState = { + nodeId, + el: nodeEl, + startClient: { x: e.clientX, y: e.clientY }, + startNode: { x: node.x, y: node.y }, + dragging: false, + pointerId: e.pointerId, + }; + + // Prevent canvas from starting a pan (canvas checks blockPan on left-click non-node) + // But we also need to stop propagation so the canvas's onPointerDown doesn't fire + e.stopPropagation(); + }); + + // Window-level move/up listeners (added once, reused across re-renders) + if (!_dragListenersAdded) { + window.addEventListener('pointermove', _onDragPointerMove); + window.addEventListener('pointerup', _onDragPointerUp); + _dragListenersAdded = true; + } +} + +function _onDragPointerMove(e) { + if (!_dragState) return; + + const dx = e.clientX - _dragState.startClient.x; + const dy = e.clientY - _dragState.startClient.y; + + if (!_dragState.dragging) { + if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return; + _dragState.dragging = true; + if (_canvas) _canvas.blockPan = true; + _dragState.el.classList.add('dragging'); + } + + if (!_canvas) return; + const gdx = dx / _canvas.zoom; + const gdy = dy / _canvas.zoom; + + const node = _nodeMap.get(_dragState.nodeId); + if (!node) return; + + node.x = _dragState.startNode.x + gdx; + node.y = _dragState.startNode.y + gdy; + _dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`); + + // Update connected edges + const edgeGroup = document.querySelector('.graph-edges'); + if (edgeGroup) { + updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); + updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); + } + + // Update minimap node position + _updateMinimapNode(_dragState.nodeId, node); +} + +function _onDragPointerUp() { + if (!_dragState) return; + + if (_dragState.dragging) { + _dragState.el.classList.remove('dragging'); + if (_canvas) _canvas.blockPan = false; + _justDragged = true; + requestAnimationFrame(() => { _justDragged = false; }); + + // Save manual position + const node = _nodeMap.get(_dragState.nodeId); + if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y }); + + // Recalc bounds for view clamping + _bounds = _calcBounds(_nodeMap); + if (_canvas && _bounds) _canvas.setBounds(_bounds); + + // Re-render flow dots (paths changed) + const edgeGroup = document.querySelector('.graph-edges'); + if (edgeGroup && _edges && _nodeMap) { + const runningIds = new Set(); + for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); } + renderFlowDots(edgeGroup, _edges, runningIds); + } + } + + _dragState = null; +} + +function _updateMinimapNode(nodeId, node) { + const mm = document.querySelector('.graph-minimap'); + if (!mm) return; + const mmNode = mm.querySelector(`rect.graph-minimap-node[data-id="${nodeId}"]`); + if (mmNode) { + mmNode.setAttribute('x', node.x); + mmNode.setAttribute('y', node.y); + } +} + +/* ── Manual position helpers ── */ + +function _applyManualPositions(nodeMap, edges) { + if (_manualPositions.size === 0) return; + for (const [id, pos] of _manualPositions) { + const node = nodeMap.get(id); + if (node) { + node.x = pos.x; + node.y = pos.y; + } + } + // Invalidate ELK edge routing for edges connected to moved nodes + for (const edge of edges) { + if (_manualPositions.has(edge.from) || _manualPositions.has(edge.to)) { + edge.points = null; // forces default bezier + } + } +} + +function _calcBounds(nodeMap) { + if (!nodeMap || nodeMap.size === 0) return { x: 0, y: 0, width: 400, height: 300 }; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of nodeMap.values()) { + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + n.width); + maxY = Math.max(maxY, n.y + n.height); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + /* ── Helpers ── */ function _escHtml(s) {