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

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