/** * Graph editor — visual entity graph with autolayout, pan/zoom, search. */ 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, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js'; import { devicesCache, captureTemplatesCache, ppTemplatesCache, streamsCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, colorStripSourcesCache, syncClocksCache, outputTargetsCache, patternTemplatesCache, scenePresetsCache, automationsCacheObj, } from '../core/state.js'; import { t } from '../core/i18n.js'; let _canvas = null; let _nodeMap = null; let _edges = null; let _bounds = null; let _selectedIds = new Set(); let _initialized = false; let _legendVisible = false; let _minimapVisible = true; let _searchVisible = false; 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(); // Rubber-band selection state let _rubberBand = null; let _rubberBandListenersAdded = false; // Minimap position/size persisted in localStorage const _MM_KEY = 'graph_minimap'; function _loadMinimapRect() { try { return JSON.parse(localStorage.getItem(_MM_KEY)); } catch { return null; } } function _saveMinimapRect(r) { localStorage.setItem(_MM_KEY, JSON.stringify(r)); } // Toolbar position persisted in localStorage const _TB_KEY = 'graph_toolbar'; function _loadToolbarPos() { try { return JSON.parse(localStorage.getItem(_TB_KEY)); } catch { return null; } } function _saveToolbarPos(r) { localStorage.setItem(_TB_KEY, JSON.stringify(r)); } /* ── Public API ── */ export async function loadGraphEditor() { const container = document.getElementById('graph-editor-content'); if (!container) return; if (_loading) return; _loading = true; // First load: replace with spinner. Re-layout: overlay spinner on top. if (!_initialized) { container.innerHTML = '
'; } else { const overlay = document.createElement('div'); overlay.className = 'graph-loading-overlay'; overlay.innerHTML = '
'; const gc = container.querySelector('.graph-container'); if (gc) gc.appendChild(overlay); } 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 = _calcBounds(nodes); _renderGraph(container); } finally { _loading = false; } } export function openGraphSearch() { if (!_nodeMap) return; const panel = document.querySelector('.graph-search'); if (!panel) return; _searchItems = []; for (const node of _nodeMap.values()) _searchItems.push(node); _searchIndex = -1; _searchVisible = true; panel.classList.add('visible'); const input = panel.querySelector('.graph-search-input'); input.value = ''; input.focus(); _renderSearchResults(''); } export function closeGraphSearch() { _searchVisible = false; const panel = document.querySelector('.graph-search'); if (panel) panel.classList.remove('visible'); } export function toggleGraphLegend() { _legendVisible = !_legendVisible; const legend = document.querySelector('.graph-legend'); if (legend) legend.classList.toggle('visible', _legendVisible); } export function toggleGraphMinimap() { _minimapVisible = !_minimapVisible; const mm = document.querySelector('.graph-minimap'); if (mm) mm.classList.toggle('visible', _minimapVisible); } export function graphFitAll() { if (_canvas && _bounds) _canvas.fitAll(_bounds); } export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); } export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); } export async function graphRelayout() { _manualPositions.clear(); await loadGraphEditor(); } /* ── Data fetching ── */ async function _fetchAllEntities() { const [ devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets, patternTemplates, scenePresets, automations, ] = await Promise.all([ devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(), streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(), valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(), outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(), automationsCacheObj.fetch(), ]); return { devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets, patternTemplates, scenePresets, automations, }; } /* ── 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'); _canvas = new GraphCanvas(svgEl); const nodeGroup = svgEl.querySelector('.graph-nodes'); const edgeGroup = svgEl.querySelector('.graph-edges'); renderEdges(edgeGroup, _edges); renderNodes(nodeGroup, _nodeMap, { onNodeClick: _onNodeClick, onNodeDblClick: _onNodeDblClick, onEditNode: _onEditNode, onDeleteNode: _onDeleteNode, }); 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(() => { if (_canvas && _bounds) _canvas.fitAll(_bounds, false); }); _canvas.onZoomChange = (z) => { const label = container.querySelector('.graph-zoom-label'); if (label) label.textContent = `${Math.round(z * 100)}%`; }; _canvas.onViewChange = (vp) => { _updateMinimapViewport(container.querySelector('.graph-minimap'), vp); }; _renderLegend(container.querySelector('.graph-legend')); _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); _initNodeDrag(nodeGroup, edgeGroup); _initRubberBand(svgEl); // Edge click: select edge and its endpoints edgeGroup.addEventListener('click', (e) => { const edgePath = e.target.closest('.graph-edge'); if (!edgePath) return; e.stopPropagation(); _onEdgeClick(edgePath, nodeGroup, edgeGroup); }); const searchInput = container.querySelector('.graph-search-input'); if (searchInput) { searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value)); searchInput.addEventListener('keydown', _onSearchKeydown); } // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e) => { if (_canvas.wasPanning) return; if (e.shiftKey) return; // Shift+click reserved for rubber-band if (!e.target.closest('.graph-node')) { _deselect(nodeGroup, edgeGroup); } }); // Double-click empty → fit all svgEl.addEventListener('dblclick', (e) => { if (!e.target.closest('.graph-node')) graphFitAll(); }); // Prevent text selection on SVG drag svgEl.addEventListener('mousedown', (e) => { // Prevent default only on the SVG background / edges, not on inputs if (!e.target.closest('input, textarea, select')) { e.preventDefault(); } }); container.addEventListener('keydown', _onKeydown); container.setAttribute('tabindex', '0'); _initialized = true; } function _deselect(nodeGroup, edgeGroup) { _selectedIds.clear(); if (nodeGroup) { updateSelection(nodeGroup, _selectedIds); nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } if (edgeGroup) clearEdgeHighlights(edgeGroup); } function _graphHTML() { const mmRect = _loadMinimapRect(); // Default: bottom-right corner with 12px margin (computed after render via _initMinimap) const mmStyle = mmRect ? `left:${mmRect.left}px;top:${mmRect.top}px;width:${mmRect.width}px;height:${mmRect.height}px;` : ''; const tbPos = _loadToolbarPos(); const tbStyle = tbPos ? `left:${tbPos.left}px;top:${tbPos.top}px;` : ''; return `
100%
${t('graph.legend')}
${t('graph.minimap')}
`; } /* ── Legend ── */ function _renderLegend(legendEl) { if (!legendEl) return; let html = `
${t('graph.legend')}
`; for (const [kind, color] of Object.entries(ENTITY_COLORS)) { const label = ENTITY_LABELS[kind] || kind; html += `
${label}
`; } legendEl.innerHTML = html; } /* ── Minimap (draggable header & resize handle) ── */ function _initMinimap(mmEl) { if (!mmEl || !_nodeMap || !_bounds) return; const svg = mmEl.querySelector('svg'); if (!svg) return; const container = mmEl.closest('.graph-container'); const pad = 10; const vb = `${_bounds.x - pad} ${_bounds.y - pad} ${_bounds.width + pad * 2} ${_bounds.height + pad * 2}`; svg.setAttribute('viewBox', vb); let html = ''; for (const node of _nodeMap.values()) { const color = ENTITY_COLORS[node.kind] || '#666'; html += ``; } // Add viewport rect (updated live via _updateMinimapViewport) html += ``; svg.innerHTML = html; // Set default position (bottom-right corner) if no saved position if (!mmEl.style.left) { const cr = container.getBoundingClientRect(); mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px'; mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px'; mmEl.style.width = '200px'; mmEl.style.height = '130px'; } // Initial viewport update if (_canvas) { _updateMinimapViewport(mmEl, _canvas.getViewport()); } // Helper to clamp minimap within container function _clampMinimap() { const cr = container.getBoundingClientRect(); const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight; let l = parseFloat(mmEl.style.left) || 0; let t = parseFloat(mmEl.style.top) || 0; l = Math.max(0, Math.min(cr.width - mw, l)); t = Math.max(0, Math.min(cr.height - mh, t)); mmEl.style.left = l + 'px'; mmEl.style.top = t + 'px'; } // ── Click on minimap SVG → pan main canvas to that point ── let mmDraggingViewport = false; svg.addEventListener('pointerdown', (e) => { if (!_canvas || !_bounds) return; e.preventDefault(); e.stopPropagation(); mmDraggingViewport = true; svg.setPointerCapture(e.pointerId); _panToMinimapPoint(svg, e); }); svg.addEventListener('pointermove', (e) => { if (!mmDraggingViewport) return; _panToMinimapPoint(svg, e); }); svg.addEventListener('pointerup', () => { mmDraggingViewport = false; }); // ── Drag via header ── const header = mmEl.querySelector('.graph-minimap-header'); let dragStart = null, dragStartPos = null; header.addEventListener('pointerdown', (e) => { e.preventDefault(); dragStart = { x: e.clientX, y: e.clientY }; dragStartPos = { left: mmEl.offsetLeft, top: mmEl.offsetTop }; header.classList.add('dragging'); header.setPointerCapture(e.pointerId); }); header.addEventListener('pointermove', (e) => { if (!dragStart) return; const cr = container.getBoundingClientRect(); const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight; let l = dragStartPos.left + (e.clientX - dragStart.x); let t = dragStartPos.top + (e.clientY - dragStart.y); l = Math.max(0, Math.min(cr.width - mw, l)); t = Math.max(0, Math.min(cr.height - mh, t)); mmEl.style.left = l + 'px'; mmEl.style.top = t + 'px'; }); header.addEventListener('pointerup', () => { if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapRect(_mmRect(mmEl)); } }); // ── Resize handles ── _initResizeHandle(mmEl.querySelector('.graph-minimap-resize-br'), 'br'); _initResizeHandle(mmEl.querySelector('.graph-minimap-resize-bl'), 'bl'); function _initResizeHandle(rh, corner) { if (!rh) return; let rs = null, rss = null; rh.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); rs = { x: e.clientX, y: e.clientY }; rss = { w: mmEl.offsetWidth, h: mmEl.offsetHeight, left: mmEl.offsetLeft }; rh.setPointerCapture(e.pointerId); }); rh.addEventListener('pointermove', (e) => { if (!rs) return; const cr = container.getBoundingClientRect(); const dy = e.clientY - rs.y; const newH = Math.max(80, Math.min(cr.height - 20, rss.h + dy)); mmEl.style.height = newH + 'px'; if (corner === 'br') { // Bottom-right: grow width rightward, left stays fixed const dx = e.clientX - rs.x; const maxW = cr.width - mmEl.offsetLeft - 4; mmEl.style.width = Math.max(120, Math.min(maxW, rss.w + dx)) + 'px'; } else { // Bottom-left: grow width leftward, right edge stays fixed const dx = rs.x - e.clientX; const newW = Math.max(120, Math.min(cr.width - 20, rss.w + dx)); const newLeft = rss.left - (newW - rss.w); mmEl.style.width = newW + 'px'; mmEl.style.left = Math.max(0, newLeft) + 'px'; } _clampMinimap(); }); rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapRect(_mmRect(mmEl)); } }); } } function _panToMinimapPoint(svg, e) { if (!_canvas || !_bounds) return; const svgRect = svg.getBoundingClientRect(); const pad = 10; const bx = _bounds.x - pad, by = _bounds.y - pad; const bw = _bounds.width + pad * 2, bh = _bounds.height + pad * 2; const gx = bx + ((e.clientX - svgRect.left) / svgRect.width) * bw; const gy = by + ((e.clientY - svgRect.top) / svgRect.height) * bh; _canvas.panTo(gx, gy, false); } function _updateMinimapViewport(mmEl, vp) { if (!mmEl) return; const rect = mmEl.querySelector('.graph-minimap-viewport'); if (!rect) return; rect.setAttribute('x', vp.x); rect.setAttribute('y', vp.y); rect.setAttribute('width', vp.width); rect.setAttribute('height', vp.height); } function _mmRect(mmEl) { return { left: mmEl.offsetLeft, top: mmEl.offsetTop, width: mmEl.offsetWidth, height: mmEl.offsetHeight }; } /* ── Toolbar drag ── */ function _initToolbarDrag(tbEl) { if (!tbEl) return; const container = tbEl.closest('.graph-container'); const handle = tbEl.querySelector('.graph-toolbar-drag'); if (!handle) return; let dragStart = null, dragStartPos = null; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); dragStart = { x: e.clientX, y: e.clientY }; dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop }; handle.setPointerCapture(e.pointerId); }); handle.addEventListener('pointermove', (e) => { if (!dragStart) return; const cr = container.getBoundingClientRect(); const tw = tbEl.offsetWidth, th = tbEl.offsetHeight; let l = dragStartPos.left + (e.clientX - dragStart.x); let t = dragStartPos.top + (e.clientY - dragStart.y); l = Math.max(0, Math.min(cr.width - tw, l)); t = Math.max(0, Math.min(cr.height - th, t)); tbEl.style.left = l + 'px'; tbEl.style.top = t + 'px'; }); handle.addEventListener('pointerup', () => { if (dragStart) { dragStart = null; _saveToolbarPos({ left: tbEl.offsetLeft, top: tbEl.offsetTop }); } }); } /* ── Search ── */ function _renderSearchResults(query) { const results = document.querySelector('.graph-search-results'); if (!results) return; const q = query.toLowerCase().trim(); const filtered = q ? _searchItems.filter(n => n.name.toLowerCase().includes(q) || n.kind.includes(q) || (n.subtype || '').includes(q)) : _searchItems.slice(0, 20); _searchIndex = filtered.length > 0 ? 0 : -1; results.innerHTML = filtered.map((n, i) => { const color = ENTITY_COLORS[n.kind] || '#666'; return `
${_escHtml(n.name)} ${n.kind.replace(/_/g, ' ')}
`; }).join(''); results.querySelectorAll('.graph-search-item').forEach(item => { item.addEventListener('click', () => { _navigateToNode(item.getAttribute('data-id')); closeGraphSearch(); }); }); } function _onSearchKeydown(e) { const results = document.querySelectorAll('.graph-search-item'); if (e.key === 'ArrowDown') { e.preventDefault(); _searchIndex = Math.min(_searchIndex + 1, results.length - 1); _updateSearchActive(results); } else if (e.key === 'ArrowUp') { e.preventDefault(); _searchIndex = Math.max(_searchIndex - 1, 0); _updateSearchActive(results); } else if (e.key === 'Enter') { e.preventDefault(); if (results[_searchIndex]) { _navigateToNode(results[_searchIndex].getAttribute('data-id')); closeGraphSearch(); } } else if (e.key === 'Escape') { closeGraphSearch(); } } function _updateSearchActive(items) { items.forEach((el, i) => el.classList.toggle('active', i === _searchIndex)); if (items[_searchIndex]) items[_searchIndex].scrollIntoView({ block: 'nearest' }); } function _navigateToNode(nodeId) { const node = _nodeMap?.get(nodeId); if (!node || !_canvas) return; _canvas.panTo(node.x + node.width / 2, node.y + node.height / 2, true); const nodeGroup = document.querySelector('.graph-nodes'); if (nodeGroup) { highlightNode(nodeGroup, nodeId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } const edgeGroup = document.querySelector('.graph-edges'); if (edgeGroup && _edges) { highlightChain(edgeGroup, nodeId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } } /* ── 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'); if (e.shiftKey) { if (_selectedIds.has(node.id)) _selectedIds.delete(node.id); else _selectedIds.add(node.id); } else { _selectedIds.clear(); _selectedIds.add(node.id); } if (nodeGroup) updateSelection(nodeGroup, _selectedIds); if (_selectedIds.size === 1 && edgeGroup && _edges) { const chain = highlightChain(edgeGroup, node.id, _edges); if (nodeGroup) { nodeGroup.querySelectorAll('.graph-node').forEach(n => { n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25'; }); } } else if (edgeGroup) { clearEdgeHighlights(edgeGroup); if (nodeGroup) nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } } function _onNodeDblClick(node) { // Zoom to node and center it in one step if (_canvas) { _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2); } } function _onEditNode(node) { const fnMap = { device: () => window.showSettings?.(node.id), capture_template: () => window.editTemplate?.(node.id), pp_template: () => window.editPPTemplate?.(node.id), audio_template: () => window.editAudioTemplate?.(node.id), pattern_template: () => window.showPatternTemplateEditor?.(node.id), picture_source: () => window.editStream?.(node.id), audio_source: () => window.editAudioSource?.(node.id), value_source: () => window.editValueSource?.(node.id), color_strip_source: () => window.showCSSEditor?.(node.id), sync_clock: () => {}, output_target: () => window.showTargetEditor?.(node.id), scene_preset: () => window.editScenePreset?.(node.id), automation: () => window.openAutomationEditor?.(node.id), }; fnMap[node.kind]?.(); } function _onDeleteNode(node) { const fnMap = { device: () => window.removeDevice?.(node.id), capture_template: () => window.deleteTemplate?.(node.id), pp_template: () => window.deletePPTemplate?.(node.id), audio_template: () => window.deleteAudioTemplate?.(node.id), pattern_template: () => window.deletePatternTemplate?.(node.id), picture_source: () => window.deleteStream?.(node.id), audio_source: () => window.deleteAudioSource?.(node.id), value_source: () => window.deleteValueSource?.(node.id), color_strip_source: () => window.deleteColorStrip?.(node.id), output_target: () => window.deleteTarget?.(node.id), scene_preset: () => window.deleteScenePreset?.(node.id), automation: () => window.deleteAutomation?.(node.id), }; fnMap[node.kind]?.(); } /* ── Keyboard ── */ function _onKeydown(e) { if (e.key === '/' && !_searchVisible) { e.preventDefault(); openGraphSearch(); } if (e.key === 'Escape') { if (_searchVisible) { closeGraphSearch(); } else { const ng = document.querySelector('.graph-nodes'); const eg = document.querySelector('.graph-edges'); _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); } // Ctrl+A → select all if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); _selectAll(); } } function _selectAll() { if (!_nodeMap) return; _selectedIds.clear(); for (const id of _nodeMap.keys()) _selectedIds.add(id); const ng = document.querySelector('.graph-nodes'); if (ng) { updateSelection(ng, _selectedIds); ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } const eg = document.querySelector('.graph-edges'); if (eg) clearEdgeHighlights(eg); } /* ── Edge click ── */ function _onEdgeClick(edgePath, nodeGroup, edgeGroup) { const fromId = edgePath.getAttribute('data-from'); const toId = edgePath.getAttribute('data-to'); _selectedIds.clear(); _selectedIds.add(fromId); _selectedIds.add(toId); if (nodeGroup) { updateSelection(nodeGroup, _selectedIds); nodeGroup.querySelectorAll('.graph-node').forEach(n => { n.style.opacity = _selectedIds.has(n.getAttribute('data-id')) ? '1' : '0.25'; }); } if (edgeGroup) { edgeGroup.querySelectorAll('.graph-edge').forEach(p => { const isThis = p === edgePath; p.classList.toggle('highlighted', isThis); p.classList.toggle('dimmed', !isThis); }); } } /* ── Node dragging (supports multi-node) ── */ const DRAG_DEAD_ZONE = 4; function _initNodeDrag(nodeGroup, edgeGroup) { nodeGroup.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; const nodeEl = e.target.closest('.graph-node'); if (!nodeEl) return; if (e.target.closest('.graph-node-overlay-btn')) return; const nodeId = nodeEl.getAttribute('data-id'); const node = _nodeMap.get(nodeId); if (!node) return; // Multi-node drag: if dragged node is part of a multi-selection if (_selectedIds.size > 1 && _selectedIds.has(nodeId)) { _dragState = { multi: true, nodes: [..._selectedIds].map(id => ({ id, el: nodeGroup.querySelector(`.graph-node[data-id="${id}"]`), startX: _nodeMap.get(id)?.x || 0, startY: _nodeMap.get(id)?.y || 0, })).filter(n => n.el), startClient: { x: e.clientX, y: e.clientY }, dragging: false, }; } else { _dragState = { multi: false, nodeId, el: nodeEl, startClient: { x: e.clientX, y: e.clientY }, startNode: { x: node.x, y: node.y }, dragging: false, }; } e.stopPropagation(); }); 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; if (_dragState.multi) { _dragState.nodes.forEach(n => n.el?.classList.add('dragging')); } else { _dragState.el.classList.add('dragging'); // Clear chain highlights during single-node drag const eg = document.querySelector('.graph-edges'); if (eg) clearEdgeHighlights(eg); const ng = document.querySelector('.graph-nodes'); if (ng) ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } } if (!_canvas) return; const gdx = dx / _canvas.zoom; const gdy = dy / _canvas.zoom; const edgeGroup = document.querySelector('.graph-edges'); if (_dragState.multi) { for (const item of _dragState.nodes) { const node = _nodeMap.get(item.id); if (!node) continue; node.x = item.startX + gdx; node.y = item.startY + gdy; if (item.el) item.el.setAttribute('transform', `translate(${node.x}, ${node.y})`); if (edgeGroup) updateEdgesForNode(edgeGroup, item.id, _nodeMap, _edges); _updateMinimapNode(item.id, node); } if (edgeGroup) updateFlowDotsForNode(edgeGroup, null, _nodeMap, _edges); } else { 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})`); if (edgeGroup) { updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges); } _updateMinimapNode(_dragState.nodeId, node); } } function _onDragPointerUp() { if (!_dragState) return; if (_dragState.dragging) { if (_canvas) _canvas.blockPan = false; _justDragged = true; requestAnimationFrame(() => { _justDragged = false; }); if (_dragState.multi) { _dragState.nodes.forEach(n => { if (n.el) n.el.classList.remove('dragging'); const node = _nodeMap.get(n.id); if (node) _manualPositions.set(n.id, { x: node.x, y: node.y }); }); } else { _dragState.el.classList.remove('dragging'); const node = _nodeMap.get(_dragState.nodeId); if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y }); } _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; } /* ── Rubber-band selection (Shift+drag on empty space) ── */ function _initRubberBand(svgEl) { // Capture-phase: intercept Shift+click on empty space before canvas panning svgEl.addEventListener('pointerdown', (e) => { if (e.button !== 0 || !e.shiftKey) return; if (e.target.closest('.graph-node')) return; e.stopPropagation(); e.preventDefault(); _rubberBand = { startGraph: _canvas.screenToGraph(e.clientX, e.clientY), startClient: { x: e.clientX, y: e.clientY }, active: false, }; }, true); // capture phase if (!_rubberBandListenersAdded) { window.addEventListener('pointermove', _onRubberBandMove); window.addEventListener('pointerup', _onRubberBandUp); _rubberBandListenersAdded = true; } } function _onRubberBandMove(e) { if (!_rubberBand || !_canvas) return; if (!_rubberBand.active) { const dx = e.clientX - _rubberBand.startClient.x; const dy = e.clientY - _rubberBand.startClient.y; if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return; _rubberBand.active = true; } const gp = _canvas.screenToGraph(e.clientX, e.clientY); const s = _rubberBand.startGraph; const x = Math.min(s.x, gp.x), y = Math.min(s.y, gp.y); const w = Math.abs(gp.x - s.x), h = Math.abs(gp.y - s.y); const rect = document.querySelector('.graph-selection-rect'); if (rect) { rect.setAttribute('x', x); rect.setAttribute('y', y); rect.setAttribute('width', w); rect.setAttribute('height', h); rect.style.display = ''; } } function _onRubberBandUp() { if (!_rubberBand) return; const rect = document.querySelector('.graph-selection-rect'); if (_rubberBand.active && rect && _nodeMap) { const rx = parseFloat(rect.getAttribute('x')); const ry = parseFloat(rect.getAttribute('y')); const rw = parseFloat(rect.getAttribute('width')); const rh = parseFloat(rect.getAttribute('height')); _selectedIds.clear(); for (const node of _nodeMap.values()) { if (node.x + node.width > rx && node.x < rx + rw && node.y + node.height > ry && node.y < ry + rh) { _selectedIds.add(node.id); } } const ng = document.querySelector('.graph-nodes'); const eg = document.querySelector('.graph-edges'); if (ng) { updateSelection(ng, _selectedIds); ng.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } if (eg) clearEdgeHighlights(eg); } if (rect) { rect.style.display = 'none'; rect.setAttribute('width', '0'); rect.setAttribute('height', '0'); } _rubberBand = 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) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // Re-render graph when language changes (toolbar titles, legend, search placeholder use t()) document.addEventListener('languageChanged', () => { if (_initialized && _nodeMap) { const container = document.getElementById('graph-editor-content'); if (container) _renderGraph(container); } });