/** * Graph editor — visual entity graph with autolayout, pan/zoom, search. */ import { GraphCanvas } from '../core/graph-canvas.ts'; import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.ts'; import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.ts'; import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.ts'; import { devicesCache, captureTemplatesCache, ppTemplatesCache, streamsCache, audioSourcesCache, audioTemplatesCache, valueSourcesCache, colorStripSourcesCache, syncClocksCache, outputTargetsCache, patternTemplatesCache, scenePresetsCache, automationsCacheObj, csptCache, } from '../core/state.ts'; import { fetchWithAuth, fetchMetricsHistory } from '../core/api.ts'; import { showToast, showConfirm, formatUptime, formatCompact } from '../core/ui.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { t } from '../core/i18n.ts'; import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.ts'; import { showTypePicker } from '../core/icon-select.ts'; import * as P from '../core/icon-paths.ts'; /* ── Local type helpers (plain objects from graph-layout) ── */ interface GraphNode { id: string; kind: string; name: string; subtype: string; tags: string[]; x?: number; y?: number; width?: number; height?: number; running?: boolean; inputPorts?: { types: string[]; ports: Record }; outputPorts?: { types: string[]; ports: Record }; [key: string]: any; } interface GraphEdge { from: string; to: string; type: string; field: string; editable?: boolean; points: Array<{ x: number; y: number }> | null; fromNode: GraphNode; toNode: GraphNode; fromPortY?: number; toPortY?: number; [key: string]: any; } interface GraphBounds { x: number; y: number; width: number; height: number; } interface AnchoredRect { width: number; height: number; anchor: string; offsetX: number; offsetY: number; } interface DockPosition { x: number; y: number; } interface UndoAction { undo: () => Promise; redo: () => Promise; label: string; } interface SelectedEdge { from: string; to: string; field: string; targetKind: string; } let _canvas: GraphCanvas | null = null; let _nodeMap: Map | null = null; let _edges: any[] | null = null; let _bounds: GraphBounds | null = null; let _selectedIds: Set = new Set(); let _initialized = false; let _legendVisible: boolean = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })(); let _minimapVisible = true; let _loading = false; let _filterVisible = false; let _filterQuery = ''; // current active filter text let _filterKinds: Set = new Set(); // empty = all kinds shown let _filterRunning: boolean | null = null; // null = all, true = running only, false = stopped only // Node drag state interface DragStateSingle { multi: false; nodeId: string; el: SVGGElement; startClient: { x: number; y: number }; startNode: { x: number; y: number }; dragging: boolean; } interface DragStateMulti { multi: true; nodes: Array<{ id: string; el: SVGGElement | null; startX: number; startY: number }>; startClient: { x: number; y: number }; dragging: boolean; } type DragState = DragStateSingle | DragStateMulti; let _dragState: DragState | null = null; let _justDragged = false; let _dragListenersAdded = false; // Manual position overrides (persisted in memory; cleared on relayout) let _manualPositions: Map = new Map(); // Rubber-band selection state interface RubberBandState { startGraph: { x: number; y: number }; startClient: { x: number; y: number }; active: boolean; } let _rubberBand: RubberBandState | null = null; let _rubberBandListenersAdded = false; // Port-drag connection state interface ConnectState { sourceNodeId: string; sourceKind: string; portType: string; startX: number; startY: number; dragPath: SVGPathElement; } let _connectState: ConnectState | null = null; let _connectListenersAdded = false; // Edge context menu let _edgeContextMenu: HTMLDivElement | null = null; // Selected edge for Delete key detach let _selectedEdge: SelectedEdge | null = null; // Minimap position/size persisted in localStorage (with anchor corner) const _MM_KEY = 'graph_minimap'; function _loadMinimapRect(): AnchoredRect | null { try { return JSON.parse(localStorage.getItem(_MM_KEY)!); } catch { return null; } } function _saveMinimapRect(r: AnchoredRect): void { 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: HTMLElement, container: HTMLElement): string { 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: HTMLElement, container: HTMLElement, saveFn: (data: AnchoredRect) => void): AnchoredRect { const cr = container.getBoundingClientRect(); const anchor = _anchorCorner(el, container); const data: AnchoredRect = { 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: HTMLElement, container: HTMLElement, saved: AnchoredRect | null): void { 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(): boolean { return !!document.fullscreenElement; } // Toolbar position persisted in localStorage const _TB_KEY = 'graph_toolbar'; const _TB_MARGIN = 12; // 8 dock positions: tl, tc, tr, cl, cr, bl, bc, br function _computeDockPositions(container: HTMLElement, el: HTMLElement): Record { const cr = container.getBoundingClientRect(); const w = el.offsetWidth, h = el.offsetHeight; const m = _TB_MARGIN; return { tl: { x: m, y: m }, tc: { x: (cr.width - w) / 2, y: m }, tr: { x: cr.width - w - m, y: m }, cl: { x: m, y: (cr.height - h) / 2 }, cr: { x: cr.width - w - m, y: (cr.height - h) / 2 }, bl: { x: m, y: cr.height - h - m }, bc: { x: (cr.width - w) / 2, y: cr.height - h - m }, br: { x: cr.width - w - m, y: cr.height - h - m }, }; } function _nearestDock(container: HTMLElement, el: HTMLElement): string { const docks = _computeDockPositions(container, el); const cx = el.offsetLeft + el.offsetWidth / 2; const cy = el.offsetTop + el.offsetHeight / 2; let best = 'tl', bestDist = Infinity; for (const [key, pos] of Object.entries(docks)) { const dx = (pos.x + el.offsetWidth / 2) - cx; const dy = (pos.y + el.offsetHeight / 2) - cy; const dist = dx * dx + dy * dy; if (dist < bestDist) { bestDist = dist; best = key; } } return best; } function _isVerticalDock(dock: string): boolean { return dock === 'cl' || dock === 'cr'; } function _applyToolbarDock(el: HTMLElement, container: HTMLElement, dock: string, animate = false): void { const isVert = _isVerticalDock(dock); el.classList.toggle('vertical', isVert); // Recompute positions after layout change requestAnimationFrame(() => { const docks = _computeDockPositions(container, el); const pos = docks[dock]; if (!pos) return; if (animate) { el.style.transition = 'left 0.25s ease, top 0.25s ease'; el.style.left = pos.x + 'px'; el.style.top = pos.y + 'px'; setTimeout(() => { el.style.transition = ''; }, 260); } else { el.style.left = pos.x + 'px'; el.style.top = pos.y + 'px'; } }); } function _loadToolbarPos(): { dock: string } | null { try { return JSON.parse(localStorage.getItem(_TB_KEY)!); } catch { return null; } } function _saveToolbarPos(r: { dock: string }): void { localStorage.setItem(_TB_KEY, JSON.stringify(r)); } // Legend position persisted in localStorage const _LG_KEY = 'graph_legend'; function _loadLegendPos(): AnchoredRect | null { try { return JSON.parse(localStorage.getItem(_LG_KEY)!); } catch { return null; } } function _saveLegendPos(r: AnchoredRect): void { 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: HTMLElement, handle: HTMLElement, { loadFn, saveFn }: { loadFn: () => AnchoredRect | null; saveFn: (data: AnchoredRect) => void }): void { if (!el || !handle) return; const container = el.closest('.graph-container') as HTMLElement; 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: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = 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 || !dragStartPos) 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(): Promise { 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 as any, edges); _nodeMap = nodes as any; _edges = edges; _bounds = _calcBounds(nodes); _renderGraph(container); } finally { _loading = false; } // Ensure keyboard focus whenever the graph is (re-)loaded container.focus(); } export function toggleGraphLegend(): void { _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') as HTMLElement; if (container) { const saved = _loadLegendPos(); if (saved?.anchor) { _applyAnchor(legend as HTMLElement, container, saved); } else if (!(legend as HTMLElement).style.left) { // Default to top-right const cr = container.getBoundingClientRect(); (legend as HTMLElement).style.left = (cr.width - (legend as HTMLElement).offsetWidth - 12) + 'px'; (legend as HTMLElement).style.top = '12px'; } } } } export function toggleGraphMinimap(): void { _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); } /* ── Filter type groups ── */ const _FILTER_GROUPS = [ { key: 'capture', kinds: ['picture_source', 'capture_template', 'pp_template'] }, { key: 'strip', kinds: ['color_strip_source', 'cspt'] }, { key: 'audio', kinds: ['audio_source', 'audio_template'] }, { key: 'targets', kinds: ['device', 'output_target', 'pattern_template'] }, { key: 'other', kinds: ['value_source', 'sync_clock', 'automation', 'scene_preset'] }, ]; function _buildFilterGroupsHTML(): string { const groupLabels = { capture: t('graph.filter_group.capture') || 'Capture', strip: t('graph.filter_group.strip') || 'Color Strip', audio: t('graph.filter_group.audio') || 'Audio', targets: t('graph.filter_group.targets') || 'Targets', other: t('graph.filter_group.other') || 'Other', }; return _FILTER_GROUPS.map(g => { const items = g.kinds.map(kind => { const label = ENTITY_LABELS[kind] || kind; const color = ENTITY_COLORS[kind] || '#666'; return ``; }).join(''); return `
${groupLabels[g.key]}
${items}
`; }).join(''); } function _updateFilterBadge(): void { const badge = document.querySelector('.graph-filter-types-badge'); if (!badge) return; const count = _filterKinds.size; badge.textContent = count > 0 ? String(count) : ''; badge.classList.toggle('visible', count > 0); // Also update toolbar button const btn = document.querySelector('.graph-filter-btn'); if (btn) btn.classList.toggle('active', count > 0 || _filterRunning !== null || !!_filterQuery); } function _syncPopoverCheckboxes(): void { const popover = document.querySelector('.graph-filter-types-popover'); if (!popover) return; popover.querySelectorAll('input[type="checkbox"]').forEach((cb: any) => { cb.checked = _filterKinds.has(cb.value); }); } export function toggleGraphFilterTypes(_btn?: HTMLElement): void { const popover = document.querySelector('.graph-filter-types-popover'); if (!popover) return; const isOpen = popover.classList.contains('visible'); if (isOpen) { popover.classList.remove('visible'); } else { _syncPopoverCheckboxes(); popover.classList.add('visible'); } } export function toggleGraphFilter(): void { _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') as HTMLInputElement; if (input) { input.value = _filterQuery; input.focus(); } // Restore running pill states bar.querySelectorAll('.graph-filter-pill[data-running]').forEach((p: HTMLElement) => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); _syncPopoverCheckboxes(); _updateFilterBadge(); } else { _filterKinds.clear(); _filterRunning = null; // Close types popover const popover = bar.querySelector('.graph-filter-types-popover'); if (popover) popover.classList.remove('visible'); _applyFilter(''); _updateFilterBadge(); } } function _applyFilter(query?: string): void { if (query !== undefined) _filterQuery = query; const q = _filterQuery.toLowerCase().trim(); const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null; const edgeGroup = document.querySelector('.graph-edges') as SVGGElement | null; const mm = document.querySelector('.graph-minimap'); if (!_nodeMap) return; // Parse structured filters: type:device, tag:foo, running:true let textPart = q; const parsedKinds = new Set(); const parsedTags: string[] = []; const tokens = q.split(/\s+/); const plainTokens: string[] = []; for (const tok of tokens) { if (tok.startsWith('type:')) { parsedKinds.add(tok.slice(5)); } else if (tok.startsWith('tag:')) { parsedTags.push(tok.slice(4)); } else { plainTokens.push(tok); } } textPart = plainTokens.join(' '); const hasTextFilter = !!textPart; const hasParsedKinds = parsedKinds.size > 0; const hasParsedTags = parsedTags.length > 0; const hasKindFilter = _filterKinds.size > 0; const hasRunningFilter = _filterRunning !== null; const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter || hasParsedKinds || hasParsedTags; // Build set of matching node IDs const matchIds = new Set(); for (const node of _nodeMap.values()) { const textMatch = !hasTextFilter || node.name.toLowerCase().includes(textPart) || node.kind.includes(textPart) || (node.subtype || '').toLowerCase().includes(textPart); const kindMatch = !hasKindFilter || _filterKinds.has(node.kind); const parsedKindMatch = !hasParsedKinds || parsedKinds.has(node.kind) || parsedKinds.has((node.subtype || '')); const tagMatch = !hasParsedTags || parsedTags.every(t => (node.tags || []).some(nt => nt.toLowerCase().includes(t))); const runMatch = !hasRunningFilter || (node.running === _filterRunning); if (textMatch && kindMatch && parsedKindMatch && tagMatch && 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(): void { if (_canvas && _bounds) _canvas.fitAll(_bounds); } export function graphZoomIn(): void { if (_canvas) _canvas.zoomIn(); } export function graphZoomOut(): void { if (_canvas) _canvas.zoomOut(); } export function graphToggleFullscreen(): void { 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(): Promise { 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: string): string => `${d}`; const _w = window as any; const ADD_ENTITY_MAP = [ { kind: 'device', fn: () => _w.showAddDevice?.(), icon: _ico(P.monitor) }, { kind: 'capture_template', fn: () => _w.showAddTemplateModal?.(), icon: _ico(P.camera) }, { kind: 'pp_template', fn: () => _w.showAddPPTemplateModal?.(), icon: _ico(P.wrench) }, { kind: 'cspt', fn: () => _w.showAddCSPTModal?.(), icon: _ico(P.wrench) }, { kind: 'audio_template', fn: () => _w.showAddAudioTemplateModal?.(),icon: _ico(P.music) }, { kind: 'picture_source', fn: () => _w.showAddStreamModal?.(), icon: _ico(P.tv) }, { kind: 'audio_source', fn: () => _w.showAudioSourceModal?.(), icon: _ico(P.music) }, { kind: 'value_source', fn: () => _w.showValueSourceModal?.(), icon: _ico(P.hash) }, { kind: 'color_strip_source', fn: () => _w.showCSSEditor?.(), icon: _ico(P.film) }, { kind: 'output_target', fn: () => _w.showTargetEditor?.(), icon: _ico(P.zap) }, { kind: 'automation', fn: () => _w.openAutomationEditor?.(), icon: _ico(P.clipboardList) }, { kind: 'sync_clock', fn: () => _w.showSyncClockModal?.(), icon: _ico(P.clock) }, { kind: 'scene_preset', fn: () => _w.editScenePreset?.(), icon: _ico(P.sparkles) }, { kind: 'pattern_template', fn: () => _w.showPatternTemplateEditor?.(),icon: _ico(P.fileText) }, ]; // 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(): void { 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: (() => void) | null = null; function _watchForNewEntity(): void { // 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: any): void => { 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') as SVGGElement | null; if (nodeGroup) { highlightNode(nodeGroup, newId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } const edgeGroup = document.querySelector('.graph-edges') as SVGGElement | null; 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(): Promise> { 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: HTMLElement): void { // Destroy previous canvas to clean up window event listeners if (_canvas) { _canvas.destroy(); _canvas = null; } container.innerHTML = _graphHTML(); const svgEl = container.querySelector('.graph-svg') as SVGSVGElement; _canvas = new GraphCanvas(svgEl); const nodeGroup = svgEl.querySelector('.graph-nodes') as SVGGElement; const edgeGroup = svgEl.querySelector('.graph-edges') as SVGGElement; renderEdges(edgeGroup, _edges!); renderNodes(nodeGroup, _nodeMap!, { onNodeClick: _onNodeClick, onNodeDblClick: _onNodeDblClick, onEditNode: _onEditNode, onDeleteNode: _onDeleteNode, onStartStopNode: _onStartStopNode, onTestNode: _onTestNode, onNotificationTest: _onNotificationTest, onCloneNode: _onCloneNode, onActivatePreset: _onActivatePreset, }); 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); _initNodeHoverTooltip(nodeGroup, container); _initPortDrag(svgEl, nodeGroup, edgeGroup); _initRubberBand(svgEl); // Edge click: select edge and its endpoints edgeGroup.addEventListener('click', (e: MouseEvent) => { const edgePath = (e.target as Element).closest('.graph-edge'); if (!edgePath) return; e.stopPropagation(); _onEdgeClick(edgePath, nodeGroup, edgeGroup); }); // Edge right-click: detach connection edgeGroup.addEventListener('contextmenu', (e: MouseEvent) => { const edgePath = (e.target as Element).closest('.graph-edge'); if (!edgePath) return; e.preventDefault(); e.stopPropagation(); _onEdgeContextMenu(edgePath, e, container); }); const filterInput = container.querySelector('.graph-filter-input'); if (filterInput) { filterInput.addEventListener('input', (e: Event) => _applyFilter((e.target as HTMLInputElement).value)); filterInput.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') toggleGraphFilter(); }); } const filterClear = container.querySelector('.graph-filter-clear'); if (filterClear) { filterClear.addEventListener('click', () => { if (filterInput) (filterInput as HTMLInputElement).value = ''; _filterKinds.clear(); _filterRunning = null; container.querySelectorAll('.graph-filter-pill').forEach(p => p.classList.remove('active')); _applyFilter(''); }); } // Entity type checkboxes in popover container.querySelectorAll('.graph-filter-type-item input[type="checkbox"]').forEach((cb: any) => { cb.addEventListener('change', () => { if (cb.checked) _filterKinds.add(cb.value); else _filterKinds.delete(cb.value); _updateFilterBadge(); _applyFilter(); }); }); // Group header toggles (click group label → toggle all in group) container.querySelectorAll('[data-group-toggle]').forEach((header: any) => { header.addEventListener('click', () => { const groupKey = header.dataset.groupToggle; const group = _FILTER_GROUPS.find(g => g.key === groupKey); if (!group) return; const allActive = group.kinds.every(k => _filterKinds.has(k)); group.kinds.forEach(k => { if (allActive) _filterKinds.delete(k); else _filterKinds.add(k); }); _syncPopoverCheckboxes(); _updateFilterBadge(); _applyFilter(); }); }); // Close popover when clicking outside container.addEventListener('click', (e: MouseEvent) => { const popover = container.querySelector('.graph-filter-types-popover'); if (!popover || !popover.classList.contains('visible')) return; if (!(e.target as Element).closest('.graph-filter-types-popover') && !(e.target as Element).closest('.graph-filter-types-btn')) { popover.classList.remove('visible'); } }); // Running/stopped pills container.querySelectorAll('.graph-filter-pill[data-running]').forEach((pill: any) => { pill.addEventListener('click', () => { const val = pill.dataset.running === 'true'; if (_filterRunning === val) { _filterRunning = null; pill.classList.remove('active'); } else { _filterRunning = val; container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active')); pill.classList.add('active'); } _updateFilterBadge(); _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'); _syncPopoverCheckboxes(); bar.querySelectorAll('.graph-filter-pill[data-running]').forEach((p: any) => { p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); }); } _updateFilterBadge(); _applyFilter(_filterQuery); } // Deselect on click on empty space (not after a pan gesture) svgEl.addEventListener('click', (e: MouseEvent) => { _dismissEdgeContextMenu(); if (_canvas!.wasPanning) return; if (e.shiftKey) return; // Shift+click reserved for rubber-band if (!(e.target as Element).closest('.graph-node')) { _deselect(nodeGroup, edgeGroup); } }); // Double-click empty → fit all svgEl.addEventListener('dblclick', (e: MouseEvent) => { if (!(e.target as Element).closest('.graph-node')) graphFitAll(); }); // Prevent text selection on SVG drag svgEl.addEventListener('mousedown', (e: MouseEvent) => { // Prevent default only on the SVG background / edges, not on inputs if (!(e.target as Element).closest('input, textarea, select')) { e.preventDefault(); } }); // Remove previous keydown listener to prevent leaks on re-render container.removeEventListener('keydown', _onKeydown); container.addEventListener('keydown', _onKeydown); container.setAttribute('tabindex', '0'); container.style.outline = 'none'; // Focus the container so keyboard shortcuts work immediately container.focus(); // Re-focus when clicking inside the graph svgEl.addEventListener('pointerdown', () => container.focus()); _initialized = true; } function _deselect(nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void { _selectedIds.clear(); _selectedEdge = null; if (nodeGroup) { updateSelection(nodeGroup, _selectedIds); nodeGroup.querySelectorAll('.graph-node').forEach((n: any) => n.style.opacity = '1'); } if (edgeGroup) clearEdgeHighlights(edgeGroup); } function _graphHTML(): string { 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;` : ''; return `
100%
${t('graph.legend')}
${t('graph.minimap')}
${_buildFilterGroupsHTML()}
`; } /* ── Legend ── */ function _renderLegend(legendEl: Element | null): void { 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: Element | null): void { if (!legendEl) return; const handle = legendEl.querySelector('.graph-legend-header') as HTMLElement; _makeDraggable(legendEl as HTMLElement, handle, { loadFn: _loadLegendPos, saveFn: _saveLegendPos }); } /* ── Minimap (draggable header & resize handle) ── */ function _initMinimap(mmElArg: HTMLElement | null): void { if (!mmElArg || !_nodeMap || !_bounds) return; const mmEl: HTMLElement = mmElArg; const svg = mmEl.querySelector('svg') as SVGSVGElement | null; if (!svg) return; const container = mmEl.closest('.graph-container') as HTMLElement; 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') as HTMLElement; _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: HTMLElement | null, corner: string): void { if (!rh) return; let rs: { x: number; y: number } | null = null, rss: { w: number; h: number; left: number } | null = 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 || !rss) 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: SVGSVGElement, e: PointerEvent): void { 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: Element | null, vp: { x: number; y: number; width: number; height: number }): void { if (!mmEl) return; const rect = mmEl.querySelector('.graph-minimap-viewport'); if (!rect) return; rect.setAttribute('x', String(vp.x)); rect.setAttribute('y', String(vp.y)); rect.setAttribute('width', String(vp.width)); rect.setAttribute('height', String(vp.height)); } function _mmRect(mmEl: HTMLElement): { left: number; top: number; width: number; height: number } { 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: HTMLElement, container: HTMLElement): { left: number; top: number } | undefined { 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: ResizeObserver | null = null; function _reanchorPanel(el: HTMLElement | null, container: HTMLElement, loadFn: () => AnchoredRect | null): void { 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: HTMLElement): void { if (_resizeObserver) _resizeObserver.disconnect(); _resizeObserver = new ResizeObserver(() => { _reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect); _reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos); _reanchorPanel(container.querySelector('.graph-help-panel.visible'), container, _loadHelpPos); // Toolbar uses dock system, not anchor system const tb = container.querySelector('.graph-toolbar') as HTMLElement | null; if (tb) { const saved = _loadToolbarPos(); const dock = saved?.dock || 'tl'; _applyToolbarDock(tb, container, dock, false); } }); _resizeObserver.observe(container); } /* ── Toolbar drag ── */ let _dockIndicators: HTMLDivElement | null = null; function _showDockIndicators(container: HTMLElement): void { _hideDockIndicators(); const cr = container.getBoundingClientRect(); const m = _TB_MARGIN + 16; // offset from edges // 8 dock positions as percentage-based fixed points const positions = { tl: { x: m, y: m }, tc: { x: cr.width / 2, y: m }, tr: { x: cr.width - m, y: m }, cl: { x: m, y: cr.height / 2 }, cr: { x: cr.width - m, y: cr.height / 2 }, bl: { x: m, y: cr.height - m }, bc: { x: cr.width / 2, y: cr.height - m }, br: { x: cr.width - m, y: cr.height - m }, }; const wrap = document.createElement('div'); wrap.className = 'graph-dock-indicators'; for (const [key, pos] of Object.entries(positions)) { const dot = document.createElement('div'); dot.className = 'graph-dock-dot'; dot.dataset.dock = key; dot.style.left = pos.x + 'px'; dot.style.top = pos.y + 'px'; wrap.appendChild(dot); } container.appendChild(wrap); _dockIndicators = wrap; } function _updateDockHighlight(container: HTMLElement, tbEl: HTMLElement): void { if (!_dockIndicators) return; const nearest = _nearestDock(container, tbEl); _dockIndicators.querySelectorAll('.graph-dock-dot').forEach(d => { d.classList.toggle('nearest', (d as HTMLElement).dataset.dock === nearest); }); } function _hideDockIndicators(): void { if (_dockIndicators) { _dockIndicators.remove(); _dockIndicators = null; } } function _initToolbarDrag(tbEl: HTMLElement | null): void { if (!tbEl) return; const container = tbEl.closest('.graph-container') as HTMLElement; if (!container) return; const handle = tbEl.querySelector('.graph-toolbar-drag') as HTMLElement | null; if (!handle) return; // Restore saved dock position const saved = _loadToolbarPos(); const dock = saved?.dock || 'tl'; _applyToolbarDock(tbEl, container, dock, false); let dragStart: { x: number; y: number } | null = null, dragStartPos: { left: number; top: number } | null = null; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); // If vertical, temporarily switch to horizontal for free dragging tbEl.classList.remove('vertical'); requestAnimationFrame(() => { dragStart = { x: e.clientX, y: e.clientY }; dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop }; handle.classList.add('dragging'); handle.setPointerCapture(e.pointerId); _showDockIndicators(container); }); }); handle.addEventListener('pointermove', (e) => { if (!dragStart || !dragStartPos) return; const cr = container.getBoundingClientRect(); const ew = tbEl.offsetWidth, eh = 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 - ew, l)); t = Math.max(0, Math.min(cr.height - eh, t)); tbEl.style.left = l + 'px'; tbEl.style.top = t + 'px'; _updateDockHighlight(container, tbEl); }); handle.addEventListener('pointerup', () => { if (!dragStart) return; dragStart = null; handle.classList.remove('dragging'); _hideDockIndicators(); // Snap to nearest dock position const newDock = _nearestDock(container, tbEl); _applyToolbarDock(tbEl, container, newDock, true); _saveToolbarPos({ dock: newDock }); }); } /* ── Node callbacks ── */ function _onNodeClick(node: any, e: MouseEvent): void { if (_justDragged) return; // suppress click after node drag const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null; const edgeGroup = document.querySelector('.graph-edges') as SVGGElement | null; 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: any) => { 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: any) => n.style.opacity = '1'); } } function _onNodeDblClick(node: any): void { // 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: string): boolean { 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') as SVGGElement | null; if (nodeGroup) { highlightNode(nodeGroup, entityId); setTimeout(() => highlightNode(nodeGroup, null), 3000); } const edgeGroup = document.querySelector('.graph-edges') as SVGGElement | null; if (edgeGroup && _edges) { highlightChain(edgeGroup, entityId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); } return true; } function _onEditNode(node: any) { const fnMap: any = { device: () => _w.showSettings?.(node.id), capture_template: () => _w.editTemplate?.(node.id), pp_template: () => _w.editPPTemplate?.(node.id), audio_template: () => _w.editAudioTemplate?.(node.id), pattern_template: () => _w.showPatternTemplateEditor?.(node.id), picture_source: () => _w.editStream?.(node.id), audio_source: () => _w.editAudioSource?.(node.id), value_source: () => _w.editValueSource?.(node.id), color_strip_source: () => _w.showCSSEditor?.(node.id), sync_clock: () => _w.editSyncClock?.(node.id), output_target: () => _w.showTargetEditor?.(node.id), cspt: () => _w.editCSPT?.(node.id), scene_preset: () => _w.editScenePreset?.(node.id), automation: () => _w.openAutomationEditor?.(node.id), }; fnMap[node.kind]?.(); } function _onDeleteNode(node: any) { const fnMap: any = { device: () => _w.removeDevice?.(node.id), capture_template: () => _w.deleteTemplate?.(node.id), pp_template: () => _w.deletePPTemplate?.(node.id), audio_template: () => _w.deleteAudioTemplate?.(node.id), pattern_template: () => _w.deletePatternTemplate?.(node.id), picture_source: () => _w.deleteStream?.(node.id), audio_source: () => _w.deleteAudioSource?.(node.id), value_source: () => _w.deleteValueSource?.(node.id), color_strip_source: () => _w.deleteColorStrip?.(node.id), output_target: () => _w.deleteTarget?.(node.id), scene_preset: () => _w.deleteScenePreset?.(node.id), automation: () => _w.deleteAutomation?.(node.id), cspt: () => _w.deleteCSPT?.(node.id), sync_clock: () => _w.deleteSyncClock?.(node.id), }; fnMap[node.kind]?.(); } async function _bulkDeleteSelected(): Promise { const count = _selectedIds.size; if (count < 2) return; const ok = await showConfirm( (t('graph.bulk_delete_confirm') || 'Delete {count} selected entities?').replace('{count}', String(count)) ); if (!ok) return; for (const id of _selectedIds) { const node = _nodeMap?.get(id); if (node) _onDeleteNode(node); } _selectedIds.clear(); } function _onCloneNode(node: any) { const fnMap: any = { device: () => _w.cloneDevice?.(node.id), capture_template: () => _w.cloneCaptureTemplate?.(node.id), pp_template: () => _w.clonePPTemplate?.(node.id), audio_template: () => _w.cloneAudioTemplate?.(node.id), pattern_template: () => _w.clonePatternTemplate?.(node.id), picture_source: () => _w.cloneStream?.(node.id), audio_source: () => _w.cloneAudioSource?.(node.id), value_source: () => _w.cloneValueSource?.(node.id), color_strip_source: () => _w.cloneColorStrip?.(node.id), output_target: () => _w.cloneTarget?.(node.id), scene_preset: () => _w.cloneScenePreset?.(node.id), automation: () => _w.cloneAutomation?.(node.id), cspt: () => _w.cloneCSPT?.(node.id), sync_clock: () => _w.cloneSyncClock?.(node.id), }; _watchForNewEntity(); fnMap[node.kind]?.(); } async function _onActivatePreset(node: any): Promise { if (node.kind !== 'scene_preset') return; try { const resp = await fetchWithAuth(`/scene-presets/${node.id}/activate`, { method: 'POST' }); if (resp.ok) { showToast(t('scene_preset.activated') || 'Preset activated', 'success'); setTimeout(() => loadGraphEditor(), 500); } else { const err = await resp.json().catch(() => ({})); showToast(err.detail || 'Activation failed', 'error'); } } catch (e) { showToast(e.message, 'error'); } } function _onStartStopNode(node: any): void { 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); }); } else if (node.kind === 'automation') { fetchWithAuth(`/automations/${node.id}`, { method: 'PUT', body: JSON.stringify({ enabled: newRunning }), }).then(resp => { if (resp.ok) { showToast(t(newRunning ? 'automation.enabled' : 'automation.disabled') || (newRunning ? 'Enabled' : 'Disabled'), 'success'); } else { _updateNodeRunning(node.id, !newRunning); } }).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: string, running: boolean): void { const node = _nodeMap?.get(nodeId); if (!node) return; node.running = running; const nodeGroup = document.querySelector('.graph-nodes') as SVGGElement | null; const edgeGroup = document.querySelector('.graph-edges') as SVGGElement | null; if (nodeGroup) { patchNodeRunning(nodeGroup, node); } // Update flow dots since running set changed if (edgeGroup) { const runningIds = new Set(); if (_nodeMap) { for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); } } renderFlowDots(edgeGroup, _edges!, runningIds); } } function _onTestNode(node: any) { const fnMap: any = { capture_template: () => _w.showTestTemplateModal?.(node.id), pp_template: () => _w.showTestPPTemplateModal?.(node.id), audio_template: () => _w.showTestAudioTemplateModal?.(node.id), picture_source: () => _w.showTestStreamModal?.(node.id), audio_source: () => _w.testAudioSource?.(node.id), value_source: () => _w.testValueSource?.(node.id), color_strip_source: () => _w.testColorStrip?.(node.id), cspt: () => _w.testCSPT?.(node.id), output_target: undefined, }; fnMap[node.kind]?.(); } function _onNotificationTest(node: any): void { if (node.kind === 'color_strip_source' && node.subtype === 'notification') { _w.testNotification?.(node.id); } } /* ── Keyboard ── */ function _onKeydown(e: KeyboardEvent): void { // Trap Tab inside the graph to prevent focus escaping to footer if (e.key === 'Tab') { e.preventDefault(); return; } // Skip when typing in search input (except Escape/F11) const inInput = (e.target as Element).matches('input, textarea, select'); if (e.key === '/' && !inInput) { e.preventDefault(); _w.openCommandPalette?.(); } if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); } if (e.key === 'Escape') { if (_filterVisible) { toggleGraphFilter(); } else { const ng = document.querySelector('.graph-nodes') as SVGGElement | null; const eg = document.querySelector('.graph-edges') as SVGGElement | null; _deselect(ng, eg); } } // Delete key → detach selected edge or delete selected node(s) 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); } else if (_selectedIds.size > 1) { _bulkDeleteSelected(); } } // 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(); } // ? → keyboard shortcuts help if (e.key === '?' && !inInput) { e.preventDefault(); toggleGraphHelp(); } // Ctrl+Z / Ctrl+Shift+Z → undo/redo if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !inInput) { e.preventDefault(); if (e.shiftKey) _redo(); else _undo(); } // Arrow keys / WASD → spatial navigation between nodes if (_selectedIds.size <= 1 && !inInput) { const dir = _arrowDir(e); if (dir) { e.preventDefault(); _navigateDirection(dir); } } } function _arrowDir(e: KeyboardEvent): string | null { 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: string): void { if (!_nodeMap || _nodeMap.size === 0) return; // Get current anchor node let anchor: any = null; if (_selectedIds.size === 1) { anchor = _nodeMap.get([..._selectedIds][0]); } if (!anchor) { // Select first visible node (topmost-leftmost) let best: any = 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') as SVGGElement | null; const eg = document.querySelector('.graph-edges') as SVGGElement | null; 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: any = 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') as SVGGElement | null; const eg = document.querySelector('.graph-edges') as SVGGElement | null; if (ng) updateSelection(ng, _selectedIds); if (eg && _edges) { const chain = highlightChain(eg, bestNode.id, _edges); // Dim non-chain nodes like _onNodeClick does if (ng) { ng.querySelectorAll('.graph-node').forEach((n: any) => { n.style.opacity = chain.has(n.getAttribute('data-id')) ? '1' : '0.25'; }); } } if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true); } } function _selectAll(): void { if (!_nodeMap) return; _selectedIds.clear(); for (const id of _nodeMap.keys()) _selectedIds.add(id); const ng = document.querySelector('.graph-nodes') as SVGGElement | null; if (ng) { updateSelection(ng, _selectedIds); ng.querySelectorAll('.graph-node').forEach((n: any) => n.style.opacity = '1'); } const eg = document.querySelector('.graph-edges') as SVGGElement | null; if (eg) clearEdgeHighlights(eg); } /* ── Edge click ── */ function _onEdgeClick(edgePath: Element, nodeGroup: SVGGElement | null, edgeGroup: SVGGElement | null): void { 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: any) => { 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: SVGGElement, _edgeGroup: SVGGElement): void { nodeGroup.addEventListener('pointerdown', (e: PointerEvent) => { if (e.button !== 0) return; const nodeEl = (e.target as Element).closest('.graph-node') as SVGGElement | null; if (!nodeEl) return; if ((e.target as Element).closest('.graph-node-overlay-btn')) return; if ((e.target as Element).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}"]`) as SVGGElement | null, 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: PointerEvent): void { 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 { const ds = _dragState as DragStateSingle; ds.el.classList.add('dragging'); // Clear chain highlights during single-node drag const eg = document.querySelector('.graph-edges') as SVGGElement | null; if (eg) clearEdgeHighlights(eg); const ng = document.querySelector('.graph-nodes') as SVGGElement | null; if (ng) ng.querySelectorAll('.graph-node').forEach((n: any) => n.style.opacity = '1'); } } if (!_canvas) return; const gdx = dx / _canvas.zoom; const gdy = dy / _canvas.zoom; const edgeGroup = document.querySelector('.graph-edges') as SVGGElement | null; 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 as any, _nodeMap!, _edges!); } else { const ds = _dragState as DragStateSingle; const node = _nodeMap!.get(ds.nodeId); if (!node) return; node.x = ds.startNode.x + gdx; node.y = ds.startNode.y + gdy; ds.el.setAttribute('transform', `translate(${node.x}, ${node.y})`); if (edgeGroup) { updateEdgesForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!); updateFlowDotsForNode(edgeGroup, ds.nodeId, _nodeMap!, _edges!); } _updateMinimapNode(ds.nodeId, node); } } function _onDragPointerUp(): void { 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 { const ds = _dragState as DragStateSingle; ds.el.classList.remove('dragging'); const node = _nodeMap!.get(ds.nodeId); if (node) _manualPositions.set(ds.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') as SVGGElement | null; 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: SVGSVGElement): void { // 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 as Element).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: PointerEvent): void { 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') as SVGElement | null; if (rect) { rect.setAttribute('x', String(x)); rect.setAttribute('y', String(y)); rect.setAttribute('width', String(w)); rect.setAttribute('height', String(h)); rect.style.display = ''; } } function _onRubberBandUp(): void { if (!_rubberBand) return; const rect = document.querySelector('.graph-selection-rect') as SVGElement | null; if (_rubberBand.active && rect && _nodeMap) { const rx = parseFloat(rect.getAttribute('x') ?? '0'); const ry = parseFloat(rect.getAttribute('y') ?? '0'); const rw = parseFloat(rect.getAttribute('width') ?? '0'); const rh = parseFloat(rect.getAttribute('height') ?? '0'); _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') as SVGGElement | null; const eg = document.querySelector('.graph-edges') as SVGGElement | null; if (ng) { updateSelection(ng, _selectedIds); ng.querySelectorAll('.graph-node').forEach((n: any) => 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: string, node: any): void { 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: Map, edges: any[]): void { 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: Map | null): GraphBounds { 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 }; } /* ── Port drag (connect/reconnect) ── */ const SVG_NS = 'http://www.w3.org/2000/svg'; function _initPortDrag(svgEl: SVGSVGElement, nodeGroup: SVGGElement, _edgeGroup: SVGGElement): void { // Capture-phase on output ports to prevent node drag nodeGroup.addEventListener('pointerdown', (e) => { const port = (e.target as Element).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: PointerEvent): void { 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: PointerEvent): void { 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') as SVGGElement | null; 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: string, targetKind: string, field: string, sourceId: string): Promise { 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'); } } /* ── Undo / Redo ── */ const _undoStack: UndoAction[] = []; const _redoStack: UndoAction[] = []; const _MAX_UNDO = 30; /** Record an undoable action. action = { undo: async fn, redo: async fn, label: string } */ export function pushUndoAction(action: UndoAction): void { _undoStack.push(action); if (_undoStack.length > _MAX_UNDO) _undoStack.shift(); _redoStack.length = 0; _updateUndoRedoButtons(); } function _updateUndoRedoButtons(): void { const undoBtn = document.getElementById('graph-undo-btn') as HTMLButtonElement | null; const redoBtn = document.getElementById('graph-redo-btn') as HTMLButtonElement | null; if (undoBtn) undoBtn.disabled = _undoStack.length === 0; if (redoBtn) redoBtn.disabled = _redoStack.length === 0; } export async function graphUndo(): Promise { await _undo(); } export async function graphRedo(): Promise { await _redo(); } async function _undo(): Promise { if (_undoStack.length === 0) { showToast(t('graph.nothing_to_undo') || 'Nothing to undo', 'info'); return; } const action = _undoStack.pop()!; try { await action.undo(); _redoStack.push(action); showToast(t('graph.undone') || `Undone: ${action.label}`, 'info'); _updateUndoRedoButtons(); await loadGraphEditor(); } catch (e) { showToast(e.message, 'error'); _updateUndoRedoButtons(); } } async function _redo(): Promise { if (_redoStack.length === 0) { showToast(t('graph.nothing_to_redo') || 'Nothing to redo', 'info'); return; } const action = _redoStack.pop()!; try { await action.redo(); _undoStack.push(action); showToast(t('graph.redone') || `Redone: ${action.label}`, 'info'); _updateUndoRedoButtons(); await loadGraphEditor(); } catch (e) { showToast(e.message, 'error'); _updateUndoRedoButtons(); } } /* ── Keyboard shortcuts help ── */ let _helpVisible = false; function _loadHelpPos(): AnchoredRect | null { try { const saved = JSON.parse(localStorage.getItem('graph_help_pos')!); return saved || { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; } catch { return { anchor: 'br', offsetX: 12, offsetY: 12, width: 0, height: 0 }; } } function _saveHelpPos(pos: AnchoredRect): void { localStorage.setItem('graph_help_pos', JSON.stringify(pos)); } export function toggleGraphHelp(): void { _helpVisible = !_helpVisible; const helpBtn = document.getElementById('graph-help-toggle'); if (helpBtn) helpBtn.classList.toggle('active', _helpVisible); let panel = document.querySelector('.graph-help-panel'); if (_helpVisible) { if (!panel) { const container = document.querySelector('#graph-editor-content .graph-container'); if (!container) return; panel = document.createElement('div'); panel.className = 'graph-help-panel visible'; panel.innerHTML = `
${t('graph.help_title')}
/ ${t('graph.help.search')}
F ${t('graph.help.filter')}
+ ${t('graph.help.add')}
? ${t('graph.help.shortcuts')}
Del ${t('graph.help.delete')}
Ctrl+A ${t('graph.help.select_all')}
Ctrl+Z ${t('graph.help.undo')}
Ctrl+Shift+Z ${t('graph.help.redo')}
F11 ${t('graph.help.fullscreen')}
Esc ${t('graph.help.deselect')}
\u2190\u2191\u2192\u2193 ${t('graph.help.navigate')}
${t('graph.help.click')} ${t('graph.help.click_desc')}
${t('graph.help.dblclick')} ${t('graph.help.dblclick_desc')}
${t('graph.help.shift_click')} ${t('graph.help.shift_click_desc')}
${t('graph.help.shift_drag')} ${t('graph.help.shift_drag_desc')}
${t('graph.help.drag_node')} ${t('graph.help.drag_node_desc')}
${t('graph.help.drag_port')} ${t('graph.help.drag_port_desc')}
${t('graph.help.right_click')} ${t('graph.help.right_click_desc')}
`; container.appendChild(panel); // Make draggable with anchor persistence const header = panel.querySelector('.graph-help-header') as HTMLElement; _makeDraggable(panel as HTMLElement, header, { loadFn: _loadHelpPos, saveFn: _saveHelpPos }); } else { panel.classList.add('visible'); } } else if (panel) { panel.classList.remove('visible'); } } /* ── Edge context menu (right-click to detach) ── */ function _onEdgeContextMenu(edgePath: Element, e: MouseEvent, container: HTMLElement): void { _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(): void { if (_edgeContextMenu) { _edgeContextMenu.remove(); _edgeContextMenu = null; } } async function _detachSelectedEdge(): Promise { 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'); } } /* ── Node hover FPS tooltip ── */ let _hoverTooltip: HTMLDivElement | null = null; // the
element, created once per graph render let _hoverTooltipChart: any = null; // Chart.js instance let _hoverTimer: ReturnType | undefined = undefined; // 300ms delay timer let _hoverPollInterval: ReturnType | undefined = undefined; // 1s polling interval let _hoverNodeId: string | null = null; // currently shown node id let _hoverFpsHistory: number[] = []; // rolling fps_actual samples let _hoverFpsCurrentHistory: number[] = []; // rolling fps_current samples const HOVER_DELAY_MS = 300; const HOVER_HISTORY_LEN = 20; function _initNodeHoverTooltip(nodeGroup: SVGGElement, container: HTMLElement): void { // Create or reset the tooltip element container.querySelector('.graph-node-tooltip')?.remove(); const tip = document.createElement('div'); tip.className = 'graph-node-tooltip'; tip.style.display = 'none'; tip.innerHTML = `
${t('graph.tooltip.errors')}
${t('graph.tooltip.uptime')}
`; container.appendChild(tip); _hoverTooltip = tip; _hoverTooltipChart = null; nodeGroup.addEventListener('pointerover', (e: PointerEvent) => { const nodeEl = (e.target as Element).closest('.graph-node.running[data-kind="output_target"]'); if (!nodeEl) return; const nodeId = nodeEl.getAttribute('data-id'); if (!nodeId) return; // Already showing for this node — nothing to do if (_hoverNodeId === nodeId && tip.style.display !== 'none') return; clearTimeout(_hoverTimer); _hoverTimer = setTimeout(() => { _showNodeTooltip(nodeId, nodeEl, container); }, HOVER_DELAY_MS); }); nodeGroup.addEventListener('pointerout', (e: PointerEvent) => { const nodeEl = (e.target as Element).closest('.graph-node'); if (!nodeEl) return; // Ignore if pointer moved to another child of the same node const related = e.relatedTarget as Node | null; if (related && nodeEl.contains(related)) return; clearTimeout(_hoverTimer); _hoverTimer = undefined; const nodeId = nodeEl.getAttribute('data-id'); if (nodeId === _hoverNodeId) { _hideNodeTooltip(); } }, true); } function _positionTooltip(_nodeEl: Element | null, container: HTMLElement): void { if (!_canvas || !_hoverTooltip || !_hoverNodeId) return; const node = _nodeMap?.get(_hoverNodeId); if (!node) return; // Convert graph-coordinate node origin to container-relative CSS pixels const cssX = (node.x - _canvas.viewX) * _canvas.zoom; const cssY = (node.y - _canvas.viewY) * _canvas.zoom; const cssW = node.width * _canvas.zoom; const tipW = _hoverTooltip.offsetWidth || 180; const tipH = _hoverTooltip.offsetHeight || 120; const contW = container.offsetWidth; const contH = container.offsetHeight; const cssH = node.height * _canvas.zoom; // Position below the node, centered horizontally let left = cssX + (cssW - tipW) / 2; left = Math.max(8, Math.min(left, contW - tipW - 8)); let top = cssY + cssH + 8; // If no room below, show above if (top + tipH > contH - 8) { top = cssY - tipH - 8; } top = Math.max(8, top); _hoverTooltip.style.left = `${left}px`; _hoverTooltip.style.top = `${top}px`; } async function _showNodeTooltip(nodeId: string, nodeEl: Element, container: HTMLElement): Promise { if (!_hoverTooltip) return; _hoverNodeId = nodeId; _hoverFpsHistory = []; _hoverFpsCurrentHistory = []; // Destroy previous chart if (_hoverTooltipChart) { _hoverTooltipChart.destroy(); _hoverTooltipChart = null; } // Seed from server-side metrics history (non-blocking) try { const hist = await fetchMetricsHistory(); if (_hoverNodeId !== nodeId) return; // user moved away during fetch if (hist) { const samples = hist.targets?.[nodeId] || []; for (const s of samples) { if (s.fps != null) _hoverFpsHistory.push(s.fps); if (s.fps_current != null) _hoverFpsCurrentHistory.push(s.fps_current); } // Trim to max length if (_hoverFpsHistory.length > HOVER_HISTORY_LEN) _hoverFpsHistory.splice(0, _hoverFpsHistory.length - HOVER_HISTORY_LEN); if (_hoverFpsCurrentHistory.length > HOVER_HISTORY_LEN) _hoverFpsCurrentHistory.splice(0, _hoverFpsCurrentHistory.length - HOVER_HISTORY_LEN); } } catch (_) { /* ignore — will populate from polls */ } if (_hoverNodeId !== nodeId) return; _hoverTooltip.style.display = ''; _hoverTooltip.classList.remove('gnt-fade-out'); _hoverTooltip.classList.add('gnt-fade-in'); _positionTooltip(nodeEl, container); // Immediate first fetch (also creates the chart with seeded history) _fetchTooltipMetrics(nodeId, container, nodeEl); // Poll every 1s clearInterval(_hoverPollInterval); _hoverPollInterval = setInterval(() => { _fetchTooltipMetrics(nodeId, container, nodeEl); }, 1000); } function _hideNodeTooltip(): void { clearInterval(_hoverPollInterval); _hoverPollInterval = undefined; _hoverNodeId = null; if (_hoverTooltipChart) { _hoverTooltipChart.destroy(); _hoverTooltipChart = null; } if (_hoverTooltip) { _hoverTooltip.classList.remove('gnt-fade-in'); _hoverTooltip.classList.add('gnt-fade-out'); _hoverTooltip.addEventListener('animationend', () => { if (_hoverTooltip?.classList.contains('gnt-fade-out')) { _hoverTooltip.style.display = 'none'; } }, { once: true }); } } async function _fetchTooltipMetrics(nodeId: string, container: HTMLElement, nodeEl: Element): Promise { if (_hoverNodeId !== nodeId) return; try { const [metricsResp, stateResp] = await Promise.all([ fetchWithAuth(`/output-targets/${nodeId}/metrics`), fetchWithAuth(`/output-targets/${nodeId}/state`), ]); if (_hoverNodeId !== nodeId) return; // node changed while fetching const metrics = metricsResp.ok ? await metricsResp.json() : {}; const state = stateResp.ok ? await stateResp.json() : {}; const fpsActual = state.fps_actual ?? 0; const fpsTarget = state.fps_target ?? 30; const fpsCurrent = state.fps_current ?? 0; const errorsCount = metrics.errors_count ?? 0; const uptimeSec = metrics.uptime_seconds ?? 0; // Update text rows if (!_hoverTooltip) return; const fpsEl = _hoverTooltip.querySelector('[data-gnt="fps"]'); const errorsEl = _hoverTooltip.querySelector('[data-gnt="errors"]'); const uptimeEl = _hoverTooltip.querySelector('[data-gnt="uptime"]'); if (fpsEl) fpsEl.innerHTML = `${fpsCurrent}/${fpsTarget}`; const avgEl = _hoverTooltip.querySelector('[data-gnt="fps-avg"]'); if (avgEl && _hoverFpsHistory.length > 0) { const avg = _hoverFpsHistory.reduce((a, b) => a + b, 0) / _hoverFpsHistory.length; avgEl.textContent = `avg ${avg.toFixed(1)}`; } if (errorsEl) errorsEl.textContent = formatCompact(errorsCount); if (uptimeEl) uptimeEl.textContent = formatUptime(uptimeSec); // Push sparkline history _hoverFpsHistory.push(fpsActual); _hoverFpsCurrentHistory.push(fpsCurrent); if (_hoverFpsHistory.length > HOVER_HISTORY_LEN) _hoverFpsHistory.shift(); if (_hoverFpsCurrentHistory.length > HOVER_HISTORY_LEN) _hoverFpsCurrentHistory.shift(); // Update or create chart if (_hoverTooltipChart) { _hoverTooltipChart.data.labels = _hoverFpsHistory.map(() => ''); _hoverTooltipChart.data.datasets[0].data = [..._hoverFpsHistory]; _hoverTooltipChart.data.datasets[1].data = [..._hoverFpsCurrentHistory]; _hoverTooltipChart.options.scales.y.max = fpsTarget * 1.15; _hoverTooltipChart.update('none'); } else { // Seed history arrays with the first value so chart renders immediately const seedActual = _hoverFpsHistory.slice(); const seedCurrent = _hoverFpsCurrentHistory.slice(); _hoverTooltipChart = createFpsSparkline( 'gnt-sparkline-canvas', seedActual, seedCurrent, fpsTarget, ); } // Re-position in case tooltip changed size _positionTooltip(nodeEl, container); } catch (_) { // Silently ignore fetch errors — tooltip will retry on next interval } } // 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); } });