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:
@@ -245,6 +245,15 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graph-node.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-node.dragging .graph-node-overlay {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.graph-node-body {
|
.graph-node-body {
|
||||||
fill: var(--card-bg);
|
fill: var(--card-bg);
|
||||||
stroke: var(--border-color);
|
stroke: var(--border-color);
|
||||||
@@ -393,11 +402,13 @@
|
|||||||
.graph-edge-flow {
|
.graph-edge-flow {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke-width: 0;
|
stroke-width: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-edge-flow circle {
|
.graph-edge-flow circle {
|
||||||
r: 3;
|
r: 3;
|
||||||
opacity: 0.8;
|
opacity: 0.85;
|
||||||
|
filter: drop-shadow(0 0 2px currentColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Drag connection preview ── */
|
/* ── Drag connection preview ── */
|
||||||
|
|||||||
@@ -167,3 +167,102 @@ export function clearEdgeHighlights(edgeGroup) {
|
|||||||
path.classList.remove('highlighted', 'dimmed');
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { GraphCanvas } from '../core/graph-canvas.js';
|
import { GraphCanvas } from '../core/graph-canvas.js';
|
||||||
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
||||||
import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.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 {
|
import {
|
||||||
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
||||||
streamsCache, audioSourcesCache, audioTemplatesCache,
|
streamsCache, audioSourcesCache, audioTemplatesCache,
|
||||||
@@ -28,6 +28,14 @@ let _searchIndex = -1;
|
|||||||
let _searchItems = [];
|
let _searchItems = [];
|
||||||
let _loading = false;
|
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
|
// Minimap position/size persisted in localStorage
|
||||||
const _MM_KEY = 'graph_minimap';
|
const _MM_KEY = 'graph_minimap';
|
||||||
function _loadMinimapRect() {
|
function _loadMinimapRect() {
|
||||||
@@ -68,9 +76,13 @@ export async function loadGraphEditor() {
|
|||||||
try {
|
try {
|
||||||
const entities = await _fetchAllEntities();
|
const entities = await _fetchAllEntities();
|
||||||
const { nodes, edges, bounds } = await computeLayout(entities);
|
const { nodes, edges, bounds } = await computeLayout(entities);
|
||||||
|
|
||||||
|
// Apply manual position overrides from previous drag operations
|
||||||
|
_applyManualPositions(nodes, edges);
|
||||||
|
|
||||||
_nodeMap = nodes;
|
_nodeMap = nodes;
|
||||||
_edges = edges;
|
_edges = edges;
|
||||||
_bounds = bounds;
|
_bounds = _calcBounds(nodes);
|
||||||
_renderGraph(container);
|
_renderGraph(container);
|
||||||
} finally {
|
} finally {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@@ -120,6 +132,7 @@ export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); }
|
|||||||
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }
|
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }
|
||||||
|
|
||||||
export async function graphRelayout() {
|
export async function graphRelayout() {
|
||||||
|
_manualPositions.clear();
|
||||||
await loadGraphEditor();
|
await loadGraphEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +160,9 @@ async function _fetchAllEntities() {
|
|||||||
/* ── Rendering ── */
|
/* ── Rendering ── */
|
||||||
|
|
||||||
function _renderGraph(container) {
|
function _renderGraph(container) {
|
||||||
|
// Destroy previous canvas to clean up window event listeners
|
||||||
|
if (_canvas) { _canvas.destroy(); _canvas = null; }
|
||||||
|
|
||||||
container.innerHTML = _graphHTML();
|
container.innerHTML = _graphHTML();
|
||||||
|
|
||||||
const svgEl = container.querySelector('.graph-svg');
|
const svgEl = container.querySelector('.graph-svg');
|
||||||
@@ -164,6 +180,13 @@ function _renderGraph(container) {
|
|||||||
});
|
});
|
||||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
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
|
// Set bounds for view clamping, then fit
|
||||||
if (_bounds) _canvas.setBounds(_bounds);
|
if (_bounds) _canvas.setBounds(_bounds);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -182,6 +205,7 @@ function _renderGraph(container) {
|
|||||||
_renderLegend(container.querySelector('.graph-legend'));
|
_renderLegend(container.querySelector('.graph-legend'));
|
||||||
_initMinimap(container.querySelector('.graph-minimap'));
|
_initMinimap(container.querySelector('.graph-minimap'));
|
||||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||||
|
_initNodeDrag(nodeGroup, edgeGroup);
|
||||||
|
|
||||||
const searchInput = container.querySelector('.graph-search-input');
|
const searchInput = container.querySelector('.graph-search-input');
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
@@ -325,7 +349,7 @@ function _initMinimap(mmEl) {
|
|||||||
let html = '';
|
let html = '';
|
||||||
for (const node of _nodeMap.values()) {
|
for (const node of _nodeMap.values()) {
|
||||||
const color = ENTITY_COLORS[node.kind] || '#666';
|
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)
|
// Add viewport rect (updated live via _updateMinimapViewport)
|
||||||
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
||||||
@@ -556,6 +580,8 @@ function _navigateToNode(nodeId) {
|
|||||||
/* ── Node callbacks ── */
|
/* ── Node callbacks ── */
|
||||||
|
|
||||||
function _onNodeClick(node, e) {
|
function _onNodeClick(node, e) {
|
||||||
|
if (_justDragged) return; // suppress click after node drag
|
||||||
|
|
||||||
const nodeGroup = document.querySelector('.graph-nodes');
|
const nodeGroup = document.querySelector('.graph-nodes');
|
||||||
const edgeGroup = document.querySelector('.graph-edges');
|
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 ── */
|
/* ── Helpers ── */
|
||||||
|
|
||||||
function _escHtml(s) {
|
function _escHtml(s) {
|
||||||
|
|||||||
Reference in New Issue
Block a user