project-graph-mcp 2.2.6 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +81 -0
- package/CHANGELOG.md +57 -0
- package/README.md +9 -4
- package/package.json +4 -13
- package/project-graph-mcp-2.3.0.tgz +0 -0
- package/src/compact/expand.js +1 -1
- package/src/core/graph-builder.js +2 -2
- package/src/core/parser.js +2 -2
- package/src/network/server.js +1 -2
- package/src/network/web-server.js +1 -1
- package/vendor/symbiote-node/CHANGELOG.md +31 -0
- package/vendor/symbiote-node/LICENSE +21 -0
- package/vendor/symbiote-node/README.md +206 -0
- package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
- package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
- package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
- package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
- package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
- package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
- package/vendor/symbiote-node/canvas/LODManager.js +88 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
- package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
- package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
- package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
- package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
- package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
- package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
- package/vendor/symbiote-node/core/Connection.js +45 -0
- package/vendor/symbiote-node/core/Editor.js +451 -0
- package/vendor/symbiote-node/core/Frame.js +31 -0
- package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
- package/vendor/symbiote-node/core/GraphText.js +210 -0
- package/vendor/symbiote-node/core/Node.js +143 -0
- package/vendor/symbiote-node/core/Portal.js +104 -0
- package/vendor/symbiote-node/core/Socket.js +185 -0
- package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
- package/vendor/symbiote-node/index.js +103 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
- package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
- package/vendor/symbiote-node/interactions/Drag.js +102 -0
- package/vendor/symbiote-node/interactions/Selector.js +132 -0
- package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
- package/vendor/symbiote-node/interactions/Zoom.js +140 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
- package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
- package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
- package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
- package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
- package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
- package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
- package/vendor/symbiote-node/layout/index.js +16 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
- package/vendor/symbiote-node/package.json +59 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
- package/vendor/symbiote-node/plugins/History.js +384 -0
- package/vendor/symbiote-node/plugins/Readonly.js +59 -0
- package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
- package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
- package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
- package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
- package/vendor/symbiote-node/shapes/PillShape.js +91 -0
- package/vendor/symbiote-node/shapes/RectShape.js +72 -0
- package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
- package/vendor/symbiote-node/shapes/index.js +53 -0
- package/vendor/symbiote-node/themes/Palette.js +32 -0
- package/vendor/symbiote-node/themes/Skin.js +113 -0
- package/vendor/symbiote-node/themes/Theme.js +84 -0
- package/vendor/symbiote-node/themes/carbon.js +137 -0
- package/vendor/symbiote-node/themes/dark.js +137 -0
- package/vendor/symbiote-node/themes/ebook.js +138 -0
- package/vendor/symbiote-node/themes/grey.js +137 -0
- package/vendor/symbiote-node/themes/light.js +137 -0
- package/vendor/symbiote-node/themes/neon.js +138 -0
- package/vendor/symbiote-node/themes/pcb.js +273 -0
- package/vendor/symbiote-node/themes/synthwave.js +137 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
- package/web/app.js +9 -5
- package/web/components/canvas-graph.js +1705 -0
- package/web/components/code-block.js +1 -1
- package/web/components/event-feed/CodeWidget.js +32 -0
- package/web/components/event-feed/EventWidget.js +97 -0
- package/web/components/event-feed/ListWidget.js +57 -0
- package/web/components/event-feed/MiniGraphWidget.js +159 -0
- package/web/components/follow-ribbon.js +134 -0
- package/web/dashboard.js +1 -1
- package/web/follow-controller.js +241 -0
- package/web/index.html +4 -0
- package/web/panels/ActionBoard/ActionBoard.js +1 -1
- package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
- package/web/panels/code-viewer.js +50 -15
- package/web/panels/dep-graph.js +2691 -7
- package/web/panels/file-tree.js +5 -2
- package/web/panels/live-monitor.js +75 -3
- package/web/style.css +39 -0
- package/docs/img/explorer-compact.jpg +0 -0
- package/docs/img/explorer-expanded.jpg +0 -0
- package/src/.contextignore +0 -22
- package/src/.project-graph-cache.json +0 -1
- package/src/compact/.project-graph-cache.json +0 -1
- package/web/.project-graph-cache.json +0 -1
- package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
|
@@ -0,0 +1,1840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NodeCanvas — main graph viewport (facade)
|
|
3
|
+
*
|
|
4
|
+
* Thin orchestration layer that delegates to:
|
|
5
|
+
* - NodeViewManager (node CRUD + group drag)
|
|
6
|
+
* - ConnectionRenderer (SVG paths + gradients + flow)
|
|
7
|
+
* - PseudoConnection (temp drag line)
|
|
8
|
+
* - ViewportActions (context menu + keyboard + fitView)
|
|
9
|
+
*
|
|
10
|
+
* @module symbiote-node/canvas/NodeCanvas
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Symbiote from '@symbiotejs/symbiote';
|
|
14
|
+
import { template } from './NodeCanvas.tpl.js';
|
|
15
|
+
import { styles } from './NodeCanvas.css.js';
|
|
16
|
+
import { Drag } from '../../interactions/Drag.js';
|
|
17
|
+
import { Zoom } from '../../interactions/Zoom.js';
|
|
18
|
+
import { ConnectFlow } from '../../interactions/ConnectFlow.js';
|
|
19
|
+
import { Selector } from '../../interactions/Selector.js';
|
|
20
|
+
import { SnapGrid } from '../../interactions/SnapGrid.js';
|
|
21
|
+
import { applyTheme, DARK_DEFAULT } from '../../themes/Theme.js';
|
|
22
|
+
import { applyPalette } from '../../themes/Palette.js';
|
|
23
|
+
import { applySkin } from '../../themes/Skin.js';
|
|
24
|
+
import { NodeViewManager } from '../NodeViewManager.js';
|
|
25
|
+
import { ConnectionRenderer } from '../ConnectionRenderer.js';
|
|
26
|
+
import { CanvasConnectionRenderer } from '../CanvasConnectionRenderer.js';
|
|
27
|
+
import { PseudoConnection } from '../PseudoConnection.js';
|
|
28
|
+
import { ViewportActions } from '../ViewportActions.js';
|
|
29
|
+
import { SubgraphManager } from '../SubgraphManager.js';
|
|
30
|
+
import '../../menu/ContextMenu/ContextMenu.js';
|
|
31
|
+
import '../../toolbar/QuickToolbar/QuickToolbar.js';
|
|
32
|
+
import '../../node/GraphFrame/GraphFrame.js';
|
|
33
|
+
import '../../inspector/InspectorPanel/InspectorPanel.js';
|
|
34
|
+
import '../Minimap/Minimap.js';
|
|
35
|
+
import '../NodeSearch/NodeSearch.js';
|
|
36
|
+
import '../Breadcrumb/Breadcrumb.js';
|
|
37
|
+
import { computeAutoLayout } from '../AutoLayout.js';
|
|
38
|
+
|
|
39
|
+
export class NodeCanvas extends Symbiote {
|
|
40
|
+
|
|
41
|
+
init$ = {
|
|
42
|
+
zoom: 1,
|
|
43
|
+
panX: 0,
|
|
44
|
+
panY: 0,
|
|
45
|
+
'+contentTransform': () => `translate(${this.$.panX}px, ${this.$.panY}px) scale(${this.$.zoom})`,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** @type {import('../core/Editor.js').NodeEditor|null} */
|
|
49
|
+
#editor = null;
|
|
50
|
+
|
|
51
|
+
/** @type {Drag|null} */
|
|
52
|
+
#drag = null;
|
|
53
|
+
|
|
54
|
+
/** @type {Zoom|null} */
|
|
55
|
+
#zoom = null;
|
|
56
|
+
|
|
57
|
+
/** @type {ConnectFlow|null} */
|
|
58
|
+
#connectFlow = null;
|
|
59
|
+
|
|
60
|
+
/** @type {Selector} */
|
|
61
|
+
#selector = new Selector({
|
|
62
|
+
onChange: (nodes, connections) => this.#onSelectionChanged(nodes, connections),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** @type {SnapGrid} */
|
|
66
|
+
#snapGrid = new SnapGrid({ size: 16, dynamic: false });
|
|
67
|
+
|
|
68
|
+
/** @type {Map<string, HTMLElement>} */
|
|
69
|
+
#nodeViews = new Map();
|
|
70
|
+
|
|
71
|
+
/** @type {Map<string, HTMLElement>} */
|
|
72
|
+
#frameViews = new Map();
|
|
73
|
+
|
|
74
|
+
/** @type {boolean} */
|
|
75
|
+
#readonly = false;
|
|
76
|
+
|
|
77
|
+
/** @type {boolean} */
|
|
78
|
+
#snapEnabled = false;
|
|
79
|
+
|
|
80
|
+
/** @type {number|null} */
|
|
81
|
+
#panAnimFrame = null;
|
|
82
|
+
|
|
83
|
+
/** @type {string} */
|
|
84
|
+
#themeName = 'dark-default';
|
|
85
|
+
|
|
86
|
+
/** @type {NodeViewManager|null} */
|
|
87
|
+
#viewManager = null;
|
|
88
|
+
|
|
89
|
+
/** @type {ConnectionRenderer|null} */
|
|
90
|
+
#connRenderer = null;
|
|
91
|
+
|
|
92
|
+
/** @type {PseudoConnection|null} */
|
|
93
|
+
#pseudo = null;
|
|
94
|
+
|
|
95
|
+
/** @type {ViewportActions|null} */
|
|
96
|
+
#actions = null;
|
|
97
|
+
|
|
98
|
+
/** @type {number} */
|
|
99
|
+
#zCounter = 0;
|
|
100
|
+
|
|
101
|
+
/** @type {'bezier'|'orthogonal'|'straight'|'pcb'} saved across setEditor calls */
|
|
102
|
+
#pathStyle = 'bezier';
|
|
103
|
+
|
|
104
|
+
// ─── Virtualization (Canvas LOD) ───
|
|
105
|
+
/** All node data objects from editor — the full set regardless of DOM state */
|
|
106
|
+
#allNodes = new Map();
|
|
107
|
+
/** Position + size cache for nodes without DOM (phantom rendering on Canvas) */
|
|
108
|
+
#phantomData = new Map();
|
|
109
|
+
/** Degree (connection count) per node — for dot sizing */
|
|
110
|
+
#nodeDegrees = new Map();
|
|
111
|
+
/** Debounce timer for promote/demote batch */
|
|
112
|
+
#virtTimer = null;
|
|
113
|
+
/** Dirty flag — phantom positions changed, needs re-sync to renderer */
|
|
114
|
+
#phantomDirty = false;
|
|
115
|
+
|
|
116
|
+
// --- Public API ---
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clear all existing node, connection, and frame views from the DOM.
|
|
120
|
+
* Called before switching to a new editor to ensure clean state.
|
|
121
|
+
*/
|
|
122
|
+
#clearViews() {
|
|
123
|
+
// Remove all node views and their preview timers
|
|
124
|
+
for (const [id, el] of this.#nodeViews) {
|
|
125
|
+
if (el._previewRaf) { clearTimeout(el._previewRaf); el._previewRaf = null; }
|
|
126
|
+
if (el._drag) el._drag.destroy();
|
|
127
|
+
el._redrawPreview = null;
|
|
128
|
+
el.remove();
|
|
129
|
+
}
|
|
130
|
+
this.#nodeViews.clear();
|
|
131
|
+
this.#allNodes.clear();
|
|
132
|
+
this.#phantomData.clear();
|
|
133
|
+
this.#nodeDegrees.clear();
|
|
134
|
+
if (this.#virtTimer) { clearTimeout(this.#virtTimer); this.#virtTimer = null; }
|
|
135
|
+
|
|
136
|
+
// Unsubscribe from previous editor events to prevent leaks
|
|
137
|
+
if (this.#editor) {
|
|
138
|
+
this.#editor.removeAllListeners?.();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Remove all connection SVG paths
|
|
142
|
+
if (this.#connRenderer) {
|
|
143
|
+
const conns = [...this.#connRenderer.data.values()];
|
|
144
|
+
for (const conn of conns) {
|
|
145
|
+
this.#connRenderer.remove(conn);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Remove all frame views
|
|
150
|
+
for (const [, el] of this.#frameViews) {
|
|
151
|
+
if (el._drag) el._drag.destroy();
|
|
152
|
+
if (el._resizeDrag) el._resizeDrag.destroy();
|
|
153
|
+
el.remove();
|
|
154
|
+
}
|
|
155
|
+
this.#frameViews.clear();
|
|
156
|
+
|
|
157
|
+
// Clear selection state
|
|
158
|
+
if (this.#selector) this.#selector.unselectAll();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Bind editor to canvas
|
|
163
|
+
* @param {import('../core/Editor.js').NodeEditor} editor
|
|
164
|
+
*/
|
|
165
|
+
setEditor(editor) {
|
|
166
|
+
// Clear previous views before switching
|
|
167
|
+
this.#clearViews();
|
|
168
|
+
|
|
169
|
+
this.#editor = editor;
|
|
170
|
+
|
|
171
|
+
const engineMode = this.getAttribute('connection-engine') || 'svg';
|
|
172
|
+
|
|
173
|
+
if (engineMode === 'canvas') {
|
|
174
|
+
this.#connRenderer = new CanvasConnectionRenderer({
|
|
175
|
+
canvasLayer: this.ref.connCanvas,
|
|
176
|
+
dotLayer: this.ref.pseudoSvg,
|
|
177
|
+
nodeViews: this.#nodeViews,
|
|
178
|
+
editor,
|
|
179
|
+
onConnectionClick: (connId, e) => this.#handleConnectionClick(connId, e),
|
|
180
|
+
getZoom: () => this.$.zoom,
|
|
181
|
+
getPan: () => ({ x: this.$.panX, y: this.$.panY }),
|
|
182
|
+
onDotDrag: (socketData) => {
|
|
183
|
+
if (this.#connectFlow && !this.#readonly) {
|
|
184
|
+
this.#connectFlow.pickSocket(socketData);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
this.#connRenderer = new ConnectionRenderer({
|
|
190
|
+
svgLayer: this.ref.connections,
|
|
191
|
+
dotLayer: this.ref.pseudoSvg,
|
|
192
|
+
nodeViews: this.#nodeViews,
|
|
193
|
+
editor,
|
|
194
|
+
onConnectionClick: (connId, e) => this.#handleConnectionClick(connId, e),
|
|
195
|
+
getZoom: () => this.$.zoom,
|
|
196
|
+
onDotDrag: (socketData) => {
|
|
197
|
+
if (this.#connectFlow && !this.#readonly) {
|
|
198
|
+
this.#connectFlow.pickSocket(socketData);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// For test automation
|
|
205
|
+
this._connRenderer = this.#connRenderer;
|
|
206
|
+
|
|
207
|
+
// Re-apply saved pathStyle after creating new renderer
|
|
208
|
+
if (this.#pathStyle !== 'bezier') {
|
|
209
|
+
this.#connRenderer.setPathStyle(this.#pathStyle);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.#pseudo = new PseudoConnection(this.ref.pseudoSvg);
|
|
213
|
+
|
|
214
|
+
this.#actions = new ViewportActions({
|
|
215
|
+
editor,
|
|
216
|
+
selector: this.#selector,
|
|
217
|
+
nodeViews: this.#nodeViews,
|
|
218
|
+
canvas: this,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Quick Action Toolbar
|
|
222
|
+
const toolbar = this.ref.quickToolbar;
|
|
223
|
+
if (toolbar) {
|
|
224
|
+
toolbar._onAction = (action, nodeId) => {
|
|
225
|
+
const nodeEl = this.#nodeViews.get(nodeId);
|
|
226
|
+
switch (action) {
|
|
227
|
+
case 'delete': this.#actions.deleteNode(nodeId); toolbar.hide(); break;
|
|
228
|
+
case 'duplicate': this.#actions.cloneNode(nodeId); break;
|
|
229
|
+
case 'enter': this.drillDown(nodeId); toolbar.hide(); break;
|
|
230
|
+
case 'mute':
|
|
231
|
+
this.#actions.muteNode(nodeId);
|
|
232
|
+
if (nodeEl) toolbar.show(nodeId, nodeEl);
|
|
233
|
+
break;
|
|
234
|
+
default:
|
|
235
|
+
// Custom actions — dispatch event for consumer (e.g. dep-graph explore)
|
|
236
|
+
this.dispatchEvent(new CustomEvent('toolbar-action', {
|
|
237
|
+
detail: { action, nodeId },
|
|
238
|
+
bubbles: true,
|
|
239
|
+
}));
|
|
240
|
+
toolbar.hide();
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.#viewManager = new NodeViewManager({
|
|
247
|
+
nodeViews: this.#nodeViews,
|
|
248
|
+
editor,
|
|
249
|
+
selector: this.#selector,
|
|
250
|
+
snapGrid: this.#snapGrid,
|
|
251
|
+
getZoom: () => this.$.zoom,
|
|
252
|
+
setNodePosition: (id, x, y) => this.setNodePosition(id, x, y),
|
|
253
|
+
animateNodeToPosition: (id, x, y) => this.animateNodeToPosition(id, x, y),
|
|
254
|
+
onNodeClick: (id, e) => this.#handleNodeClick(id, e),
|
|
255
|
+
nodesLayer: this.ref.nodesLayer,
|
|
256
|
+
canvas: this,
|
|
257
|
+
onSvgShapeReady: (nodeId) => this.#connRenderer?.renderFreeDots(nodeId),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ConnectFlow
|
|
261
|
+
this.#connectFlow = new ConnectFlow(editor, {
|
|
262
|
+
getNodePosition: (id) => {
|
|
263
|
+
const el = this.#nodeViews.get(id);
|
|
264
|
+
return el?._position || { x: 0, y: 0 };
|
|
265
|
+
},
|
|
266
|
+
getNodeSize: (id) => {
|
|
267
|
+
const el = this.#nodeViews.get(id);
|
|
268
|
+
return { width: el?.offsetWidth || 180, height: el?.offsetHeight || 60 };
|
|
269
|
+
},
|
|
270
|
+
getTransform: () => ({
|
|
271
|
+
k: this.$.zoom,
|
|
272
|
+
x: this.$.panX,
|
|
273
|
+
y: this.$.panY,
|
|
274
|
+
rect: this.ref.canvasContainer.getBoundingClientRect(),
|
|
275
|
+
}),
|
|
276
|
+
onPseudoStart: (sx, sy, socketData) => {
|
|
277
|
+
this.#actions.highlightCompatibleSockets(socketData, this.ref.nodesLayer);
|
|
278
|
+
},
|
|
279
|
+
onPseudoMove: (sx, sy, ex, ey) => {
|
|
280
|
+
this.#pseudo.show(sx, sy, ex, ey);
|
|
281
|
+
},
|
|
282
|
+
onPseudoEnd: () => {
|
|
283
|
+
this.#pseudo.hide();
|
|
284
|
+
this.#actions.clearSocketHighlights(this.ref.nodesLayer);
|
|
285
|
+
this.#actions.clearPortHints();
|
|
286
|
+
this.#connRenderer?.clearDotHighlights();
|
|
287
|
+
},
|
|
288
|
+
onCompatibleMove: (worldX, worldY, socketData) => {
|
|
289
|
+
// Highlight compatible SVG dots (no port teleportation)
|
|
290
|
+
const compatibleIds = this.#actions.getCompatibleNodeIds(socketData);
|
|
291
|
+
this.#connRenderer?.highlightDotsForNodes(compatibleIds);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
onDropEmpty: (x, y, socketData) => {
|
|
295
|
+
this.#actions.handleDropEmpty(x, y, socketData);
|
|
296
|
+
// Show context menu at drop position
|
|
297
|
+
const container = this.ref.canvasContainer;
|
|
298
|
+
const rect = container.getBoundingClientRect();
|
|
299
|
+
const menuX = x * this.$.zoom + this.$.panX;
|
|
300
|
+
const menuY = y * this.$.zoom + this.$.panY;
|
|
301
|
+
this.ref.contextMenu?.show(menuX, menuY, [
|
|
302
|
+
{ label: 'Add Node', icon: 'add_box', action: () => this.#editor?.emit('contextadd', { x, y }) },
|
|
303
|
+
]);
|
|
304
|
+
},
|
|
305
|
+
findNearestDot: (wx, wy) => this.#connRenderer?.findNearestDot(wx, wy),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Subscribe to editor events
|
|
309
|
+
editor.on('nodecreated', (node) => {
|
|
310
|
+
this.#allNodes.set(node.id, node);
|
|
311
|
+
// If virtualization is active (phantom data initialized), add as phantom; otherwise DOM
|
|
312
|
+
if (this.#phantomData.size > 0) {
|
|
313
|
+
this.#phantomData.set(node.id, {
|
|
314
|
+
id: node.id, x: 0, y: 0, w: 180, h: 60,
|
|
315
|
+
degree: 0, color: null, label: node.label || node.id,
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
this.#viewManager.addView(node);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
editor.on('noderemoved', (node) => {
|
|
322
|
+
this.#viewManager.removeView(node);
|
|
323
|
+
// Remove connections touching this node
|
|
324
|
+
for (const [, conn] of this.#connRenderer.data) {
|
|
325
|
+
if (conn.from === node.id || conn.to === node.id) {
|
|
326
|
+
this.#connRenderer.remove(conn);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
editor.on('connectioncreated', (conn) => this.#connRenderer.add(conn));
|
|
331
|
+
editor.on('connectionremoved', (conn) => {
|
|
332
|
+
this.#connRenderer.remove(conn);
|
|
333
|
+
this.#selector.getSelectedConnections().delete(conn.id);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Re-render connections after node layout changes (collapse/mute)
|
|
337
|
+
const refreshNodeConnections = ({ nodeId }) => {
|
|
338
|
+
requestAnimationFrame(() => this.#connRenderer?.updateForNode(nodeId));
|
|
339
|
+
};
|
|
340
|
+
editor.on('nodecollapse', refreshNodeConnections);
|
|
341
|
+
editor.on('nodemute', refreshNodeConnections);
|
|
342
|
+
|
|
343
|
+
// ─── Virtualized initialization ───
|
|
344
|
+
// Store all nodes as data, compute degrees from connections
|
|
345
|
+
this.#allNodes.clear();
|
|
346
|
+
this.#phantomData.clear();
|
|
347
|
+
this.#nodeDegrees.clear();
|
|
348
|
+
|
|
349
|
+
for (const node of editor.getNodes()) {
|
|
350
|
+
this.#allNodes.set(node.id, node);
|
|
351
|
+
this.#nodeDegrees.set(node.id, 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const allConns = editor.getConnections();
|
|
355
|
+
for (const conn of allConns) {
|
|
356
|
+
this.#nodeDegrees.set(conn.from, (this.#nodeDegrees.get(conn.from) || 0) + 1);
|
|
357
|
+
this.#nodeDegrees.set(conn.to, (this.#nodeDegrees.get(conn.to) || 0) + 1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Create DOM only for nodes that will be visible (or all if small graph)
|
|
361
|
+
const VIRT_THRESHOLD = 200;
|
|
362
|
+
if (this.#allNodes.size <= VIRT_THRESHOLD) {
|
|
363
|
+
// Small graph — create all DOM nodes immediately
|
|
364
|
+
this.#viewManager.addViews(editor.getNodes());
|
|
365
|
+
} else {
|
|
366
|
+
// Large graph — start all as phantom, promote visible ones after layout
|
|
367
|
+
const defaultW = 180, defaultH = 60;
|
|
368
|
+
for (const [id, node] of this.#allNodes) {
|
|
369
|
+
this.#phantomData.set(id, {
|
|
370
|
+
id, x: 0, y: 0, w: defaultW, h: defaultH,
|
|
371
|
+
degree: this.#nodeDegrees.get(id) || 0,
|
|
372
|
+
color: null,
|
|
373
|
+
label: node.label || id,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Batch renderer operations to prevent multiple redundant redraws
|
|
379
|
+
this.#connRenderer.setBatchMode(true);
|
|
380
|
+
this.#connRenderer.addBatch(allConns);
|
|
381
|
+
this.#syncPhantomToRenderer();
|
|
382
|
+
this.#connRenderer.setBatchMode(false);
|
|
383
|
+
|
|
384
|
+
// Subscribe to frame events
|
|
385
|
+
editor.on('framecreated', (frame) => this.#addFrameView(frame));
|
|
386
|
+
editor.on('frameremoved', (frame) => this.#removeFrameView(frame));
|
|
387
|
+
|
|
388
|
+
// Align tools emit nodemovetopos
|
|
389
|
+
editor.on('nodemovetopos', ({ nodeId, x, y }) => {
|
|
390
|
+
this.setNodePosition(nodeId, x, y);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Render existing frames
|
|
394
|
+
for (const frame of editor.getFrames()) {
|
|
395
|
+
this.#addFrameView(frame);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Initialize subgraph navigation (skip during drill-down/drillUp)
|
|
399
|
+
if (!this.#navigating) {
|
|
400
|
+
this.#subgraphManager.initialize(this, editor);
|
|
401
|
+
const breadcrumb = this.ref.breadcrumb;
|
|
402
|
+
if (breadcrumb) {
|
|
403
|
+
this.#subgraphManager.onNavigate((path) => {
|
|
404
|
+
breadcrumb.setPath(path);
|
|
405
|
+
});
|
|
406
|
+
breadcrumb.onNavigate((level) => {
|
|
407
|
+
this.drillUp(level);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** @returns {ConnectFlow|null} */
|
|
414
|
+
getConnectFlow() { return this.#connectFlow; }
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Enable/disable snap to grid
|
|
418
|
+
* @param {boolean} enabled
|
|
419
|
+
* @param {number} [size]
|
|
420
|
+
*/
|
|
421
|
+
setSnapGrid(enabled, size) {
|
|
422
|
+
this.#snapEnabled = enabled;
|
|
423
|
+
if (size) this.#snapGrid.setSize(size);
|
|
424
|
+
this.#viewManager?.setSnapEnabled(enabled);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Enable/disable readonly mode
|
|
429
|
+
* @param {boolean} enabled
|
|
430
|
+
*/
|
|
431
|
+
setReadonly(enabled) {
|
|
432
|
+
this.#readonly = enabled;
|
|
433
|
+
if (enabled) {
|
|
434
|
+
this.setAttribute('data-readonly', '');
|
|
435
|
+
} else {
|
|
436
|
+
this.removeAttribute('data-readonly');
|
|
437
|
+
}
|
|
438
|
+
this.#viewManager?.setReadonly(enabled);
|
|
439
|
+
this.#actions?.setReadonly(enabled);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Enable/disable compact mode (hides node body: ports & controls).
|
|
444
|
+
* Use this for schematic/PCB views where nodes show only labels.
|
|
445
|
+
* This is a structural setting — independent of visual theme.
|
|
446
|
+
* @param {boolean} enabled
|
|
447
|
+
*/
|
|
448
|
+
setCompactMode(enabled) {
|
|
449
|
+
if (enabled) {
|
|
450
|
+
this.setAttribute('data-compact', '');
|
|
451
|
+
} else {
|
|
452
|
+
this.removeAttribute('data-compact');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Apply a theme to the canvas
|
|
458
|
+
* @param {import('../themes/Theme.js').ThemeDefinition} theme
|
|
459
|
+
*/
|
|
460
|
+
setTheme(theme) {
|
|
461
|
+
applyTheme(this, theme);
|
|
462
|
+
this.#themeName = theme.name;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Apply only color palette
|
|
467
|
+
* @param {import('../themes/Palette.js').PaletteDefinition} palette
|
|
468
|
+
*/
|
|
469
|
+
setPalette(palette) { applyPalette(this, palette); }
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Apply only geometry skin
|
|
473
|
+
* @param {import('../themes/Skin.js').SkinDefinition} skin
|
|
474
|
+
*/
|
|
475
|
+
setSkin(skin) { applySkin(this, skin); }
|
|
476
|
+
|
|
477
|
+
/** @returns {string} */
|
|
478
|
+
getThemeName() { return this.#themeName; }
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Set data flow animation on a connection
|
|
482
|
+
* @param {string} connId
|
|
483
|
+
* @param {boolean} active
|
|
484
|
+
*/
|
|
485
|
+
setFlowing(connId, active) { this.#connRenderer?.setFlowing(connId, active); }
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Set data flow animation on all connections
|
|
489
|
+
* @param {boolean} active
|
|
490
|
+
*/
|
|
491
|
+
setAllFlowing(active) { this.#connRenderer?.setAllFlowing(active); }
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Set connection path style (persists across setEditor/drill-down)
|
|
495
|
+
* @param {'bezier'|'orthogonal'|'straight'|'pcb'} style
|
|
496
|
+
*/
|
|
497
|
+
setPathStyle(style) {
|
|
498
|
+
this.#pathStyle = style;
|
|
499
|
+
this.#connRenderer?.setPathStyle(style);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** @returns {'bezier'|'orthogonal'|'straight'|'pcb'} */
|
|
503
|
+
getPathStyle() { return this.#pathStyle; }
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Programmatically select a node by ID
|
|
507
|
+
* @param {string} nodeId
|
|
508
|
+
*/
|
|
509
|
+
selectNode(nodeId) {
|
|
510
|
+
this.#selector?.selectNode(nodeId);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Clear all connector caches and re-render.
|
|
515
|
+
* Call after initial node positioning to settle SVG connectors.
|
|
516
|
+
*/
|
|
517
|
+
refreshConnections() { this.#connRenderer?.refreshAll(); }
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Set error state on a node with frame-style error display
|
|
521
|
+
* @param {string} nodeId
|
|
522
|
+
* @param {string} message - Error message to display
|
|
523
|
+
*/
|
|
524
|
+
setNodeError(nodeId, message) {
|
|
525
|
+
const el = this.#nodeViews.get(nodeId);
|
|
526
|
+
if (!el) return;
|
|
527
|
+
|
|
528
|
+
// Remove existing error frame if any
|
|
529
|
+
this.clearNodeError(nodeId);
|
|
530
|
+
|
|
531
|
+
el.setAttribute('data-error', '');
|
|
532
|
+
|
|
533
|
+
// Build error frame DOM
|
|
534
|
+
const frame = document.createElement('div');
|
|
535
|
+
frame.className = 'error-frame';
|
|
536
|
+
|
|
537
|
+
const header = document.createElement('div');
|
|
538
|
+
header.className = 'error-frame-header';
|
|
539
|
+
const icon = document.createElement('span');
|
|
540
|
+
icon.className = 'material-symbols-outlined';
|
|
541
|
+
icon.textContent = 'error';
|
|
542
|
+
header.append(icon, ' Error');
|
|
543
|
+
|
|
544
|
+
const body = document.createElement('div');
|
|
545
|
+
body.className = 'error-frame-body';
|
|
546
|
+
body.textContent = message;
|
|
547
|
+
|
|
548
|
+
frame.append(header, body);
|
|
549
|
+
el.append(frame);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Clear error state from a node
|
|
554
|
+
* @param {string} nodeId
|
|
555
|
+
*/
|
|
556
|
+
clearNodeError(nodeId) {
|
|
557
|
+
const el = this.#nodeViews.get(nodeId);
|
|
558
|
+
if (!el) return;
|
|
559
|
+
el.removeAttribute('data-error');
|
|
560
|
+
const frame = el.querySelector('.error-frame');
|
|
561
|
+
if (frame) frame.remove();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Clear all error states
|
|
566
|
+
*/
|
|
567
|
+
clearAllErrors() {
|
|
568
|
+
for (const [id] of this.#nodeViews) {
|
|
569
|
+
this.clearNodeError(id);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Apply auto layout to all nodes
|
|
575
|
+
*/
|
|
576
|
+
autoLayout() {
|
|
577
|
+
if (!this.#editor) return;
|
|
578
|
+
const positions = computeAutoLayout(this.#editor);
|
|
579
|
+
for (const [nodeId, pos] of Object.entries(positions)) {
|
|
580
|
+
this.setNodePosition(nodeId, pos.x, pos.y);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Fit all nodes into the viewport.
|
|
586
|
+
* Calculates required zoom/pan based on current node layout,
|
|
587
|
+
* accounting for the inspector panel if open.
|
|
588
|
+
*/
|
|
589
|
+
fitView() {
|
|
590
|
+
if (this.#nodeViews.size === 0 && this.#phantomData.size === 0) return;
|
|
591
|
+
|
|
592
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
593
|
+
|
|
594
|
+
// Include DOM nodes
|
|
595
|
+
for (const [, el] of this.#nodeViews) {
|
|
596
|
+
if (!el._position) continue;
|
|
597
|
+
const x = el._position.x;
|
|
598
|
+
const y = el._position.y;
|
|
599
|
+
const w = el._cachedW || el.offsetWidth || 150;
|
|
600
|
+
const h = el._cachedH || el.offsetHeight || 40;
|
|
601
|
+
if (x < minX) minX = x;
|
|
602
|
+
if (y < minY) minY = y;
|
|
603
|
+
if (x + w > maxX) maxX = x + w;
|
|
604
|
+
if (y + h > maxY) maxY = y + h;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Include phantom nodes (virtualized)
|
|
608
|
+
for (const [, pd] of this.#phantomData) {
|
|
609
|
+
if (pd.x < minX) minX = pd.x;
|
|
610
|
+
if (pd.y < minY) minY = pd.y;
|
|
611
|
+
if (pd.x + pd.w > maxX) maxX = pd.x + pd.w;
|
|
612
|
+
if (pd.y + pd.h > maxY) maxY = pd.y + pd.h;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (minX === Infinity) return;
|
|
616
|
+
|
|
617
|
+
const graphW = maxX - minX;
|
|
618
|
+
const graphH = maxY - minY;
|
|
619
|
+
const canvasRect = this.ref.canvasContainer.getBoundingClientRect();
|
|
620
|
+
|
|
621
|
+
let visibleWidth = canvasRect.width;
|
|
622
|
+
const inspector = this.ref.inspector || this.querySelector('inspector-panel');
|
|
623
|
+
if (inspector && !inspector.hasAttribute('hidden')) {
|
|
624
|
+
visibleWidth -= inspector.offsetWidth || 280;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const scaleX = (visibleWidth - 80) / graphW;
|
|
628
|
+
const scaleY = (canvasRect.height - 80) / graphH;
|
|
629
|
+
const scale = Math.max(0.001, Math.min(scaleX, scaleY, 1.5));
|
|
630
|
+
|
|
631
|
+
const centerX = (minX + maxX) / 2;
|
|
632
|
+
const centerY = (minY + maxY) / 2;
|
|
633
|
+
|
|
634
|
+
this.$.zoom = scale;
|
|
635
|
+
this.$.panX = (visibleWidth / 2) - centerX * scale;
|
|
636
|
+
this.$.panY = canvasRect.height / 2 - centerY * scale;
|
|
637
|
+
this.#updateTransform();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Focus viewport on a specific node by ID.
|
|
642
|
+
* Deducts inspector panel width from visibility calculation.
|
|
643
|
+
* @param {string} nodeId - Target node ID
|
|
644
|
+
* @param {Object} [opts]
|
|
645
|
+
* @param {number} [opts.zoom=0.8] - Target zoom level
|
|
646
|
+
* @returns {boolean}
|
|
647
|
+
*/
|
|
648
|
+
flyToNode(nodeId, { zoom = 0.8 } = {}) {
|
|
649
|
+
let el = this.#nodeViews.get(nodeId);
|
|
650
|
+
|
|
651
|
+
// If node is phantom (no DOM), promote it first
|
|
652
|
+
if (!el && this.#phantomData.has(nodeId)) {
|
|
653
|
+
this.#promoteNode(nodeId);
|
|
654
|
+
el = this.#nodeViews.get(nodeId);
|
|
655
|
+
}
|
|
656
|
+
if (!el) return false;
|
|
657
|
+
|
|
658
|
+
// If position not set yet, use phantom data
|
|
659
|
+
const pos = el._position || (() => {
|
|
660
|
+
const pd = this.#phantomData.get(nodeId);
|
|
661
|
+
return pd ? { x: pd.x, y: pd.y } : { x: 0, y: 0 };
|
|
662
|
+
})();
|
|
663
|
+
|
|
664
|
+
const canvasRect = this.ref.canvasContainer.getBoundingClientRect();
|
|
665
|
+
let visibleWidth = canvasRect.width;
|
|
666
|
+
|
|
667
|
+
const inspector = this.ref.inspector || this.querySelector('inspector-panel');
|
|
668
|
+
if (inspector && !inspector.hasAttribute('hidden') && inspector.offsetWidth > 20) {
|
|
669
|
+
visibleWidth -= inspector.offsetWidth;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const elWidth = el._cachedW || el.offsetWidth || 150;
|
|
673
|
+
const elHeight = el._cachedH || el.offsetHeight || 40;
|
|
674
|
+
|
|
675
|
+
const nodeX = pos.x + (elWidth / 2);
|
|
676
|
+
const nodeY = pos.y + (elHeight / 2);
|
|
677
|
+
|
|
678
|
+
const newPanX = (visibleWidth / 2) - nodeX * zoom;
|
|
679
|
+
const newPanY = canvasRect.height / 2 - nodeY * zoom;
|
|
680
|
+
|
|
681
|
+
const dz = Math.abs(this.$.zoom - zoom);
|
|
682
|
+
const dx = Math.abs(this.$.panX - newPanX);
|
|
683
|
+
const dy = Math.abs(this.$.panY - newPanY);
|
|
684
|
+
|
|
685
|
+
if (dz < 0.01 && dx < 2 && dy < 2) {
|
|
686
|
+
this.selectNode(nodeId);
|
|
687
|
+
if (!this._cullingScheduled) {
|
|
688
|
+
this._cullingScheduled = true;
|
|
689
|
+
requestAnimationFrame(() => {
|
|
690
|
+
this._cullingScheduled = false;
|
|
691
|
+
this.#applyCullingAndLOD();
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
this.$.zoom = zoom;
|
|
698
|
+
this.$.panX = newPanX;
|
|
699
|
+
this.$.panY = newPanY;
|
|
700
|
+
|
|
701
|
+
this.selectNode(nodeId);
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Measure actual DOM sizes of all rendered nodes.
|
|
708
|
+
* Returns a plain object { [nodeId]: { w, h } } suitable for AutoLayout's nodeSizes option.
|
|
709
|
+
* Call after nodes are rendered to DOM (after setEditor + requestAnimationFrame).
|
|
710
|
+
* @returns {Object<string, { w: number, h: number }>}
|
|
711
|
+
*/
|
|
712
|
+
measureNodeSizes() {
|
|
713
|
+
const sizes = {};
|
|
714
|
+
for (const [nodeId, el] of this.#nodeViews) {
|
|
715
|
+
if (el && el.offsetWidth > 0) {
|
|
716
|
+
sizes[nodeId] = { w: el.offsetWidth, h: el.offsetHeight };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return sizes;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Set preview content on a node (image URL or text)
|
|
724
|
+
* @param {string} nodeId
|
|
725
|
+
* @param {string} content - Image URL or text
|
|
726
|
+
* @param {'image'|'text'} [type='text']
|
|
727
|
+
*/
|
|
728
|
+
setPreview(nodeId, content, type = 'text') {
|
|
729
|
+
const el = this.#nodeViews.get(nodeId);
|
|
730
|
+
if (!el) return;
|
|
731
|
+
const preview = el.ref?.previewArea;
|
|
732
|
+
if (!preview) return;
|
|
733
|
+
|
|
734
|
+
preview.hidden = false;
|
|
735
|
+
preview.replaceChildren();
|
|
736
|
+
if (type === 'image') {
|
|
737
|
+
const img = document.createElement('img');
|
|
738
|
+
img.src = content;
|
|
739
|
+
img.alt = 'Preview';
|
|
740
|
+
preview.appendChild(img);
|
|
741
|
+
} else {
|
|
742
|
+
const div = document.createElement('div');
|
|
743
|
+
div.className = 'sn-preview-text';
|
|
744
|
+
div.textContent = content;
|
|
745
|
+
preview.appendChild(div);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Clear preview from a node
|
|
751
|
+
* @param {string} nodeId
|
|
752
|
+
*/
|
|
753
|
+
clearPreview(nodeId) {
|
|
754
|
+
const el = this.#nodeViews.get(nodeId);
|
|
755
|
+
if (!el) return;
|
|
756
|
+
const preview = el.ref?.previewArea;
|
|
757
|
+
if (!preview) return;
|
|
758
|
+
preview.hidden = true;
|
|
759
|
+
preview.replaceChildren();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Get node view element by ID (used by FlowSimulator)
|
|
764
|
+
* @param {string} nodeId
|
|
765
|
+
* @returns {HTMLElement|undefined}
|
|
766
|
+
*/
|
|
767
|
+
_getNodeView(nodeId) { return this.#nodeViews.get(nodeId); }
|
|
768
|
+
|
|
769
|
+
/** Alias for SubgraphManager */
|
|
770
|
+
getNodeView(nodeId) { return this.#nodeViews.get(nodeId); }
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Highlight nodes sequentially based on execution trace.
|
|
774
|
+
* Each node pulses green in order, then fades.
|
|
775
|
+
* Uses inline styles to guarantee visibility regardless of CSS cache.
|
|
776
|
+
*
|
|
777
|
+
* @param {Array<{nodeId: string}>} trace - Execution trace from Fire/Run
|
|
778
|
+
* @param {number} [stepDelay=300] - Delay between node highlights (ms)
|
|
779
|
+
*/
|
|
780
|
+
highlightTrace(trace, stepDelay = 300) {
|
|
781
|
+
if (!trace || !trace.length) return;
|
|
782
|
+
|
|
783
|
+
// Inject keyframe animation once
|
|
784
|
+
if (!document.getElementById('sn-fire-keyframes')) {
|
|
785
|
+
const style = document.createElement('style');
|
|
786
|
+
style.id = 'sn-fire-keyframes';
|
|
787
|
+
style.textContent = `
|
|
788
|
+
@keyframes sn-fire-pulse {
|
|
789
|
+
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.7); }
|
|
790
|
+
50% { box-shadow: 0 0 20px 6px rgba(76, 175, 80, 0.5); }
|
|
791
|
+
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
|
|
792
|
+
}
|
|
793
|
+
`;
|
|
794
|
+
document.head.appendChild(style);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Clear any previous fire states
|
|
798
|
+
for (const [, el] of this.#nodeViews) {
|
|
799
|
+
el.removeAttribute('data-fire-state');
|
|
800
|
+
el.style.opacity = '';
|
|
801
|
+
el.style.borderColor = '';
|
|
802
|
+
el.style.animation = '';
|
|
803
|
+
el.style.zIndex = '';
|
|
804
|
+
el.style.transition = '';
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Set all traced nodes to pending (dimmed)
|
|
808
|
+
for (const step of trace) {
|
|
809
|
+
const el = this.#nodeViews.get(step.nodeId);
|
|
810
|
+
if (el) {
|
|
811
|
+
el.style.opacity = '0.4';
|
|
812
|
+
el.style.transition = 'opacity 0.15s';
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Sequentially activate each node
|
|
817
|
+
trace.forEach((step, i) => {
|
|
818
|
+
setTimeout(() => {
|
|
819
|
+
const el = this.#nodeViews.get(step.nodeId);
|
|
820
|
+
if (!el) return;
|
|
821
|
+
|
|
822
|
+
// Active: green pulse
|
|
823
|
+
el.style.opacity = '1';
|
|
824
|
+
el.style.borderColor = '#4caf50';
|
|
825
|
+
el.style.animation = 'sn-fire-pulse 0.6s ease-out';
|
|
826
|
+
el.style.zIndex = '50';
|
|
827
|
+
|
|
828
|
+
// Done: fade border
|
|
829
|
+
setTimeout(() => {
|
|
830
|
+
el.style.animation = '';
|
|
831
|
+
el.style.borderColor = 'rgba(76, 175, 80, 0.4)';
|
|
832
|
+
el.style.transition = 'border-color 2s ease-out';
|
|
833
|
+
}, 600);
|
|
834
|
+
}, i * stepDelay);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// Clear all states after animation completes
|
|
838
|
+
const totalDuration = trace.length * stepDelay + 3500;
|
|
839
|
+
setTimeout(() => {
|
|
840
|
+
for (const [, el] of this.#nodeViews) {
|
|
841
|
+
el.style.opacity = '';
|
|
842
|
+
el.style.borderColor = '';
|
|
843
|
+
el.style.animation = '';
|
|
844
|
+
el.style.zIndex = '';
|
|
845
|
+
el.style.transition = '';
|
|
846
|
+
}
|
|
847
|
+
}, totalDuration);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// --- Subgraph Navigation ---
|
|
851
|
+
|
|
852
|
+
/** @type {SubgraphManager} */
|
|
853
|
+
#subgraphManager = new SubgraphManager();
|
|
854
|
+
|
|
855
|
+
/** @type {boolean} - guard to prevent setEditor re-init during navigation */
|
|
856
|
+
#navigating = false;
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Drill down into a subgraph node
|
|
860
|
+
* @param {string} nodeId - SubgraphNode ID
|
|
861
|
+
*/
|
|
862
|
+
drillDown(nodeId) {
|
|
863
|
+
if (!this.#editor) return;
|
|
864
|
+
const node = this.#editor.getNode(nodeId);
|
|
865
|
+
if (!node?._isSubgraph) return;
|
|
866
|
+
this.#navigating = true;
|
|
867
|
+
this.#subgraphManager.drillDown(node);
|
|
868
|
+
this.#navigating = false;
|
|
869
|
+
this.dispatchEvent(new CustomEvent('subgraph-enter', {
|
|
870
|
+
detail: { node, nodeId },
|
|
871
|
+
bubbles: true,
|
|
872
|
+
}));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Navigate up to a breadcrumb level
|
|
877
|
+
* @param {number} level - 0 = root
|
|
878
|
+
*/
|
|
879
|
+
drillUp(level) {
|
|
880
|
+
this.#navigating = true;
|
|
881
|
+
this.#subgraphManager.drillUp(level);
|
|
882
|
+
this.#navigating = false;
|
|
883
|
+
this.dispatchEvent(new CustomEvent('subgraph-exit', {
|
|
884
|
+
detail: { level },
|
|
885
|
+
bubbles: true,
|
|
886
|
+
}));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Get current subgraph depth (0 = root)
|
|
891
|
+
* @returns {number}
|
|
892
|
+
*/
|
|
893
|
+
getSubgraphDepth() {
|
|
894
|
+
return this.#subgraphManager.depth;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Get subgraph breadcrumb path
|
|
899
|
+
* @returns {Array<{ label: string, level: number }>}
|
|
900
|
+
*/
|
|
901
|
+
getSubgraphPath() {
|
|
902
|
+
return this.#subgraphManager.getPath();
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Enable/disable batch positioning mode.
|
|
907
|
+
* When true, setNodePosition skips connection updates.
|
|
908
|
+
* Call refreshConnections() after batch is done.
|
|
909
|
+
* @param {boolean} active
|
|
910
|
+
*/
|
|
911
|
+
setBatchMode(active) {
|
|
912
|
+
this._batchMode = !!active;
|
|
913
|
+
if (!this._batchMode && !this._cullingScheduled) {
|
|
914
|
+
this._cullingScheduled = true;
|
|
915
|
+
requestAnimationFrame(() => {
|
|
916
|
+
this._cullingScheduled = false;
|
|
917
|
+
this.#applyCullingAndLOD();
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Set node position
|
|
924
|
+
* @param {string} nodeId
|
|
925
|
+
* @param {number} x
|
|
926
|
+
* @param {number} y
|
|
927
|
+
*/
|
|
928
|
+
setNodePosition(nodeId, x, y) {
|
|
929
|
+
const el = this.#nodeViews.get(nodeId);
|
|
930
|
+
if (!el) {
|
|
931
|
+
// Update phantom data if node is virtualized (no DOM)
|
|
932
|
+
const pd = this.#phantomData.get(nodeId);
|
|
933
|
+
if (pd) { pd.x = x; pd.y = y; this.#phantomDirty = true; }
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
el.style.transform = `translate(${x}px, ${y}px)`;
|
|
937
|
+
el._position = { x, y };
|
|
938
|
+
|
|
939
|
+
// Skip connection updates during batch positioning
|
|
940
|
+
if (this._batchMode) return;
|
|
941
|
+
|
|
942
|
+
this.#connRenderer?.updateForNode(nodeId);
|
|
943
|
+
// Render or refresh free dots for SVG nodes
|
|
944
|
+
if (el.hasAttribute('data-svg-shape')) {
|
|
945
|
+
this.#connRenderer?.refreshFreeDots(nodeId);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Keep toolbar in sync during drag
|
|
949
|
+
const toolbar = this.ref.quickToolbar;
|
|
950
|
+
if (toolbar && toolbar._nodeId === nodeId) {
|
|
951
|
+
toolbar.updatePosition(el);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Animate node to position with wires synced via RAF
|
|
957
|
+
* @param {string} nodeId
|
|
958
|
+
* @param {number} targetX
|
|
959
|
+
* @param {number} targetY
|
|
960
|
+
* @param {number} [duration=200] - Animation duration in ms
|
|
961
|
+
*/
|
|
962
|
+
animateNodeToPosition(nodeId, targetX, targetY, duration = 200) {
|
|
963
|
+
const el = this.#nodeViews.get(nodeId);
|
|
964
|
+
if (!el) return;
|
|
965
|
+
|
|
966
|
+
const startX = el._position.x;
|
|
967
|
+
const startY = el._position.y;
|
|
968
|
+
const dx = targetX - startX;
|
|
969
|
+
const dy = targetY - startY;
|
|
970
|
+
|
|
971
|
+
// Skip animation if position hasn't changed
|
|
972
|
+
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
|
|
973
|
+
|
|
974
|
+
const startTime = performance.now();
|
|
975
|
+
|
|
976
|
+
const animate = (now) => {
|
|
977
|
+
const t = Math.min((now - startTime) / duration, 1);
|
|
978
|
+
// Ease-out cubic
|
|
979
|
+
const ease = 1 - (1 - t) ** 3;
|
|
980
|
+
const x = startX + dx * ease;
|
|
981
|
+
const y = startY + dy * ease;
|
|
982
|
+
|
|
983
|
+
el.style.transform = `translate(${x}px, ${y}px)`;
|
|
984
|
+
el._position = { x, y };
|
|
985
|
+
this.#connRenderer?.updateForNode(nodeId);
|
|
986
|
+
this.#connRenderer?.refreshFreeDots(nodeId);
|
|
987
|
+
|
|
988
|
+
const toolbar = this.ref.quickToolbar;
|
|
989
|
+
if (toolbar && toolbar._nodeId === nodeId) {
|
|
990
|
+
toolbar.updatePosition(el);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (t < 1) {
|
|
994
|
+
requestAnimationFrame(animate);
|
|
995
|
+
} else {
|
|
996
|
+
// Ensure final position is exact
|
|
997
|
+
el._position = { x: targetX, y: targetY };
|
|
998
|
+
el.style.transform = `translate(${targetX}px, ${targetY}px)`;
|
|
999
|
+
this.#connRenderer?.updateForNode(nodeId);
|
|
1000
|
+
this.#connRenderer?.refreshFreeDots(nodeId);
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
requestAnimationFrame(animate);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Get all node positions — includes both DOM nodes and phantom (virtualized) nodes.
|
|
1009
|
+
* SubgraphRouter relies on this to decide whether a node is on the current canvas layer;
|
|
1010
|
+
* phantom nodes DO have layout positions but no DOM element, so they must be included here.
|
|
1011
|
+
* @returns {Object<string, number[]>}
|
|
1012
|
+
*/
|
|
1013
|
+
getPositions() {
|
|
1014
|
+
const positions = {};
|
|
1015
|
+
// DOM nodes (promoted / small graph)
|
|
1016
|
+
for (const [id, el] of this.#nodeViews) {
|
|
1017
|
+
if (el._position) {
|
|
1018
|
+
positions[id] = [el._position.x, el._position.y];
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// Phantom nodes (virtualized, laid out but no DOM yet).
|
|
1022
|
+
// Include even at (0,0) — position existence means node IS on this layer.
|
|
1023
|
+
for (const [id, pd] of this.#phantomData) {
|
|
1024
|
+
if (!positions[id]) {
|
|
1025
|
+
positions[id] = [pd.x, pd.y];
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return positions;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Check whether a node exists on the current canvas layer (DOM or phantom).
|
|
1033
|
+
* SubgraphRouter uses this to avoid spurious drillDown when layout is still in progress.
|
|
1034
|
+
* @param {string} nodeId
|
|
1035
|
+
* @returns {boolean}
|
|
1036
|
+
*/
|
|
1037
|
+
hasNode(nodeId) {
|
|
1038
|
+
return this.#nodeViews.has(nodeId) || this.#phantomData.has(nodeId);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// --- Frame API ---
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Add a frame to the canvas
|
|
1045
|
+
* @param {import('../core/Frame.js').Frame} frame
|
|
1046
|
+
*/
|
|
1047
|
+
addFrame(frame) {
|
|
1048
|
+
this.#editor?.addFrame(frame);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Set frame position
|
|
1053
|
+
* @param {string} frameId
|
|
1054
|
+
* @param {number} x
|
|
1055
|
+
* @param {number} y
|
|
1056
|
+
*/
|
|
1057
|
+
setFramePosition(frameId, x, y) {
|
|
1058
|
+
const el = this.#frameViews.get(frameId);
|
|
1059
|
+
if (!el) return;
|
|
1060
|
+
el.style.transform = `translate(${x}px, ${y}px)`;
|
|
1061
|
+
el._position = { x, y };
|
|
1062
|
+
const frame = this.#editor?.getFrame(frameId);
|
|
1063
|
+
if (frame) { frame.x = x; frame.y = y; }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Set frame size
|
|
1068
|
+
* @param {string} frameId
|
|
1069
|
+
* @param {number} w
|
|
1070
|
+
* @param {number} h
|
|
1071
|
+
*/
|
|
1072
|
+
setFrameSize(frameId, w, h) {
|
|
1073
|
+
const el = this.#frameViews.get(frameId);
|
|
1074
|
+
if (!el) return;
|
|
1075
|
+
el.style.width = `${w}px`;
|
|
1076
|
+
el.style.height = `${h}px`;
|
|
1077
|
+
const frame = this.#editor?.getFrame(frameId);
|
|
1078
|
+
if (frame) { frame.width = w; frame.height = h; }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Get node IDs that are spatially inside a frame
|
|
1083
|
+
* @param {string} frameId
|
|
1084
|
+
* @returns {string[]}
|
|
1085
|
+
*/
|
|
1086
|
+
#getNodesInFrame(frameId) {
|
|
1087
|
+
const el = this.#frameViews.get(frameId);
|
|
1088
|
+
if (!el) return [];
|
|
1089
|
+
const fp = el._position;
|
|
1090
|
+
const fw = parseFloat(el.style.width) || el._frameData?.width || 400;
|
|
1091
|
+
const fh = parseFloat(el.style.height) || el._frameData?.height || 300;
|
|
1092
|
+
const ids = [];
|
|
1093
|
+
for (const [nodeId, nodeEl] of this.#nodeViews) {
|
|
1094
|
+
const np = nodeEl._position;
|
|
1095
|
+
if (np.x >= fp.x && np.y >= fp.y && np.x <= fp.x + fw && np.y <= fp.y + fh) {
|
|
1096
|
+
ids.push(nodeId);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return ids;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Create frame DOM element with drag and resize
|
|
1104
|
+
* @param {import('../core/Frame.js').Frame} frame
|
|
1105
|
+
*/
|
|
1106
|
+
#addFrameView(frame) {
|
|
1107
|
+
const el = document.createElement('graph-frame');
|
|
1108
|
+
el.style.position = 'absolute';
|
|
1109
|
+
el.style.width = `${frame.width}px`;
|
|
1110
|
+
el.style.height = `${frame.height}px`;
|
|
1111
|
+
el.style.transform = `translate(${frame.x}px, ${frame.y}px)`;
|
|
1112
|
+
el._position = { x: frame.x, y: frame.y };
|
|
1113
|
+
el._frameData = frame;
|
|
1114
|
+
el.setAttribute('frame-id', frame.id);
|
|
1115
|
+
|
|
1116
|
+
// Set frame color directly as CSS variable (reliable, no dependency on Symbiote state)
|
|
1117
|
+
el.style.setProperty('--frame-color', frame.color);
|
|
1118
|
+
|
|
1119
|
+
// Wait for Symbiote render, then set state
|
|
1120
|
+
requestAnimationFrame(() => {
|
|
1121
|
+
if (el.$) {
|
|
1122
|
+
el.$.label = frame.label;
|
|
1123
|
+
el.$.color = frame.color;
|
|
1124
|
+
} else {
|
|
1125
|
+
// Fallback: set label text directly
|
|
1126
|
+
const labelEl = el.querySelector('.sn-frame-label');
|
|
1127
|
+
if (labelEl) labelEl.textContent = frame.label;
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// Frame drag — moves child nodes too
|
|
1132
|
+
const drag = new Drag();
|
|
1133
|
+
let childStartPositions = null;
|
|
1134
|
+
let frameStartPos = null;
|
|
1135
|
+
|
|
1136
|
+
drag.initialize(
|
|
1137
|
+
el,
|
|
1138
|
+
{
|
|
1139
|
+
getPosition: () => el._position,
|
|
1140
|
+
getZoom: () => this.$.zoom,
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
onStart: () => {
|
|
1144
|
+
frameStartPos = { ...el._position };
|
|
1145
|
+
// Capture positions of nodes that are inside this frame
|
|
1146
|
+
const nodeIds = this.#getNodesInFrame(frame.id);
|
|
1147
|
+
childStartPositions = new Map();
|
|
1148
|
+
for (const nid of nodeIds) {
|
|
1149
|
+
const nel = this.#nodeViews.get(nid);
|
|
1150
|
+
if (nel) childStartPositions.set(nid, { ...nel._position });
|
|
1151
|
+
}
|
|
1152
|
+
},
|
|
1153
|
+
onTranslate: (x, y) => {
|
|
1154
|
+
// Move child nodes by delta from frame start
|
|
1155
|
+
if (childStartPositions && frameStartPos) {
|
|
1156
|
+
const dx = x - frameStartPos.x;
|
|
1157
|
+
const dy = y - frameStartPos.y;
|
|
1158
|
+
for (const [nid, startPos] of childStartPositions) {
|
|
1159
|
+
this.setNodePosition(nid, startPos.x + dx, startPos.y + dy);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
this.setFramePosition(frame.id, x, y);
|
|
1163
|
+
},
|
|
1164
|
+
onDrop: () => {
|
|
1165
|
+
childStartPositions = null;
|
|
1166
|
+
frameStartPos = null;
|
|
1167
|
+
},
|
|
1168
|
+
}
|
|
1169
|
+
);
|
|
1170
|
+
el._drag = drag;
|
|
1171
|
+
|
|
1172
|
+
// Resize handle
|
|
1173
|
+
requestAnimationFrame(() => {
|
|
1174
|
+
const handle = el.ref?.resizeHandle;
|
|
1175
|
+
if (handle) {
|
|
1176
|
+
const resizeDrag = new Drag();
|
|
1177
|
+
let startSize = null;
|
|
1178
|
+
resizeDrag.initialize(
|
|
1179
|
+
handle,
|
|
1180
|
+
{
|
|
1181
|
+
getPosition: () => ({ x: frame.width, y: frame.height }),
|
|
1182
|
+
getZoom: () => this.$.zoom,
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
onStart: () => {
|
|
1186
|
+
startSize = { w: frame.width, h: frame.height };
|
|
1187
|
+
},
|
|
1188
|
+
onTranslate: (x, y) => {
|
|
1189
|
+
const w = Math.max(120, x);
|
|
1190
|
+
const h = Math.max(80, y);
|
|
1191
|
+
this.setFrameSize(frame.id, w, h);
|
|
1192
|
+
},
|
|
1193
|
+
onDrop: () => { startSize = null; },
|
|
1194
|
+
}
|
|
1195
|
+
);
|
|
1196
|
+
el._resizeDrag = resizeDrag;
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
this.ref.framesLayer.appendChild(el);
|
|
1201
|
+
this.#frameViews.set(frame.id, el);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Remove frame DOM element
|
|
1206
|
+
* @param {import('../core/Frame.js').Frame} frame
|
|
1207
|
+
*/
|
|
1208
|
+
#removeFrameView(frame) {
|
|
1209
|
+
const el = this.#frameViews.get(frame.id);
|
|
1210
|
+
if (!el) return;
|
|
1211
|
+
if (el._drag) el._drag.destroy();
|
|
1212
|
+
if (el._resizeDrag) el._resizeDrag.destroy();
|
|
1213
|
+
el.remove();
|
|
1214
|
+
this.#frameViews.delete(frame.id);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// --- Selection ---
|
|
1218
|
+
|
|
1219
|
+
#onSelectionChanged(selectedNodes, selectedConnections) {
|
|
1220
|
+
this.#zCounter++;
|
|
1221
|
+
|
|
1222
|
+
// 1. Identify neighbors of currently selected nodes for "Focus Mode" label visibility
|
|
1223
|
+
const neighbors = new Set();
|
|
1224
|
+
if (this.#editor && selectedNodes.size > 0) {
|
|
1225
|
+
for (const conn of this.#editor.getConnections()) {
|
|
1226
|
+
if (selectedNodes.has(conn.from)) neighbors.add(conn.to);
|
|
1227
|
+
if (selectedNodes.has(conn.to)) neighbors.add(conn.from);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Update node attributes — guard to avoid redundant DOM mutations
|
|
1232
|
+
for (const [id, el] of this.#nodeViews) {
|
|
1233
|
+
const shouldSelect = selectedNodes.has(id);
|
|
1234
|
+
const isSelected = el.hasAttribute('data-selected');
|
|
1235
|
+
if (shouldSelect && !isSelected) {
|
|
1236
|
+
el.setAttribute('data-selected', '');
|
|
1237
|
+
el.style.zIndex = this.#zCounter;
|
|
1238
|
+
} else if (!shouldSelect && isSelected) {
|
|
1239
|
+
el.removeAttribute('data-selected');
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const shouldNeighbor = neighbors.has(id) && !shouldSelect;
|
|
1243
|
+
const isNeighbor = el.hasAttribute('data-neighbor-focused');
|
|
1244
|
+
if (shouldNeighbor && !isNeighbor) {
|
|
1245
|
+
el.setAttribute('data-neighbor-focused', '');
|
|
1246
|
+
} else if (!shouldNeighbor && isNeighbor) {
|
|
1247
|
+
el.removeAttribute('data-neighbor-focused');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// 2. Mark connections touching selected nodes
|
|
1252
|
+
const activeConnIds = new Set();
|
|
1253
|
+
if (this.#editor && selectedNodes.size > 0) {
|
|
1254
|
+
for (const conn of this.#editor.getConnections()) {
|
|
1255
|
+
if (selectedNodes.has(conn.from) || selectedNodes.has(conn.to)) {
|
|
1256
|
+
activeConnIds.add(conn.id);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Use cached path map instead of querySelector per connection
|
|
1262
|
+
const connSvg = this.ref.connections;
|
|
1263
|
+
if (!this._connPathCache) this._connPathCache = new Map();
|
|
1264
|
+
for (const [id] of this.#connRenderer?.data || []) {
|
|
1265
|
+
let path = this._connPathCache.get(id);
|
|
1266
|
+
if (!path || !path.isConnected) {
|
|
1267
|
+
path = connSvg.querySelector(`[data-conn-id="${id}"]`);
|
|
1268
|
+
if (path) this._connPathCache.set(id, path);
|
|
1269
|
+
}
|
|
1270
|
+
if (!path) continue;
|
|
1271
|
+
|
|
1272
|
+
// Selection state
|
|
1273
|
+
const shouldSelectConn = selectedConnections.has(id);
|
|
1274
|
+
if (shouldSelectConn !== path.hasAttribute('data-selected')) {
|
|
1275
|
+
shouldSelectConn ? path.setAttribute('data-selected', '') : path.removeAttribute('data-selected');
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Active connection: touches a selected node
|
|
1279
|
+
const isActive = activeConnIds.has(id);
|
|
1280
|
+
if (isActive !== path.hasAttribute('data-active-conn')) {
|
|
1281
|
+
isActive ? path.setAttribute('data-active-conn', '') : path.removeAttribute('data-active-conn');
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Dimming
|
|
1285
|
+
const shouldDim = !isActive && selectedNodes.size > 0;
|
|
1286
|
+
if (shouldDim !== path.hasAttribute('data-dimmed')) {
|
|
1287
|
+
shouldDim ? path.setAttribute('data-dimmed', '') : path.removeAttribute('data-dimmed');
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Pass selection state to Canvas renderer for dimming implementation
|
|
1292
|
+
if (this.#connRenderer && typeof this.#connRenderer.setSelectionState === 'function') {
|
|
1293
|
+
this.#connRenderer.setSelectionState(selectedNodes.size > 0, activeConnIds);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Quick Action Toolbar — show for single node selection
|
|
1297
|
+
const toolbar = this.ref.quickToolbar;
|
|
1298
|
+
if (toolbar) {
|
|
1299
|
+
if (selectedNodes.size === 1) {
|
|
1300
|
+
const nodeId = [...selectedNodes][0];
|
|
1301
|
+
const nodeEl = this.#nodeViews.get(nodeId);
|
|
1302
|
+
if (nodeEl) toolbar.show(nodeId, nodeEl);
|
|
1303
|
+
} else {
|
|
1304
|
+
toolbar.hide();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// Inspector — show selected node details, auto-hide on deselect
|
|
1309
|
+
const inspector = this.ref.inspector;
|
|
1310
|
+
if (inspector) {
|
|
1311
|
+
inspector._canvas = this;
|
|
1312
|
+
if (selectedNodes.size === 1) {
|
|
1313
|
+
const nodeId = [...selectedNodes][0];
|
|
1314
|
+
const node = this.#editor?.getNode(nodeId);
|
|
1315
|
+
if (node) {
|
|
1316
|
+
inspector.inspect(node);
|
|
1317
|
+
inspector.hidden = false;
|
|
1318
|
+
}
|
|
1319
|
+
} else {
|
|
1320
|
+
inspector.clear();
|
|
1321
|
+
inspector.hidden = true;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Dispatch event so consumers can react to selection changes (including deselect)
|
|
1326
|
+
this.dispatchEvent(new CustomEvent('selection-changed', {
|
|
1327
|
+
detail: { nodes: [...selectedNodes], connections: [...selectedConnections] },
|
|
1328
|
+
}));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** @type {number} */
|
|
1332
|
+
#lastClickTime = 0;
|
|
1333
|
+
/** @type {string|null} */
|
|
1334
|
+
#lastClickNodeId = null;
|
|
1335
|
+
|
|
1336
|
+
#handleNodeClick(nodeId, e) {
|
|
1337
|
+
const accumulate = e.ctrlKey || e.metaKey;
|
|
1338
|
+
this.#selector.selectNode(nodeId, accumulate);
|
|
1339
|
+
|
|
1340
|
+
// Double-click detection for subgraph drill-down
|
|
1341
|
+
const now = Date.now();
|
|
1342
|
+
if (this.#lastClickNodeId === nodeId && now - this.#lastClickTime < 400) {
|
|
1343
|
+
this.drillDown(nodeId);
|
|
1344
|
+
this.#lastClickTime = 0;
|
|
1345
|
+
this.#lastClickNodeId = null;
|
|
1346
|
+
} else {
|
|
1347
|
+
this.#lastClickTime = now;
|
|
1348
|
+
this.#lastClickNodeId = nodeId;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
#handleConnectionClick(connId, e) {
|
|
1355
|
+
const accumulate = e.ctrlKey || e.metaKey;
|
|
1356
|
+
this.#selector.selectConnection(connId, accumulate);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// --- Transform ---
|
|
1360
|
+
|
|
1361
|
+
#updateTransform() {
|
|
1362
|
+
|
|
1363
|
+
// Sync grid dots with pan/zoom (cached to avoid forced reflow via getComputedStyle)
|
|
1364
|
+
if (this._gridBase === undefined) {
|
|
1365
|
+
this._gridBase = parseInt(getComputedStyle(this).getPropertyValue('--sn-grid-size')) || 20;
|
|
1366
|
+
}
|
|
1367
|
+
const gridBase = this._gridBase;
|
|
1368
|
+
const zoom = this.$.zoom;
|
|
1369
|
+
const multiplier = zoom < 0.5 ? 2 : 1;
|
|
1370
|
+
const gridSize = gridBase * multiplier * zoom;
|
|
1371
|
+
this.style.backgroundSize = `${gridSize}px ${gridSize}px`;
|
|
1372
|
+
this.style.backgroundPosition = `${this.$.panX}px ${this.$.panY}px`;
|
|
1373
|
+
|
|
1374
|
+
// Sync toolbar position with zoom/pan
|
|
1375
|
+
const toolbar = this.ref.quickToolbar;
|
|
1376
|
+
if (toolbar) {
|
|
1377
|
+
toolbar._transform = { zoom, panX: this.$.panX, panY: this.$.panY };
|
|
1378
|
+
if (toolbar._nodeEl) toolbar.updatePosition(toolbar._nodeEl);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Viewport culling + LOD (throttled via rAF to prevent re-render cycles)
|
|
1382
|
+
if (!this._cullingScheduled) {
|
|
1383
|
+
this._cullingScheduled = true;
|
|
1384
|
+
requestAnimationFrame(() => {
|
|
1385
|
+
this._cullingScheduled = false;
|
|
1386
|
+
this.#applyCullingAndLOD();
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** Apply viewport culling and LOD based on current transform */
|
|
1392
|
+
#applyCullingAndLOD() {
|
|
1393
|
+
if (!this.ref.canvasContainer) return;
|
|
1394
|
+
// Allow running even with 0 DOM nodes (phantom-only mode)
|
|
1395
|
+
if (this.#nodeViews.size === 0 && this.#phantomData.size === 0) return;
|
|
1396
|
+
|
|
1397
|
+
const cw = this.ref.canvasContainer.clientWidth;
|
|
1398
|
+
const ch = this.ref.canvasContainer.clientHeight;
|
|
1399
|
+
const zoom = this.$.zoom;
|
|
1400
|
+
const panX = this.$.panX;
|
|
1401
|
+
const panY = this.$.panY;
|
|
1402
|
+
const margin = 300;
|
|
1403
|
+
|
|
1404
|
+
const lod = zoom < 0.5 ? 'medium' : 'full';
|
|
1405
|
+
this._currentLod = lod;
|
|
1406
|
+
|
|
1407
|
+
if (this.ref.connections) {
|
|
1408
|
+
const isDimmed = this.ref.connections.hasAttribute('data-lod-dimmed');
|
|
1409
|
+
if (lod !== 'full') {
|
|
1410
|
+
if (!isDimmed) this.ref.connections.setAttribute('data-lod-dimmed', '');
|
|
1411
|
+
} else {
|
|
1412
|
+
if (isDimmed) this.ref.connections.removeAttribute('data-lod-dimmed');
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// ─── Virtualization: promote/demote ───
|
|
1417
|
+
const isVirtualized = this.#phantomData.size > 0 || this.#allNodes.size > 200;
|
|
1418
|
+
const FAR_ZOOM = zoom < 0.25;
|
|
1419
|
+
const toPromote = [];
|
|
1420
|
+
const toDemote = [];
|
|
1421
|
+
|
|
1422
|
+
// Check existing DOM nodes for visibility
|
|
1423
|
+
for (const [id, el] of this.#nodeViews) {
|
|
1424
|
+
const pos = el._position;
|
|
1425
|
+
if (!pos) continue;
|
|
1426
|
+
|
|
1427
|
+
const screenX = pos.x * zoom + panX;
|
|
1428
|
+
const screenY = pos.y * zoom + panY;
|
|
1429
|
+
if (!el._cachedW) {
|
|
1430
|
+
el._cachedW = el.offsetWidth || 180;
|
|
1431
|
+
el._cachedH = el.offsetHeight || 60;
|
|
1432
|
+
}
|
|
1433
|
+
const w = el._cachedW * zoom;
|
|
1434
|
+
const h = el._cachedH * zoom;
|
|
1435
|
+
|
|
1436
|
+
const visible = (screenX + w > -margin) && (screenX < cw + margin) &&
|
|
1437
|
+
(screenY + h > -margin) && (screenY < ch + margin);
|
|
1438
|
+
|
|
1439
|
+
if (isVirtualized && FAR_ZOOM) {
|
|
1440
|
+
toDemote.push(id);
|
|
1441
|
+
} else if (visible) {
|
|
1442
|
+
if (el.style.visibility !== '') el.style.visibility = '';
|
|
1443
|
+
if (el.getAttribute('data-lod') !== lod) el.setAttribute('data-lod', lod);
|
|
1444
|
+
} else if (isVirtualized) {
|
|
1445
|
+
// Demote to phantom (only in virtualized mode)
|
|
1446
|
+
toDemote.push(id);
|
|
1447
|
+
} else {
|
|
1448
|
+
if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden';
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Check phantom nodes for visibility — promote if in viewport
|
|
1453
|
+
// Skip promotion if ALL nodes are still phantom (initial load before fitView)
|
|
1454
|
+
const allPhantom = this.#nodeViews.size === 0 && this.#phantomData.size > 0;
|
|
1455
|
+
if (isVirtualized && !FAR_ZOOM) {
|
|
1456
|
+
for (const [id, pd] of this.#phantomData) {
|
|
1457
|
+
const screenX = pd.x * zoom + panX;
|
|
1458
|
+
const screenY = pd.y * zoom + panY;
|
|
1459
|
+
const w = (pd.w || 180) * zoom;
|
|
1460
|
+
const h = (pd.h || 60) * zoom;
|
|
1461
|
+
|
|
1462
|
+
const visible = (screenX + w > -margin) && (screenX < cw + margin) &&
|
|
1463
|
+
(screenY + h > -margin) && (screenY < ch + margin);
|
|
1464
|
+
|
|
1465
|
+
if (visible) toPromote.push(id);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// FAR_ZOOM: synchronous demote — no debounce to prevent flicker
|
|
1470
|
+
if (FAR_ZOOM && toDemote.length > 0) {
|
|
1471
|
+
if (this.#virtTimer) { clearTimeout(this.#virtTimer); this.#virtTimer = null; }
|
|
1472
|
+
for (const id of toDemote) this.#demoteNode(id);
|
|
1473
|
+
this.#syncPhantomToRenderer();
|
|
1474
|
+
} else if (toPromote.length > 0 || toDemote.length > 0) {
|
|
1475
|
+
// Normal viewport culling: debounce to avoid thrashing during pan/scroll
|
|
1476
|
+
if (this.#virtTimer) clearTimeout(this.#virtTimer);
|
|
1477
|
+
this.#virtTimer = setTimeout(() => {
|
|
1478
|
+
this.#virtTimer = null;
|
|
1479
|
+
for (const id of toDemote) this.#demoteNode(id);
|
|
1480
|
+
for (const id of toPromote) this.#promoteNode(id);
|
|
1481
|
+
this.#syncPhantomToRenderer();
|
|
1482
|
+
}, 100);
|
|
1483
|
+
} else if (this.#phantomDirty || allPhantom) {
|
|
1484
|
+
// Phantom positions changed or initial phantom-only state — sync to renderer
|
|
1485
|
+
this.#phantomDirty = false;
|
|
1486
|
+
this.#syncPhantomToRenderer();
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// ─── Virtualization helpers ───
|
|
1491
|
+
|
|
1492
|
+
/** Promote a phantom node to full DOM */
|
|
1493
|
+
#promoteNode(nodeId) {
|
|
1494
|
+
if (this.#nodeViews.has(nodeId)) return; // already DOM
|
|
1495
|
+
const node = this.#allNodes.get(nodeId);
|
|
1496
|
+
if (!node) return;
|
|
1497
|
+
|
|
1498
|
+
const pd = this.#phantomData.get(nodeId);
|
|
1499
|
+
this.#viewManager.addView(node);
|
|
1500
|
+
const el = this.#nodeViews.get(nodeId);
|
|
1501
|
+
if (el && pd) {
|
|
1502
|
+
el._position = { x: pd.x, y: pd.y };
|
|
1503
|
+
el._cachedW = pd.w;
|
|
1504
|
+
el._cachedH = pd.h;
|
|
1505
|
+
el.style.transform = `translate(${pd.x}px, ${pd.y}px)`;
|
|
1506
|
+
}
|
|
1507
|
+
this.#phantomData.delete(nodeId);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Demote a DOM node to phantom (Canvas dot) */
|
|
1511
|
+
#demoteNode(nodeId) {
|
|
1512
|
+
if (!this.#nodeViews.has(nodeId)) return; // already phantom
|
|
1513
|
+
const el = this.#nodeViews.get(nodeId);
|
|
1514
|
+
// Capture color from DOM before removal (use cached or inline style — avoid getComputedStyle reflow)
|
|
1515
|
+
let color = null;
|
|
1516
|
+
if (el) {
|
|
1517
|
+
color = el._cachedBgColor || el.style.backgroundColor || null;
|
|
1518
|
+
}
|
|
1519
|
+
const dims = this.#viewManager.removeViewInstant(nodeId);
|
|
1520
|
+
if (dims) {
|
|
1521
|
+
const node = this.#allNodes.get(nodeId);
|
|
1522
|
+
this.#phantomData.set(nodeId, {
|
|
1523
|
+
id: nodeId,
|
|
1524
|
+
x: dims.x, y: dims.y,
|
|
1525
|
+
w: dims.w, h: dims.h,
|
|
1526
|
+
degree: this.#nodeDegrees.get(nodeId) || 0,
|
|
1527
|
+
color,
|
|
1528
|
+
label: node?.label || nodeId,
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** Push current phantom data to the Canvas renderer */
|
|
1534
|
+
#syncPhantomToRenderer() {
|
|
1535
|
+
if (this.#connRenderer && typeof this.#connRenderer.setPhantomNodes === 'function') {
|
|
1536
|
+
this.#connRenderer.setPhantomNodes([...this.#phantomData.values()]);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** Public: force sync phantom data to renderer (for use after batch setNodePosition) */
|
|
1541
|
+
syncPhantom() {
|
|
1542
|
+
this.#phantomDirty = false;
|
|
1543
|
+
this.#syncPhantomToRenderer();
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// --- Lifecycle ---
|
|
1547
|
+
|
|
1548
|
+
renderCallback() {
|
|
1549
|
+
const container = this.ref.canvasContainer;
|
|
1550
|
+
const content = this.ref.content;
|
|
1551
|
+
|
|
1552
|
+
// Canvas pan
|
|
1553
|
+
this.#drag = new Drag();
|
|
1554
|
+
this.#drag.initialize(
|
|
1555
|
+
container,
|
|
1556
|
+
{
|
|
1557
|
+
getPosition: () => ({ x: this.$.panX, y: this.$.panY }),
|
|
1558
|
+
getZoom: () => 1,
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
onStart: (e) => {
|
|
1562
|
+
// Track start position — only unselect on click (not drag)
|
|
1563
|
+
this._panStart = e ? { x: e.pageX, y: e.pageY, target: e.target } : null;
|
|
1564
|
+
},
|
|
1565
|
+
onTranslate: (x, y) => {
|
|
1566
|
+
if (this.#zoom?.isTranslating()) return;
|
|
1567
|
+
if (this.#connectFlow?.isPicking()) return;
|
|
1568
|
+
this.$.panX = x;
|
|
1569
|
+
this.$.panY = y;
|
|
1570
|
+
this.#updateTransform();
|
|
1571
|
+
this.dispatchEvent(new CustomEvent('manualviewport'));
|
|
1572
|
+
|
|
1573
|
+
// Suppress CSS :hover on paths during active pan
|
|
1574
|
+
if (!this.hasAttribute('data-interacting')) {
|
|
1575
|
+
this.setAttribute('data-interacting', '');
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
onDrop: (e) => {
|
|
1579
|
+
// Unselect only on click (minimal movement), not after panning
|
|
1580
|
+
if (this._panStart && e) {
|
|
1581
|
+
const dx = Math.abs(e.pageX - this._panStart.x);
|
|
1582
|
+
const dy = Math.abs(e.pageY - this._panStart.y);
|
|
1583
|
+
const t = this._panStart.target;
|
|
1584
|
+
const isNode = t?.closest?.('graph-node, quick-toolbar, context-menu, inspector-panel');
|
|
1585
|
+
if (dx < 5 && dy < 5 && !isNode) {
|
|
1586
|
+
this.#selector.unselectAll();
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
this._panStart = null;
|
|
1590
|
+
this.removeAttribute('data-interacting');
|
|
1591
|
+
},
|
|
1592
|
+
}
|
|
1593
|
+
);
|
|
1594
|
+
|
|
1595
|
+
// Zoom
|
|
1596
|
+
this.#zoom = new Zoom(0.1);
|
|
1597
|
+
let interactingTimer = null;
|
|
1598
|
+
this.#zoom.initialize(container, content, (delta, ox, oy) => {
|
|
1599
|
+
const k = this.$.zoom;
|
|
1600
|
+
const newK = k * (1 + delta);
|
|
1601
|
+
if (newK < 0.001 || newK > 5) return;
|
|
1602
|
+
this.$.zoom = newK;
|
|
1603
|
+
this.$.panX += ox;
|
|
1604
|
+
this.$.panY += oy;
|
|
1605
|
+
this.#updateTransform();
|
|
1606
|
+
this.dispatchEvent(new CustomEvent('manualviewport'));
|
|
1607
|
+
|
|
1608
|
+
// Suppress CSS :hover on paths during active zoom
|
|
1609
|
+
if (!this.hasAttribute('data-interacting')) {
|
|
1610
|
+
this.setAttribute('data-interacting', '');
|
|
1611
|
+
}
|
|
1612
|
+
clearTimeout(interactingTimer);
|
|
1613
|
+
interactingTimer = setTimeout(() => {
|
|
1614
|
+
this.removeAttribute('data-interacting');
|
|
1615
|
+
}, 150);
|
|
1616
|
+
}, () => ({ x: this.$.panX, y: this.$.panY }));
|
|
1617
|
+
|
|
1618
|
+
// Context menu + keyboard
|
|
1619
|
+
container.addEventListener('contextmenu', (e) => {
|
|
1620
|
+
this.#actions?.showContextMenu(e, this.ref.contextMenu, container, {
|
|
1621
|
+
panX: this.$.panX,
|
|
1622
|
+
panY: this.$.panY,
|
|
1623
|
+
zoom: this.$.zoom,
|
|
1624
|
+
});
|
|
1625
|
+
});
|
|
1626
|
+
container.addEventListener('keydown', (e) => this.#actions?.handleKeydown(e));
|
|
1627
|
+
|
|
1628
|
+
// Ctrl+F to open node search
|
|
1629
|
+
container.addEventListener('keydown', (e) => {
|
|
1630
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
1631
|
+
e.preventDefault();
|
|
1632
|
+
this.ref.nodeSearch?.toggle();
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
// Computed transform — auto-tracks panX, panY, zoom
|
|
1637
|
+
// --- Loop detector for contentTransform ---
|
|
1638
|
+
this.sub('+contentTransform', (val) => {
|
|
1639
|
+
if (this.ref.content) {
|
|
1640
|
+
this.ref.content.style.transform = val;
|
|
1641
|
+
}
|
|
1642
|
+
this.#connRenderer?.refreshAll();
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
this.#updateTransform();
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
// Minimap — auto-show on viewport change, toggle button
|
|
1649
|
+
const minimap = this.ref.minimap;
|
|
1650
|
+
const minimapToggle = this.ref.minimapToggle;
|
|
1651
|
+
const MINIMAP_KEY = 'sn-minimap-enabled';
|
|
1652
|
+
let minimapEnabled = localStorage.getItem(MINIMAP_KEY) === 'true';
|
|
1653
|
+
let fadeTimer = null;
|
|
1654
|
+
const FADE_DELAY = 2000;
|
|
1655
|
+
|
|
1656
|
+
const showMinimap = () => {
|
|
1657
|
+
if (!minimapEnabled || !minimap) return;
|
|
1658
|
+
minimap.hidden = false;
|
|
1659
|
+
minimap.removeAttribute('data-fading');
|
|
1660
|
+
minimap.update?.();
|
|
1661
|
+
clearTimeout(fadeTimer);
|
|
1662
|
+
fadeTimer = setTimeout(() => {
|
|
1663
|
+
minimap.setAttribute('data-fading', '');
|
|
1664
|
+
// After transition ends, hide completely
|
|
1665
|
+
setTimeout(() => {
|
|
1666
|
+
if (minimap.hasAttribute('data-fading')) {
|
|
1667
|
+
minimap.hidden = true;
|
|
1668
|
+
minimap.removeAttribute('data-fading');
|
|
1669
|
+
}
|
|
1670
|
+
}, 400);
|
|
1671
|
+
}, FADE_DELAY);
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
const updateToggleState = () => {
|
|
1675
|
+
if (minimapToggle) {
|
|
1676
|
+
minimapToggle.toggleAttribute('data-active', minimapEnabled);
|
|
1677
|
+
}
|
|
1678
|
+
if (!minimapEnabled && minimap) {
|
|
1679
|
+
minimap.hidden = true;
|
|
1680
|
+
minimap.removeAttribute('data-fading');
|
|
1681
|
+
clearTimeout(fadeTimer);
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
updateToggleState();
|
|
1686
|
+
|
|
1687
|
+
if (minimapToggle) {
|
|
1688
|
+
minimapToggle.addEventListener('click', (e) => {
|
|
1689
|
+
e.stopPropagation();
|
|
1690
|
+
minimapEnabled = !minimapEnabled;
|
|
1691
|
+
localStorage.setItem(MINIMAP_KEY, minimapEnabled);
|
|
1692
|
+
updateToggleState();
|
|
1693
|
+
if (minimapEnabled) showMinimap();
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// Minimap only shown via toggle button (auto-show disabled)
|
|
1698
|
+
|
|
1699
|
+
if (minimap) {
|
|
1700
|
+
minimap.setStateGetter(() => {
|
|
1701
|
+
const nodes = [];
|
|
1702
|
+
for (const [id, el] of this.#nodeViews) {
|
|
1703
|
+
const pos = el._position || { x: 0, y: 0 };
|
|
1704
|
+
if (!el._cachedW) {
|
|
1705
|
+
el._cachedW = el.offsetWidth || 180;
|
|
1706
|
+
el._cachedH = el.offsetHeight || 60;
|
|
1707
|
+
}
|
|
1708
|
+
nodes.push({
|
|
1709
|
+
x: pos.x,
|
|
1710
|
+
y: pos.y,
|
|
1711
|
+
width: el._cachedW,
|
|
1712
|
+
height: el._cachedH,
|
|
1713
|
+
bypassed: el.hasAttribute('data-bypassed'),
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
return {
|
|
1717
|
+
nodes,
|
|
1718
|
+
transform: { x: this.$.panX, y: this.$.panY, zoom: this.$.zoom },
|
|
1719
|
+
containerSize: {
|
|
1720
|
+
width: container.clientWidth,
|
|
1721
|
+
height: container.clientHeight,
|
|
1722
|
+
},
|
|
1723
|
+
};
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
// Handle minimap viewport drag
|
|
1727
|
+
minimap.addEventListener('minimap-navigate', (e) => {
|
|
1728
|
+
this.$.panX = e.detail.x;
|
|
1729
|
+
this.$.panY = e.detail.y;
|
|
1730
|
+
this.#updateTransform();
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Node search
|
|
1735
|
+
const nodeSearch = this.ref.nodeSearch;
|
|
1736
|
+
if (nodeSearch) {
|
|
1737
|
+
nodeSearch.configure({
|
|
1738
|
+
getNodes: () => {
|
|
1739
|
+
const result = [];
|
|
1740
|
+
if (this.#editor) {
|
|
1741
|
+
for (const node of this.#editor.getNodes()) {
|
|
1742
|
+
result.push({ id: node.id, label: node.label, type: node.type, category: node.category });
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
return result;
|
|
1746
|
+
},
|
|
1747
|
+
onSelect: (nodeId) => {
|
|
1748
|
+
// Select node
|
|
1749
|
+
this.#selector.selectNode(nodeId);
|
|
1750
|
+
// Center viewport on node
|
|
1751
|
+
const el = this.#nodeViews.get(nodeId);
|
|
1752
|
+
if (el?._position) {
|
|
1753
|
+
const cx = container.clientWidth / 2;
|
|
1754
|
+
const cy = container.clientHeight / 2;
|
|
1755
|
+
this.$.panX = -el._position.x * this.$.zoom + cx;
|
|
1756
|
+
this.$.panY = -el._position.y * this.$.zoom + cy;
|
|
1757
|
+
this.#updateTransform();
|
|
1758
|
+
}
|
|
1759
|
+
},
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Smoothly pan viewport to center on a node
|
|
1766
|
+
* @param {string} nodeId
|
|
1767
|
+
* @param {number} [duration=400] - Animation duration in ms
|
|
1768
|
+
*/
|
|
1769
|
+
panToNode(nodeId, duration = 400) {
|
|
1770
|
+
const el = this.#nodeViews.get(nodeId);
|
|
1771
|
+
if (!el?._position) return;
|
|
1772
|
+
const container = this.ref.canvasContainer;
|
|
1773
|
+
if (!container) return;
|
|
1774
|
+
|
|
1775
|
+
const cx = container.clientWidth / 2;
|
|
1776
|
+
const cy = container.clientHeight / 2;
|
|
1777
|
+
const nodeW = (el.offsetWidth || 180) / 2;
|
|
1778
|
+
const nodeH = (el.offsetHeight || 60) / 2;
|
|
1779
|
+
const targetX = -(el._position.x + nodeW) * this.$.zoom + cx;
|
|
1780
|
+
const targetY = -(el._position.y + nodeH) * this.$.zoom + cy;
|
|
1781
|
+
|
|
1782
|
+
this.#animatePan(targetX, targetY, duration);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* RAF-based smooth pan animation with easeOutCubic
|
|
1787
|
+
* @param {number} targetX
|
|
1788
|
+
* @param {number} targetY
|
|
1789
|
+
* @param {number} duration
|
|
1790
|
+
*/
|
|
1791
|
+
#animatePan(targetX, targetY, duration) {
|
|
1792
|
+
if (this.#panAnimFrame) {
|
|
1793
|
+
cancelAnimationFrame(this.#panAnimFrame);
|
|
1794
|
+
this.#panAnimFrame = null;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const startX = this.$.panX;
|
|
1798
|
+
const startY = this.$.panY;
|
|
1799
|
+
const dx = targetX - startX;
|
|
1800
|
+
const dy = targetY - startY;
|
|
1801
|
+
|
|
1802
|
+
// Skip if already close enough
|
|
1803
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
|
|
1804
|
+
|
|
1805
|
+
const startTime = performance.now();
|
|
1806
|
+
|
|
1807
|
+
const step = (now) => {
|
|
1808
|
+
const elapsed = now - startTime;
|
|
1809
|
+
const t = Math.min(elapsed / duration, 1);
|
|
1810
|
+
// easeOutCubic
|
|
1811
|
+
const ease = 1 - Math.pow(1 - t, 3);
|
|
1812
|
+
|
|
1813
|
+
this.$.panX = startX + dx * ease;
|
|
1814
|
+
this.$.panY = startY + dy * ease;
|
|
1815
|
+
this.#updateTransform();
|
|
1816
|
+
|
|
1817
|
+
if (t < 1) {
|
|
1818
|
+
this.#panAnimFrame = requestAnimationFrame(step);
|
|
1819
|
+
} else {
|
|
1820
|
+
this.#panAnimFrame = null;
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
|
|
1824
|
+
this.#panAnimFrame = requestAnimationFrame(step);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
destroyCallback() {
|
|
1828
|
+
if (this.#panAnimFrame) cancelAnimationFrame(this.#panAnimFrame);
|
|
1829
|
+
if (this.#drag) this.#drag.destroy();
|
|
1830
|
+
if (this.#zoom) this.#zoom.destroy();
|
|
1831
|
+
if (this.#connectFlow) this.#connectFlow.destroy();
|
|
1832
|
+
for (const [, el] of this.#nodeViews) {
|
|
1833
|
+
if (el._drag) el._drag.destroy();
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
NodeCanvas.template = template;
|
|
1839
|
+
NodeCanvas.rootStyles = styles;
|
|
1840
|
+
NodeCanvas.reg('node-canvas');
|