40751fecb7
Lint & Test / test (push) Successful in 1m24s
- Cache per-entity colors in HALightTargetProcessor._update_lights()
- Broadcast colors_update to WS clients at target's update_rate
- WS endpoint: /api/v1/output-targets/{target_id}/ha-light/ws
- Frontend: connect WS when target runs, update swatch colors live
- Card shows colored boxes per mapped entity with entity name labels
2566 lines
105 KiB
TypeScript
2566 lines
105 KiB
TypeScript
/**
|
|
* 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<string, number> };
|
|
outputPorts?: { types: string[]; ports: Record<string, number> };
|
|
[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<void>;
|
|
redo: () => Promise<void>;
|
|
label: string;
|
|
}
|
|
|
|
interface SelectedEdge {
|
|
from: string;
|
|
to: string;
|
|
field: string;
|
|
targetKind: string;
|
|
}
|
|
|
|
let _canvas: GraphCanvas | null = null;
|
|
let _nodeMap: Map<string, any> | null = null;
|
|
let _edges: any[] | null = null;
|
|
let _bounds: GraphBounds | null = null;
|
|
let _selectedIds: Set<string> = 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<string> = 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<string, { x: number; y: number }> = 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<string, DockPosition> {
|
|
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<void> {
|
|
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 = '<div class="loading-spinner"></div>';
|
|
} else {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'graph-loading-overlay';
|
|
overlay.innerHTML = '<div class="loading-spinner"></div>';
|
|
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 `<label class="graph-filter-type-item" data-kind="${kind}">
|
|
<input type="checkbox" value="${kind}">
|
|
<span class="graph-filter-type-dot" style="background:${color}"></span>
|
|
<span>${label}</span>
|
|
</label>`;
|
|
}).join('');
|
|
return `<div class="graph-filter-type-group" data-group="${g.key}">
|
|
<div class="graph-filter-type-group-header" data-group-toggle="${g.key}">${groupLabels[g.key]}</div>
|
|
${items}
|
|
</div>`;
|
|
}).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<string>();
|
|
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<void> {
|
|
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 => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
|
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<string>();
|
|
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<Record<string, any>> {
|
|
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<string>();
|
|
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 `
|
|
<div class="graph-container" tabindex="0" role="application" aria-label="${t('graph.title')}">
|
|
<div class="graph-toolbar">
|
|
<span class="graph-toolbar-drag" title="Drag to move">⠿</span>
|
|
<button class="btn-icon" onclick="graphFitAll()" title="${t('graph.fit_all')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/></svg>
|
|
</button>
|
|
<button class="btn-icon" onclick="graphZoomIn()" title="${t('graph.zoom_in')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>
|
|
</button>
|
|
<span class="graph-zoom-label">100%</span>
|
|
<button class="btn-icon" onclick="graphZoomOut()" title="${t('graph.zoom_out')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/></svg>
|
|
</button>
|
|
<span class="graph-toolbar-sep"></span>
|
|
<button class="btn-icon" onclick="openCommandPalette()" title="${t('graph.search')} (/)">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
|
</button>
|
|
<button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
|
|
<svg class="icon" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
|
</button>
|
|
<button class="btn-icon${_legendVisible ? ' active' : ''}" id="graph-legend-toggle" onclick="toggleGraphLegend()" title="${t('graph.legend')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
|
|
</button>
|
|
<button class="btn-icon${_minimapVisible ? ' active' : ''}" id="graph-minimap-toggle" onclick="toggleGraphMinimap()" title="${t('graph.minimap')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><rect width="7" height="5" x="14" y="14" rx="1" fill="currentColor" opacity="0.3"/></svg>
|
|
</button>
|
|
<span class="graph-toolbar-sep"></span>
|
|
<button class="btn-icon" id="graph-undo-btn" onclick="graphUndo()" title="${t('graph.help.undo') || 'Undo'} (Ctrl+Z)" disabled>
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg>
|
|
</button>
|
|
<button class="btn-icon" id="graph-redo-btn" onclick="graphRedo()" title="${t('graph.help.redo') || 'Redo'} (Ctrl+Shift+Z)" disabled>
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>
|
|
</button>
|
|
<span class="graph-toolbar-sep"></span>
|
|
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
|
|
</button>
|
|
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
|
|
</button>
|
|
<span class="graph-toolbar-sep"></span>
|
|
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
|
|
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
</button>
|
|
<button class="btn-icon${_helpVisible ? ' active' : ''}" id="graph-help-toggle" onclick="toggleGraphHelp()" title="${t('graph.help_title') || 'Shortcuts'} (?)">
|
|
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="graph-legend${_legendVisible ? ' visible' : ''}">
|
|
<div class="graph-legend-header">
|
|
<span class="graph-legend-title">${t('graph.legend')}</span>
|
|
</div>
|
|
<div class="graph-legend-body"></div>
|
|
</div>
|
|
|
|
<div class="graph-minimap${_minimapVisible ? ' visible' : ''}" style="${mmStyle}">
|
|
<div class="graph-minimap-header"><span>${t('graph.minimap')}</span></div>
|
|
<div class="graph-minimap-resize graph-minimap-resize-br"></div>
|
|
<div class="graph-minimap-resize graph-minimap-resize-bl"></div>
|
|
<svg></svg>
|
|
</div>
|
|
|
|
<div class="graph-filter">
|
|
<div class="graph-filter-row">
|
|
<svg class="graph-filter-icon" viewBox="0 0 24 24" width="16" height="16"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
|
<input class="graph-filter-input" placeholder="${t('graph.filter_placeholder')}" autocomplete="off">
|
|
<button class="graph-filter-clear" title="${t('graph.filter_clear')}">×</button>
|
|
</div>
|
|
<div class="graph-filter-row graph-filter-actions">
|
|
<button class="graph-filter-types-btn" onclick="toggleGraphFilterTypes(this)">
|
|
${t('graph.filter_types') || 'Types'} <span class="graph-filter-types-badge"></span>
|
|
</button>
|
|
<button class="graph-filter-pill graph-filter-running" data-running="true" style="--pill-color:var(--success-color)" title="${t('graph.filter_running') || 'Running'}">${t('graph.filter_running') || 'Running'}</button>
|
|
<button class="graph-filter-pill graph-filter-running" data-running="false" style="--pill-color:var(--text-muted)" title="${t('graph.filter_stopped') || 'Stopped'}">${t('graph.filter_stopped') || 'Stopped'}</button>
|
|
</div>
|
|
<div class="graph-filter-types-popover">
|
|
${_buildFilterGroupsHTML()}
|
|
</div>
|
|
</div>
|
|
|
|
<svg class="graph-svg" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="running-gradient" gradientUnits="userSpaceOnUse">
|
|
<stop offset="0%" stop-color="var(--primary-color)"/>
|
|
<stop offset="50%" stop-color="var(--success-color)"/>
|
|
<stop offset="100%" stop-color="var(--primary-color)"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<rect class="graph-bg" width="100%" height="100%" fill="transparent"/>
|
|
<g class="graph-root">
|
|
<g class="graph-edges"></g>
|
|
<g class="graph-nodes"></g>
|
|
<rect class="graph-selection-rect" x="0" y="0" width="0" height="0" style="display:none"/>
|
|
</g>
|
|
</svg>
|
|
|
|
</div>`;
|
|
}
|
|
|
|
/* ── 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 += `<div class="graph-legend-item">
|
|
<span class="graph-legend-dot" style="background:${color}"></span>
|
|
<span>${label}</span>
|
|
</div>`;
|
|
}
|
|
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 += `<rect class="graph-minimap-node" data-id="${node.id}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
|
|
}
|
|
// Add viewport rect (updated live via _updateMinimapViewport)
|
|
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
|
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<void> {
|
|
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<void> {
|
|
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<string>();
|
|
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<string>();
|
|
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<string, any>, 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<string, any> | 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<void> {
|
|
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<void> { await _undo(); }
|
|
export async function graphRedo(): Promise<void> { await _redo(); }
|
|
|
|
async function _undo(): Promise<void> {
|
|
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<void> {
|
|
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 = `
|
|
<div class="graph-help-header">
|
|
<span>${t('graph.help_title')}</span>
|
|
</div>
|
|
<div class="graph-help-body">
|
|
<div class="graph-help-row"><kbd>/</kbd> <span>${t('graph.help.search')}</span></div>
|
|
<div class="graph-help-row"><kbd>F</kbd> <span>${t('graph.help.filter')}</span></div>
|
|
<div class="graph-help-row"><kbd>+</kbd> <span>${t('graph.help.add')}</span></div>
|
|
<div class="graph-help-row"><kbd>?</kbd> <span>${t('graph.help.shortcuts')}</span></div>
|
|
<div class="graph-help-row"><kbd>Del</kbd> <span>${t('graph.help.delete')}</span></div>
|
|
<div class="graph-help-row"><kbd>Ctrl+A</kbd> <span>${t('graph.help.select_all')}</span></div>
|
|
<div class="graph-help-row"><kbd>Ctrl+Z</kbd> <span>${t('graph.help.undo')}</span></div>
|
|
<div class="graph-help-row"><kbd>Ctrl+Shift+Z</kbd> <span>${t('graph.help.redo')}</span></div>
|
|
<div class="graph-help-row"><kbd>F11</kbd> <span>${t('graph.help.fullscreen')}</span></div>
|
|
<div class="graph-help-row"><kbd>Esc</kbd> <span>${t('graph.help.deselect')}</span></div>
|
|
<div class="graph-help-row"><kbd>\u2190\u2191\u2192\u2193</kbd> <span>${t('graph.help.navigate')}</span></div>
|
|
<div class="graph-help-sep"></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.click')}</span> <span>${t('graph.help.click_desc')}</span></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.dblclick')}</span> <span>${t('graph.help.dblclick_desc')}</span></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_click')}</span> <span>${t('graph.help.shift_click_desc')}</span></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.shift_drag')}</span> <span>${t('graph.help.shift_drag_desc')}</span></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_node')}</span> <span>${t('graph.help.drag_node_desc')}</span></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.drag_port')}</span> <span>${t('graph.help.drag_port_desc')}</span></div>
|
|
<div class="graph-help-row"><span class="graph-help-mouse">${t('graph.help.right_click')}</span> <span>${t('graph.help.right_click_desc')}</span></div>
|
|
</div>`;
|
|
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<void> {
|
|
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 <div> element, created once per graph render
|
|
let _hoverTooltipChart: any = null; // Chart.js instance
|
|
let _hoverTimer: ReturnType<typeof setTimeout> | undefined = undefined; // 300ms delay timer
|
|
let _hoverPollInterval: ReturnType<typeof setInterval> | 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 = `
|
|
<div class="gnt-row"><span class="gnt-label">${t('graph.tooltip.errors')}</span><span class="gnt-value" data-gnt="errors">—</span></div>
|
|
<div class="gnt-row"><span class="gnt-label">${t('graph.tooltip.uptime')}</span><span class="gnt-value" data-gnt="uptime">—</span></div>
|
|
<div class="target-fps-row gnt-fps-row">
|
|
<div class="target-fps-sparkline"><canvas id="gnt-sparkline-canvas"></canvas></div>
|
|
<div class="target-fps-label">
|
|
<span class="metric-value" data-gnt="fps">—</span>
|
|
<span class="target-fps-avg" data-gnt="fps-avg"></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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<void> {
|
|
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<void> {
|
|
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}<span class="target-fps-target">/${fpsTarget}</span>`;
|
|
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);
|
|
}
|
|
});
|