/** * Graph editor — visual entity graph with autolayout, pan/zoom, search. */ import { GraphCanvas } from '../core/graph-canvas.js'; import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } 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, csptCache, } from '../core/state.js'; import { fetchWithAuth } from '../core/api.js'; import { showToast, showConfirm } from '../core/ui.js'; import { t } from '../core/i18n.js'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js'; import { showTypePicker } from '../core/icon-select.js'; import * as P from '../core/icon-paths.js'; let _canvas = null; let _nodeMap = null; let _edges = null; let _bounds = null; let _selectedIds = new Set(); let _initialized = false; let _legendVisible = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })(); let _minimapVisible = true; let _searchVisible = false; let _searchIndex = -1; let _searchItems = []; let _loading = false; let _filterVisible = false; let _filterQuery = ''; // current active filter text let _filterKinds = new Set(); // empty = all kinds shown let _filterRunning = null; // null = all, true = running only, false = stopped only // 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; // Port-drag connection state let _connectState = null; // { sourceNodeId, sourceKind, portType, startPos, dragPath } let _connectListenersAdded = false; // Edge context menu let _edgeContextMenu = null; // Selected edge for Delete key detach let _selectedEdge = null; // { from, to, field, targetKind } // Minimap position/size persisted in localStorage (with anchor corner) 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)); } /** * Anchor-based positioning: detect closest corner, store offset from that corner, * and reposition from that corner on resize. Works for minimap, toolbar, etc. */ function _anchorCorner(el, container) { const cr = container.getBoundingClientRect(); const cx = el.offsetLeft + el.offsetWidth / 2; const cy = el.offsetTop + el.offsetHeight / 2; return (cy > cr.height / 2 ? 'b' : 't') + (cx > cr.width / 2 ? 'r' : 'l'); } function _saveAnchored(el, container, saveFn) { const cr = container.getBoundingClientRect(); const anchor = _anchorCorner(el, container); const data = { width: el.offsetWidth, height: el.offsetHeight, anchor, offsetX: anchor.includes('r') ? cr.width - el.offsetLeft - el.offsetWidth : el.offsetLeft, offsetY: anchor.includes('b') ? cr.height - el.offsetTop - el.offsetHeight : el.offsetTop, }; saveFn(data); return data; } function _applyAnchor(el, container, saved) { if (!saved?.anchor) return; const cr = container.getBoundingClientRect(); const w = saved.width || el.offsetWidth; const h = saved.height || el.offsetHeight; const ox = Math.max(0, saved.offsetX || 0); const oy = Math.max(0, saved.offsetY || 0); let l = saved.anchor.includes('r') ? cr.width - w - ox : ox; let t = saved.anchor.includes('b') ? cr.height - h - oy : oy; l = Math.max(0, Math.min(cr.width - el.offsetWidth, l)); t = Math.max(0, Math.min(cr.height - el.offsetHeight, t)); el.style.left = l + 'px'; el.style.top = t + 'px'; } /** True when the graph container is in fullscreen — suppress anchor persistence. */ function _isFullscreen() { return !!document.fullscreenElement; } // 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)); } // Legend position persisted in localStorage const _LG_KEY = 'graph_legend'; function _loadLegendPos() { try { return JSON.parse(localStorage.getItem(_LG_KEY)); } catch { return null; } } function _saveLegendPos(r) { localStorage.setItem(_LG_KEY, JSON.stringify(r)); } /** * Generic draggable panel setup. * @param {HTMLElement} el - The panel element * @param {HTMLElement} handle - The drag handle element * @param {object} opts - { loadFn, saveFn } */ function _makeDraggable(el, handle, { loadFn, saveFn }) { if (!el || !handle) return; const container = el.closest('.graph-container'); if (!container) return; // Apply saved anchor position or clamp const saved = loadFn(); if (saved?.anchor) { _applyAnchor(el, container, saved); } else { _clampElementInContainer(el, container); } let dragStart = null, dragStartPos = null; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); dragStart = { x: e.clientX, y: e.clientY }; dragStartPos = { left: el.offsetLeft, top: el.offsetTop }; handle.classList.add('dragging'); handle.setPointerCapture(e.pointerId); }); handle.addEventListener('pointermove', (e) => { if (!dragStart) return; const cr = container.getBoundingClientRect(); const ew = el.offsetWidth, eh = el.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 - ew, l)); t = Math.max(0, Math.min(cr.height - eh, t)); el.style.left = l + 'px'; el.style.top = t + 'px'; }); handle.addEventListener('pointerup', () => { if (dragStart) { dragStart = null; handle.classList.remove('dragging'); if (!_isFullscreen()) _saveAnchored(el, container, saveFn); } }); } /* ── 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); computePorts(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; try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {} const legend = document.querySelector('.graph-legend'); if (!legend) return; legend.classList.toggle('visible', _legendVisible); const legendBtn = document.getElementById('graph-legend-toggle'); if (legendBtn) legendBtn.classList.toggle('active', _legendVisible); if (_legendVisible) { const container = legend.closest('.graph-container'); if (container) { const saved = _loadLegendPos(); if (saved?.anchor) { _applyAnchor(legend, container, saved); } else if (!legend.style.left) { // Default to top-right const cr = container.getBoundingClientRect(); legend.style.left = (cr.width - legend.offsetWidth - 12) + 'px'; legend.style.top = '12px'; } } } } export function toggleGraphMinimap() { _minimapVisible = !_minimapVisible; const mm = document.querySelector('.graph-minimap'); if (mm) mm.classList.toggle('visible', _minimapVisible); const mmBtn = document.getElementById('graph-minimap-toggle'); if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible); } export function toggleGraphFilter() { _filterVisible = !_filterVisible; const bar = document.querySelector('.graph-filter'); if (!bar) return; bar.classList.toggle('visible', _filterVisible); if (_filterVisible) { const input = bar.querySelector('.graph-filter-input'); if (input) { input.value = _filterQuery; input.focus(); } // Restore pill active states bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => { p.classList.toggle('active', _filterKinds.has(p.dataset.kind)); }); bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); } else { _filterKinds.clear(); _filterRunning = null; _applyFilter(''); } } function _applyFilter(query) { if (query !== undefined) _filterQuery = query; const q = _filterQuery.toLowerCase().trim(); const nodeGroup = document.querySelector('.graph-nodes'); const edgeGroup = document.querySelector('.graph-edges'); const mm = document.querySelector('.graph-minimap'); if (!_nodeMap) return; const hasTextFilter = !!q; const hasKindFilter = _filterKinds.size > 0; const hasRunningFilter = _filterRunning !== null; const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter; // Build set of matching node IDs const matchIds = new Set(); for (const node of _nodeMap.values()) { const textMatch = !hasTextFilter || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q); const kindMatch = !hasKindFilter || _filterKinds.has(node.kind); const runMatch = !hasRunningFilter || (node.running === _filterRunning); if (textMatch && kindMatch && runMatch) matchIds.add(node.id); } // Apply filtered-out class to nodes if (nodeGroup) { nodeGroup.querySelectorAll('.graph-node').forEach(el => { el.classList.toggle('graph-filtered-out', hasAny && !matchIds.has(el.getAttribute('data-id'))); }); } // Dim edges where either endpoint is filtered out if (edgeGroup) { edgeGroup.querySelectorAll('.graph-edge').forEach(el => { const from = el.getAttribute('data-from'); const to = el.getAttribute('data-to'); el.classList.toggle('graph-filtered-out', hasAny && (!matchIds.has(from) || !matchIds.has(to))); }); } // Dim minimap nodes if (mm) { mm.querySelectorAll('.graph-minimap-node').forEach(el => { const id = el.getAttribute('data-id'); el.setAttribute('opacity', (!hasAny || matchIds.has(id)) ? '0.7' : '0.07'); }); } // Update filter button active state const btn = document.querySelector('.graph-filter-btn'); if (btn) btn.classList.toggle('active', hasAny); } export function graphFitAll() { if (_canvas && _bounds) _canvas.fitAll(_bounds); } export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); } export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); } export function graphToggleFullscreen() { const container = document.querySelector('#graph-editor-content .graph-container'); if (!container) return; if (document.fullscreenElement) { document.exitFullscreen(); } else { // Move bg-anim canvas into container so it's visible in fullscreen const bgCanvas = document.getElementById('bg-anim-canvas'); if (bgCanvas && !container.contains(bgCanvas)) { container.insertBefore(bgCanvas, container.firstChild); } container.requestFullscreen().catch(() => {}); } } // Restore bg-anim canvas to body when exiting fullscreen document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement) { const bgCanvas = document.getElementById('bg-anim-canvas'); if (bgCanvas && bgCanvas.parentElement !== document.body) { document.body.insertBefore(bgCanvas, document.body.firstChild); } } }); export async function graphRelayout() { if (_manualPositions.size > 0) { const ok = await showConfirm(t('graph.relayout_confirm')); if (!ok) return; } _manualPositions.clear(); await loadGraphEditor(); } // Entity kind → window function to open add/create modal + icon path const _ico = (d) => `${d}`; const ADD_ENTITY_MAP = [ { kind: 'device', fn: () => window.showAddDevice?.(), icon: _ico(P.monitor) }, { kind: 'capture_template', fn: () => window.showAddTemplateModal?.(), icon: _ico(P.camera) }, { kind: 'pp_template', fn: () => window.showAddPPTemplateModal?.(), icon: _ico(P.wrench) }, { kind: 'cspt', fn: () => window.showAddCSPTModal?.(), icon: _ico(P.wrench) }, { kind: 'audio_template', fn: () => window.showAddAudioTemplateModal?.(),icon: _ico(P.music) }, { kind: 'picture_source', fn: () => window.showAddStreamModal?.(), icon: _ico(P.tv) }, { kind: 'audio_source', fn: () => window.showAudioSourceModal?.(), icon: _ico(P.music) }, { kind: 'value_source', fn: () => window.showValueSourceModal?.(), icon: _ico(P.hash) }, { kind: 'color_strip_source', fn: () => window.showCSSEditor?.(), icon: _ico(P.film) }, { kind: 'output_target', fn: () => window.showTargetEditor?.(), icon: _ico(P.zap) }, { kind: 'automation', fn: () => window.openAutomationEditor?.(), icon: _ico(P.clipboardList) }, ]; // All caches to watch for new entity creation const ALL_CACHES = [ devicesCache, captureTemplatesCache, ppTemplatesCache, streamsCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, colorStripSourcesCache, syncClocksCache, outputTargetsCache, patternTemplatesCache, scenePresetsCache, automationsCacheObj, csptCache, ]; export function graphAddEntity() { const items = ADD_ENTITY_MAP.map(item => ({ value: item.kind, icon: item.icon, label: ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' '), })); showTypePicker({ title: t('graph.add_entity') || 'Add Entity', items, onPick: (kind) => { const entry = ADD_ENTITY_MAP.find(e => e.kind === kind); if (entry) { _watchForNewEntity(); entry.fn(); } }, }); } // Watch for new entity creation after add-entity menu action let _entityWatchCleanup = null; function _watchForNewEntity() { // Cleanup any previous watcher if (_entityWatchCleanup) _entityWatchCleanup(); // Snapshot all current IDs const knownIds = new Set(); for (const cache of ALL_CACHES) { for (const item of (cache.data || [])) { if (item.id) knownIds.add(item.id); } } const handler = (data) => { if (!Array.isArray(data)) return; for (const item of data) { if (item.id && !knownIds.has(item.id)) { // Found a new entity — reload graph and zoom to it const newId = item.id; cleanup(); loadGraphEditor().then(() => { const node = _nodeMap?.get(newId); if (node && _canvas) { // Animate zoom + pan together in one transition _canvas.zoomToPoint(1.5, node.x + node.width / 2, node.y + node.height / 2); } // Highlight the node and its chain (without re-panning) const nodeGroup = document.querySelector('.graph-nodes'); if (nodeGroup) { highlightNode(nodeGroup, newId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } const edgeGroup = document.querySelector('.graph-edges'); if (edgeGroup && _edges) { highlightChain(edgeGroup, newId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } }); return; } } }; for (const cache of ALL_CACHES) cache.subscribe(handler); // Auto-cleanup after 2 minutes (user might cancel the modal) const timeout = setTimeout(cleanup, 120_000); function cleanup() { clearTimeout(timeout); for (const cache of ALL_CACHES) cache.unsubscribe(handler); _entityWatchCleanup = null; } _entityWatchCleanup = cleanup; } /* ── Data fetching ── */ async function _fetchAllEntities() { const [ devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets, patternTemplates, scenePresets, automations, csptTemplates, batchStatesResp, ] = 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(), csptCache.fetch(), fetchWithAuth('/output-targets/batch/states').catch(() => null), ]); // Enrich output targets with running state from batch states let batchStates = {}; if (batchStatesResp && batchStatesResp.ok) { const data = await batchStatesResp.json().catch(() => ({})); batchStates = data.states || {}; } const enrichedTargets = (outputTargets || []).map(t => ({ ...t, running: batchStates[t.id]?.processing || false, })); return { devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations, csptTemplates, }; } /* ── 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, onStartStopNode: _onStartStopNode, onTestNode: _onTestNode, onNotificationTest: _onNotificationTest, }); 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); }; const legendEl = container.querySelector('.graph-legend'); _renderLegend(legendEl); _initLegendDrag(legendEl); _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); _initResizeClamp(container); _initNodeDrag(nodeGroup, edgeGroup); _initPortDrag(svgEl, 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); }); // Edge right-click: detach connection edgeGroup.addEventListener('contextmenu', (e) => { const edgePath = e.target.closest('.graph-edge'); if (!edgePath) return; e.preventDefault(); e.stopPropagation(); _onEdgeContextMenu(edgePath, e, container); }); const searchInput = container.querySelector('.graph-search-input'); if (searchInput) { searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value)); searchInput.addEventListener('keydown', _onSearchKeydown); } const filterInput = container.querySelector('.graph-filter-input'); if (filterInput) { filterInput.addEventListener('input', (e) => _applyFilter(e.target.value)); filterInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') toggleGraphFilter(); }); } const filterClear = container.querySelector('.graph-filter-clear'); if (filterClear) { filterClear.addEventListener('click', () => { if (filterInput) filterInput.value = ''; _filterKinds.clear(); _filterRunning = null; container.querySelectorAll('.graph-filter-pill').forEach(p => p.classList.remove('active')); _applyFilter(''); }); } // Entity type pills container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => { pill.addEventListener('click', () => { const kind = pill.dataset.kind; if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); } else { _filterKinds.add(kind); pill.classList.add('active'); } _applyFilter(); }); }); // Running/stopped pills container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => { pill.addEventListener('click', () => { const val = pill.dataset.running === 'true'; if (_filterRunning === val) { _filterRunning = null; pill.classList.remove('active'); } else { _filterRunning = val; // Deactivate sibling running pills, activate this one container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active')); pill.classList.add('active'); } _applyFilter(); }); }); // Restore active filter if re-rendering if ((_filterQuery || _filterKinds.size || _filterRunning !== null) && _filterVisible) { const bar = container.querySelector('.graph-filter'); if (bar) { bar.classList.add('visible'); bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => { p.classList.toggle('active', _filterKinds.has(p.dataset.kind)); }); bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); } _applyFilter(_filterQuery); } // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e) => { _dismissEdgeContextMenu(); 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(); _selectedEdge = null; if (nodeGroup) { updateSelection(nodeGroup, _selectedIds); nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1'); } if (edgeGroup) clearEdgeHighlights(edgeGroup); } function _graphHTML() { const mmRect = _loadMinimapRect(); // Only set size from saved state; position is applied in _initMinimap via anchor logic const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : ''; // Toolbar position is applied in _initToolbarDrag via anchor logic const tbPos = _loadToolbarPos(); const tbStyle = tbPos && !tbPos.anchor ? `left:${tbPos.left}px;top:${tbPos.top}px;` : ''; return `
100%
${t('graph.legend')}
${t('graph.minimap')}
${Object.entries(ENTITY_LABELS).map(([kind, label]) => `` ).join('')}
`; } /* ── 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); } });