Add node dragging, animated flow dots, and canvas cleanup to graph editor

- Drag nodes to reposition with dead-zone, edge re-routing, and minimap sync
- Animated flow dots trace upstream chains to running nodes
- Manual positions persist across re-renders, cleared on relayout
- Fix canvas event listener leak on re-render by calling destroy()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:21:14 +03:00
parent bd7a315c2c
commit 6d85385dbb
3 changed files with 284 additions and 4 deletions

View File

@@ -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 ── */

View File

@@ -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<string>} 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());
}

View File

@@ -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 += `<rect class="graph-minimap-node" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
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)
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
@@ -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) {