`;
}
/* ── Legend ── */
function _renderLegend(legendEl) {
if (!legendEl) return;
const body = legendEl.querySelector('.graph-legend-body');
if (!body) return;
let html = '';
for (const [kind, color] of Object.entries(ENTITY_COLORS)) {
const label = ENTITY_LABELS[kind] || kind;
html += `
${label}
`;
}
body.innerHTML = html;
}
function _initLegendDrag(legendEl) {
if (!legendEl) return;
const handle = legendEl.querySelector('.graph-legend-header');
_makeDraggable(legendEl, handle, { loadFn: _loadLegendPos, saveFn: _saveLegendPos });
}
/* ── 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 = getNodeDisplayColor(node.id, node.kind);
html += ``;
}
// Add viewport rect (updated live via _updateMinimapViewport)
html += ``;
svg.innerHTML = html;
// Apply saved anchored position or default to bottom-right
const saved = _loadMinimapRect();
if (saved?.anchor) {
if (saved.width) mmEl.style.width = saved.width + 'px';
if (saved.height) mmEl.style.height = saved.height + 'px';
_applyAnchor(mmEl, container, saved);
} else if (!mmEl.style.left || mmEl.style.left === '0px') {
const cr = container.getBoundingClientRect();
mmEl.style.width = '200px';
mmEl.style.height = '130px';
mmEl.style.left = (cr.width - mmEl.offsetWidth - 12) + 'px';
mmEl.style.top = (cr.height - mmEl.offsetHeight - 12) + 'px';
}
// Initial viewport update
if (_canvas) {
_updateMinimapViewport(mmEl, _canvas.getViewport());
}
// Helper to clamp minimap within container
function _clampMinimap() {
_clampElementInContainer(mmEl, container);
}
// ── 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 (uses shared _makeDraggable) ──
const header = mmEl.querySelector('.graph-minimap-header');
_makeDraggable(mmEl, header, { loadFn: () => null, saveFn: _saveMinimapRect });
// ── 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; if (!_isFullscreen()) _saveAnchored(mmEl, container, _saveMinimapRect); } });
}
}
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 };
}
/* ── Shared element clamping ── */
/** Clamp an absolutely-positioned element within its container. */
function _clampElementInContainer(el, container) {
if (!el || !container) return;
const cr = container.getBoundingClientRect();
const ew = el.offsetWidth, eh = el.offsetHeight;
if (!ew || !eh) return;
let l = el.offsetLeft, t = el.offsetTop;
const cl = Math.max(0, Math.min(cr.width - ew, l));
const ct = Math.max(0, Math.min(cr.height - eh, t));
if (cl !== l || ct !== t) {
el.style.left = cl + 'px';
el.style.top = ct + 'px';
}
return { left: cl, top: ct };
}
let _resizeObserver = null;
function _reanchorPanel(el, container, loadFn) {
if (!el) return;
if (_isFullscreen()) {
_clampElementInContainer(el, container);
} else {
const saved = loadFn();
if (saved?.anchor) {
_applyAnchor(el, container, saved);
} else {
_clampElementInContainer(el, container);
}
}
}
function _initResizeClamp(container) {
if (_resizeObserver) _resizeObserver.disconnect();
_resizeObserver = new ResizeObserver(() => {
_reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect);
_reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos);
_reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos);
});
_resizeObserver.observe(container);
}
/* ── Toolbar drag ── */
function _initToolbarDrag(tbEl) {
if (!tbEl) return;
const handle = tbEl.querySelector('.graph-toolbar-drag');
_makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos });
}
/* ── 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);
}
}
/** Navigate graph to a node by entity ID — zoom + highlight. */
export function graphNavigateToNode(entityId) {
const node = _nodeMap?.get(entityId);
if (!node || !_canvas) return false;
_canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2);
const nodeGroup = document.querySelector('.graph-nodes');
if (nodeGroup) { highlightNode(nodeGroup, entityId); setTimeout(() => highlightNode(nodeGroup, null), 3000); }
const edgeGroup = document.querySelector('.graph-edges');
if (edgeGroup && _edges) { highlightChain(edgeGroup, entityId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); }
return true;
}
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]?.();
}
function _onStartStopNode(node) {
const newRunning = !node.running;
// Optimistic update — toggle UI immediately
_updateNodeRunning(node.id, newRunning);
if (node.kind === 'output_target') {
const action = newRunning ? 'start' : 'stop';
fetchWithAuth(`/output-targets/${node.id}/${action}`, { method: 'POST' }).then(resp => {
if (resp.ok) {
showToast(t(action === 'start' ? 'device.started' : 'device.stopped'), 'success');
} else {
resp.json().catch(() => ({})).then(err => {
showToast(err.detail || t(`target.error.${action}_failed`), 'error');
});
_updateNodeRunning(node.id, !newRunning); // revert
}
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
} else if (node.kind === 'sync_clock') {
const action = newRunning ? 'resume' : 'pause';
fetchWithAuth(`/sync-clocks/${node.id}/${action}`, { method: 'POST' }).then(resp => {
if (resp.ok) {
showToast(t(action === 'pause' ? 'sync_clock.paused' : 'sync_clock.resumed'), 'success');
} else {
_updateNodeRunning(node.id, !newRunning); // revert
}
}).catch(() => { _updateNodeRunning(node.id, !newRunning); });
}
}
/** Update a node's running state in the model and patch it in-place (no re-render). */
function _updateNodeRunning(nodeId, running) {
const node = _nodeMap?.get(nodeId);
if (!node) return;
node.running = running;
const nodeGroup = document.querySelector('.graph-nodes');
const edgeGroup = document.querySelector('.graph-edges');
if (nodeGroup) {
patchNodeRunning(nodeGroup, node);
}
// Update flow dots since running set changed
if (edgeGroup) {
const runningIds = new Set();
for (const n of _nodeMap.values()) {
if (n.running) runningIds.add(n.id);
}
renderFlowDots(edgeGroup, _edges, runningIds);
}
}
function _onTestNode(node) {
const fnMap = {
capture_template: () => window.showTestTemplateModal?.(node.id),
pp_template: () => window.showTestPPTemplateModal?.(node.id),
audio_template: () => window.showTestAudioTemplateModal?.(node.id),
picture_source: () => window.showTestStreamModal?.(node.id),
audio_source: () => window.testAudioSource?.(node.id),
value_source: () => window.testValueSource?.(node.id),
color_strip_source: () => window.testColorStrip?.(node.id),
output_target: () => window.testKCTarget?.(node.id),
};
fnMap[node.kind]?.();
}
function _onNotificationTest(node) {
if (node.kind === 'color_strip_source' && node.subtype === 'notification') {
window.testNotification?.(node.id);
}
}
/* ── Keyboard ── */
function _onKeydown(e) {
// Skip when typing in search input (except Escape/F11)
const inInput = e.target.matches('input, textarea, select');
if (e.key === '/' && !_searchVisible && !inInput) { e.preventDefault(); openGraphSearch(); }
if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); }
if (e.key === 'Escape') {
if (_filterVisible) { toggleGraphFilter(); }
else if (_searchVisible) { closeGraphSearch(); }
else {
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
_deselect(ng, eg);
}
}
// Delete key → detach selected edge or delete single selected node
if (e.key === 'Delete' && !inInput) {
if (_selectedEdge) {
_detachSelectedEdge();
} else if (_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' && !inInput) {
e.preventDefault();
_selectAll();
}
// F11 → fullscreen
if (e.key === 'F11') {
e.preventDefault();
graphToggleFullscreen();
}
// + → add entity
if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
graphAddEntity();
}
// Arrow keys / WASD → spatial navigation between nodes
if (_selectedIds.size <= 1 && !_searchVisible && !inInput) {
const dir = _arrowDir(e);
if (dir) {
e.preventDefault();
_navigateDirection(dir);
}
}
}
function _arrowDir(e) {
if (e.ctrlKey || e.metaKey) return null;
switch (e.key) {
case 'ArrowLeft': case 'a': case 'A': return 'left';
case 'ArrowRight': case 'd': case 'D': return 'right';
case 'ArrowUp': case 'w': case 'W': return 'up';
case 'ArrowDown': case 's': case 'S': return 'down';
default: return null;
}
}
function _navigateDirection(dir) {
if (!_nodeMap || _nodeMap.size === 0) return;
// Get current anchor node
let anchor = null;
if (_selectedIds.size === 1) {
anchor = _nodeMap.get([..._selectedIds][0]);
}
if (!anchor) {
// Select first visible node (topmost-leftmost)
let best = null;
for (const n of _nodeMap.values()) {
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
}
if (best) {
_selectedIds.clear();
_selectedIds.add(best.id);
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
if (ng) updateSelection(ng, _selectedIds);
if (eg) clearEdgeHighlights(eg);
if (_canvas) _canvas.panTo(best.x + best.width / 2, best.y + best.height / 2, true);
}
return;
}
const cx = anchor.x + anchor.width / 2;
const cy = anchor.y + anchor.height / 2;
let bestNode = null;
let bestDist = Infinity;
for (const n of _nodeMap.values()) {
if (n.id === anchor.id) continue;
const nx = n.x + n.width / 2;
const ny = n.y + n.height / 2;
const dx = nx - cx;
const dy = ny - cy;
// Check direction constraint
let valid = false;
if (dir === 'right' && dx > 10) valid = true;
if (dir === 'left' && dx < -10) valid = true;
if (dir === 'down' && dy > 10) valid = true;
if (dir === 'up' && dy < -10) valid = true;
if (!valid) continue;
// Distance with directional bias (favor the primary axis)
const primaryDist = dir === 'left' || dir === 'right' ? Math.abs(dx) : Math.abs(dy);
const crossDist = dir === 'left' || dir === 'right' ? Math.abs(dy) : Math.abs(dx);
const dist = primaryDist + crossDist * 2;
if (dist < bestDist) {
bestDist = dist;
bestNode = n;
}
}
if (bestNode) {
_selectedIds.clear();
_selectedIds.add(bestNode.id);
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
if (ng) updateSelection(ng, _selectedIds);
if (eg && _edges) highlightChain(eg, bestNode.id, _edges);
if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true);
}
}
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');
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
const toNode = _nodeMap?.get(toId);
if (toNode && isEditableEdge(field)) {
_selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind };
} else {
_selectedEdge = null;
}
_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;
if (e.target.closest('.graph-port-out')) return; // handled by port drag
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;
}
/* ── Port drag (connect/reconnect) ── */
const SVG_NS = 'http://www.w3.org/2000/svg';
function _initPortDrag(svgEl, nodeGroup, edgeGroup) {
// Capture-phase on output ports to prevent node drag
nodeGroup.addEventListener('pointerdown', (e) => {
const port = e.target.closest('.graph-port-out');
if (!port || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
// Compute start position in graph coords (output port = right side of node)
const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2;
const startX = sourceNode.x + sourceNode.width;
const startY = sourceNode.y + portY;
// Create temporary drag edge in SVG
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
if (_canvas) _canvas.blockPan = true;
svgEl.classList.add('connecting');
// Highlight compatible input ports
const compatible = getCompatibleInputs(sourceKind);
const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`));
nodeGroup.querySelectorAll('.graph-port-in').forEach(p => {
const nKind = p.getAttribute('data-node-kind');
const pType = p.getAttribute('data-port-type');
const nId = p.getAttribute('data-node-id');
// Don't connect to self
if (nId === sourceNodeId) {
p.classList.add('graph-port-incompatible');
return;
}
if (compatibleSet.has(`${nKind}:${pType}`)) {
p.classList.add('graph-port-compatible');
} else {
p.classList.add('graph-port-incompatible');
}
});
}, true); // capture phase to beat node drag
if (!_connectListenersAdded) {
window.addEventListener('pointermove', _onConnectPointerMove);
window.addEventListener('pointerup', _onConnectPointerUp);
_connectListenersAdded = true;
}
}
function _onConnectPointerMove(e) {
if (!_connectState || !_canvas) return;
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
const { startX, startY, dragPath } = _connectState;
const dx = Math.abs(gp.x - startX) * 0.4;
dragPath.setAttribute('d',
`M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}`
);
// Highlight drop target port
const svgEl = document.querySelector('.graph-svg');
if (!svgEl) return;
const elem = document.elementFromPoint(e.clientX, e.clientY);
const port = elem?.closest?.('.graph-port-compatible');
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
if (port) port.classList.add('graph-port-drop-target');
}
function _onConnectPointerUp(e) {
if (!_connectState) return;
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;
// Clean up drag edge
dragPath.remove();
const svgEl = document.querySelector('.graph-svg');
if (svgEl) svgEl.classList.remove('connecting');
if (_canvas) _canvas.blockPan = false;
// Clean up port highlights
const nodeGroup = document.querySelector('.graph-nodes');
if (nodeGroup) {
nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
});
}
// Check if dropped on a compatible input port
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
const matches = findConnection(targetKind, sourceKind, targetPortType);
if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) {
// Multiple possible fields (e.g., template → picture_source could be capture or pp template)
// Resolve by source kind
const exact = matches.find(m => m.sourceKind === sourceKind);
if (exact) {
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
}
}
}
}
_connectState = null;
}
async function _doConnect(targetId, targetKind, field, sourceId) {
const ok = await updateConnection(targetId, targetKind, field, sourceId);
if (ok) {
showToast(t('graph.connection_updated') || 'Connection updated', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.connection_failed') || 'Failed to update connection', 'error');
}
}
/* ── Edge context menu (right-click to detach) ── */
function _onEdgeContextMenu(edgePath, e, container) {
_dismissEdgeContextMenu();
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
const menu = document.createElement('div');
menu.className = 'graph-edge-menu';
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
const btn = document.createElement('button');
btn.className = 'graph-edge-menu-item danger';
btn.textContent = t('graph.disconnect') || 'Disconnect';
btn.addEventListener('click', async () => {
_dismissEdgeContextMenu();
const ok = await detachConnection(toId, toNode.kind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
_edgeContextMenu = menu;
}
function _dismissEdgeContextMenu() {
if (_edgeContextMenu) {
_edgeContextMenu.remove();
_edgeContextMenu = null;
}
}
async function _detachSelectedEdge() {
if (!_selectedEdge) return;
const { to, field, targetKind } = _selectedEdge;
_selectedEdge = null;
const ok = await detachConnection(to, targetKind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
}
// 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);
}
});