project-graph-mcp 2.2.6 → 2.3.0
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 +6 -13
- 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/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 +6 -5
- package/web/components/canvas-graph.js +1666 -0
- 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 +69 -0
- package/web/dashboard.js +1 -1
- 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 +2712 -7
- package/web/panels/file-tree.js +5 -2
- package/web/panels/live-monitor.js +75 -3
- package/web/style.css +33 -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,962 @@
|
|
|
1
|
+
import { getShape } from '../shapes/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parallel support for connection rendering via HTML5 Canvas API.
|
|
5
|
+
* This is used to test performance against the DOM-bound SVG renderer.
|
|
6
|
+
*/
|
|
7
|
+
export class CanvasConnectionRenderer {
|
|
8
|
+
#canvasLayer;
|
|
9
|
+
#dotLayer;
|
|
10
|
+
#nodeViews;
|
|
11
|
+
#editor;
|
|
12
|
+
#onConnectionClick;
|
|
13
|
+
#getZoom;
|
|
14
|
+
#getPan;
|
|
15
|
+
#onDotDrag;
|
|
16
|
+
|
|
17
|
+
#pathStyle = 'bezier';
|
|
18
|
+
#connectionData = new Map();
|
|
19
|
+
#ctx;
|
|
20
|
+
#resizeObserver;
|
|
21
|
+
#animationFrameId;
|
|
22
|
+
#batchMode = false;
|
|
23
|
+
#batchDirty = false;
|
|
24
|
+
|
|
25
|
+
/** @type {Array<{id:string, x:number, y:number, w:number, h:number, degree:number, color:string, label:string}>} */
|
|
26
|
+
#phantomNodes = [];
|
|
27
|
+
/** @type {Map<string, Object>} Fast lookup for phantom proxy by nodeId */
|
|
28
|
+
#phantomMap = new Map();
|
|
29
|
+
|
|
30
|
+
// Computed styles matching the theme
|
|
31
|
+
#colorParams = {
|
|
32
|
+
normal: '#4a9eff',
|
|
33
|
+
selected: '#ff6b6b',
|
|
34
|
+
width: 2,
|
|
35
|
+
flowingColor: '#4a9eff', // We use --sn-conn-color directly
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} config
|
|
40
|
+
* @param {HTMLCanvasElement} config.canvasLayer
|
|
41
|
+
* @param {HTMLElement} config.dotLayer
|
|
42
|
+
* @param {Map<string, HTMLElement>} config.nodeViews
|
|
43
|
+
* @param {import('../core/GraphEditor.js').GraphEditor} config.editor
|
|
44
|
+
* @param {function(string, MouseEvent)} config.onConnectionClick
|
|
45
|
+
* @param {function(): number} config.getZoom
|
|
46
|
+
* @param {function(): {x: number, y: number}} config.getPan
|
|
47
|
+
* @param {function(Object)} config.onDotDrag
|
|
48
|
+
*/
|
|
49
|
+
constructor(config = {}) {
|
|
50
|
+
this.#canvasLayer = config.canvasLayer || document.createElement('canvas');
|
|
51
|
+
this.#dotLayer = config.dotLayer;
|
|
52
|
+
this.#nodeViews = config.nodeViews;
|
|
53
|
+
this.#editor = config.editor;
|
|
54
|
+
this.#onConnectionClick = config.onConnectionClick;
|
|
55
|
+
this.#getZoom = config.getZoom || (() => 1);
|
|
56
|
+
this.#getPan = config.getPan || (() => ({ x: 0, y: 0 }));
|
|
57
|
+
this.#onDotDrag = config.onDotDrag;
|
|
58
|
+
|
|
59
|
+
this.#ctx = this.#canvasLayer.getContext('2d', { alpha: true, desynchronized: false });
|
|
60
|
+
this.#initResizeObserver();
|
|
61
|
+
this.#updateStyles();
|
|
62
|
+
|
|
63
|
+
// Start render loop for flow animations (if flowing exists)
|
|
64
|
+
this.#animationFrameId = requestAnimationFrame(this.#renderLoop);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resize observer to keep the canvas 1:1 with device pixels
|
|
69
|
+
*/
|
|
70
|
+
#initResizeObserver() {
|
|
71
|
+
const parent = this.#canvasLayer.parentElement;
|
|
72
|
+
if (!parent) return;
|
|
73
|
+
|
|
74
|
+
this.#resizeObserver = new ResizeObserver((entries) => {
|
|
75
|
+
const rect = entries[0].contentRect;
|
|
76
|
+
const dpr = window.devicePixelRatio || 1;
|
|
77
|
+
|
|
78
|
+
this.#canvasLayer.width = rect.width * dpr;
|
|
79
|
+
this.#canvasLayer.height = rect.height * dpr;
|
|
80
|
+
|
|
81
|
+
this.redraw();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.#resizeObserver.observe(parent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#updateStyles() {
|
|
88
|
+
const computed = getComputedStyle(document.body);
|
|
89
|
+
this.#colorParams.normal = computed.getPropertyValue('--sn-conn-color').trim() || '#4a9eff';
|
|
90
|
+
this.#colorParams.selected = computed.getPropertyValue('--sn-conn-selected').trim() || '#ff6b6b';
|
|
91
|
+
this.#colorParams.outline = computed.getPropertyValue('--sn-port-outline').trim() || '#16213e';
|
|
92
|
+
this.#colorParams.bg = computed.getPropertyValue('--sn-bg').trim() || '#1a1a2e';
|
|
93
|
+
this.#colorParams.width = parseFloat(computed.getPropertyValue('--sn-conn-width')) || 2;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {'bezier'|'orthogonal'|'straight'|'pcb'} style */
|
|
97
|
+
setPathStyle(style) {
|
|
98
|
+
this.#pathStyle = style;
|
|
99
|
+
this.redraw();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get data() {
|
|
103
|
+
return this.#connectionData;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
addBatch(conns) {
|
|
107
|
+
for (const conn of conns) {
|
|
108
|
+
this.#connectionData.set(conn.id, conn);
|
|
109
|
+
}
|
|
110
|
+
this.redraw();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
refreshAll() {
|
|
114
|
+
this.redraw();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
add(conn) {
|
|
118
|
+
this.#connectionData.set(conn.id, conn);
|
|
119
|
+
this.redraw();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
remove(conn) {
|
|
123
|
+
this.#connectionData.delete(conn.id);
|
|
124
|
+
this.redraw();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
updateForNode(nodeId) {
|
|
128
|
+
this.redraw();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
setFlowing(connId, active) {
|
|
132
|
+
const conn = this.#connectionData.get(connId);
|
|
133
|
+
if (conn) conn.flowing = active;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setAllFlowing(active) {
|
|
137
|
+
for (const conn of this.#connectionData.values()) {
|
|
138
|
+
conn.flowing = active;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setPathStyle(style) {
|
|
143
|
+
this.#pathStyle = style;
|
|
144
|
+
this.redraw();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
highlightDotsForNodes(compatibleNodeIds) { }
|
|
148
|
+
clearDotHighlights() { }
|
|
149
|
+
renderFreeDots(nodeId) { }
|
|
150
|
+
removeFreeDot(nodeId, key, side) { }
|
|
151
|
+
refreshFreeDots(nodeId) { }
|
|
152
|
+
findNearestDot(wx, wy, radius = 20) { return null; }
|
|
153
|
+
|
|
154
|
+
clear() {
|
|
155
|
+
this.#connectionData.clear();
|
|
156
|
+
this.#phantomNodes = [];
|
|
157
|
+
this.#phantomMap.clear();
|
|
158
|
+
this.redraw();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// #phantomMap moved to class field declarations (line 25)
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Set phantom nodes — nodes without DOM that are rendered as Canvas dots.
|
|
165
|
+
* @param {Array<{id:string, x:number, y:number, w:number, h:number, degree:number, color:string, label:string}>} nodes
|
|
166
|
+
*/
|
|
167
|
+
setPhantomNodes(nodes) {
|
|
168
|
+
this.#phantomNodes = nodes || [];
|
|
169
|
+
this.#phantomMap.clear();
|
|
170
|
+
for (const n of this.#phantomNodes) {
|
|
171
|
+
this.#phantomMap.set(n.id, n);
|
|
172
|
+
}
|
|
173
|
+
this.redraw();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Retrieve actual connector coordinate relative to the origin */
|
|
177
|
+
getSocketOffset(nodeEl, portKey, side, targetPos) {
|
|
178
|
+
if (!nodeEl) return { x: 0, y: 0 };
|
|
179
|
+
const w = nodeEl._cachedW || nodeEl.offsetWidth || 180;
|
|
180
|
+
const h = nodeEl._cachedH || nodeEl.offsetHeight || 100;
|
|
181
|
+
|
|
182
|
+
let basePortX = side === 'output' ? w : 0;
|
|
183
|
+
|
|
184
|
+
// Fast path: cached layout coords for the node
|
|
185
|
+
if (nodeEl._slotCache && nodeEl._slotCache.has(portKey)) {
|
|
186
|
+
const cached = nodeEl._slotCache.get(portKey);
|
|
187
|
+
return {
|
|
188
|
+
x: cached.x,
|
|
189
|
+
y: cached.y,
|
|
190
|
+
angle: cached.angle
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const nodeModel = this.#editor?.getNode(nodeEl.id);
|
|
195
|
+
const isParamNode = nodeModel?.type === 'param';
|
|
196
|
+
let portIndex = 0;
|
|
197
|
+
let totalPorts = 1;
|
|
198
|
+
|
|
199
|
+
if (nodeModel && nodeModel.type !== 'param') {
|
|
200
|
+
const portsData = side === 'output' ? nodeModel.outputs : nodeModel.inputs;
|
|
201
|
+
if (portsData) {
|
|
202
|
+
const keys = Object.keys(portsData);
|
|
203
|
+
totalPorts = keys.length || 1;
|
|
204
|
+
const idx = keys.indexOf(portKey);
|
|
205
|
+
if (idx !== -1) portIndex = idx;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Delegate to UniversalSvgShape if defined and handles geometric coordinates (SVGShape)
|
|
210
|
+
const shapeConfig = getShape(nodeModel?.shape);
|
|
211
|
+
if (shapeConfig && shapeConfig.pathData && shapeConfig.getSocketPosition) {
|
|
212
|
+
const pos = shapeConfig.getSocketPosition(side, portIndex, totalPorts, { width: w, height: h }, targetPos);
|
|
213
|
+
if (pos) return pos;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Standard shapes: read from DOM socket elements
|
|
217
|
+
const container = side === 'output'
|
|
218
|
+
? nodeEl.querySelector('.outputs')
|
|
219
|
+
: nodeEl.querySelector('.inputs');
|
|
220
|
+
|
|
221
|
+
if (container) {
|
|
222
|
+
const portItems = container.querySelectorAll('port-item');
|
|
223
|
+
for (const portItem of portItems) {
|
|
224
|
+
if (String(portItem.$.key) === String(portKey)) {
|
|
225
|
+
const socket = portItem.querySelector('.sn-socket');
|
|
226
|
+
if (socket) {
|
|
227
|
+
const nodeRect = nodeEl.getBoundingClientRect();
|
|
228
|
+
const socketRect = socket.getBoundingClientRect();
|
|
229
|
+
const z = this.#getZoom();
|
|
230
|
+
return {
|
|
231
|
+
x: (socketRect.left - nodeRect.left + socketRect.width / 2) / z,
|
|
232
|
+
y: (socketRect.top - nodeRect.top + socketRect.height / 2) / z,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
x: side === 'output' ? (nodeEl._cachedW || nodeEl.offsetWidth || 180) : 0,
|
|
241
|
+
y: (nodeEl._cachedH || nodeEl.offsetHeight || 100) / 2,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#hasSelection = false;
|
|
246
|
+
#activeConnIds = new Set();
|
|
247
|
+
|
|
248
|
+
setSelectionState(hasSelection, activeConnIds) {
|
|
249
|
+
this.#hasSelection = hasSelection;
|
|
250
|
+
this.#activeConnIds = activeConnIds;
|
|
251
|
+
this.redraw();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Suppress redraws during batch operations (e.g. setEditor initialization) */
|
|
255
|
+
setBatchMode(on) {
|
|
256
|
+
this.#batchMode = on;
|
|
257
|
+
if (!on && this.#batchDirty) {
|
|
258
|
+
this.#batchDirty = false;
|
|
259
|
+
this.redraw();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Perform full synchronous redraw of all connections */
|
|
264
|
+
redraw() {
|
|
265
|
+
if (this.#batchMode) { this.#batchDirty = true; return; }
|
|
266
|
+
const ctx = this.#ctx;
|
|
267
|
+
if (!ctx) return;
|
|
268
|
+
|
|
269
|
+
// Reset and clear with devicePixelRatio
|
|
270
|
+
const dpr = window.devicePixelRatio || 1;
|
|
271
|
+
const zoom = this.#getZoom();
|
|
272
|
+
this._frameZoom = zoom; // cache for #plotPath LOD
|
|
273
|
+
const pan = this.#getPan();
|
|
274
|
+
|
|
275
|
+
// Reset transform to identity to clear the raw screen buffer
|
|
276
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
277
|
+
ctx.clearRect(0, 0, this.#canvasLayer.width, this.#canvasLayer.height);
|
|
278
|
+
|
|
279
|
+
// Set view transform: Map World coordinates -> Screen coordinates
|
|
280
|
+
ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, dpr * pan.x, dpr * pan.y);
|
|
281
|
+
|
|
282
|
+
// Update theme vars
|
|
283
|
+
this.#updateStyles();
|
|
284
|
+
|
|
285
|
+
const time = Date.now();
|
|
286
|
+
let hasFlowing = false;
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
// Cache node layout geometry once per frame for the router (Map for O(1) lookup)
|
|
290
|
+
this._nodeRectMap = new Map();
|
|
291
|
+
for (const [nid, el] of this.#nodeViews) {
|
|
292
|
+
if (el && el._position) {
|
|
293
|
+
this._nodeRectMap.set(nid, {
|
|
294
|
+
id: nid,
|
|
295
|
+
x: el._position.x,
|
|
296
|
+
y: el._position.y,
|
|
297
|
+
w: el._cachedW || 180,
|
|
298
|
+
h: el._cachedH || 60,
|
|
299
|
+
el: el
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Include phantom nodes in geometry cache for FAR_ZOOM routing
|
|
304
|
+
for (const node of this.#phantomNodes) {
|
|
305
|
+
if (node && !this._nodeRectMap.has(node.id)) {
|
|
306
|
+
this._nodeRectMap.set(node.id, {
|
|
307
|
+
id: node.id,
|
|
308
|
+
x: node.x || 0,
|
|
309
|
+
y: node.y || 0,
|
|
310
|
+
w: node.w || 180,
|
|
311
|
+
h: node.h || 60,
|
|
312
|
+
el: null
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Pre-compute connection index once per frame (avoids O(N²) Array.from+indexOf in routing)
|
|
318
|
+
const connIndexMap = new Map();
|
|
319
|
+
let ci = 0;
|
|
320
|
+
for (const key of this.#connectionData.keys()) {
|
|
321
|
+
connIndexMap.set(key, ci++);
|
|
322
|
+
}
|
|
323
|
+
this._connIndexMap = connIndexMap;
|
|
324
|
+
|
|
325
|
+
// Collect connected sockets to draw caps over them (fixes DOM/Canvas sub-pixel drift seams)
|
|
326
|
+
const socketsToDraw = new Map();
|
|
327
|
+
|
|
328
|
+
const drawConnection = (id, connection) => {
|
|
329
|
+
// Draw connection
|
|
330
|
+
const isFlowing = connection.flowing;
|
|
331
|
+
const isActive = this.#activeConnIds ? this.#activeConnIds.has(connection.id) : false;
|
|
332
|
+
const isSelected = isActive;
|
|
333
|
+
const isDimmed = !isActive && this.#hasSelection;
|
|
334
|
+
|
|
335
|
+
const fromNode = this.#editor?.getNode(connection.from);
|
|
336
|
+
const toNode = this.#editor?.getNode(connection.to);
|
|
337
|
+
const fromColor = fromNode?.outputs?.[connection.out]?.socket?.color;
|
|
338
|
+
const toColor = toNode?.inputs?.[connection.in]?.socket?.color;
|
|
339
|
+
|
|
340
|
+
// Scale lineWidth inversely with zoom to maintain screen visibility
|
|
341
|
+
// At zoom 0.003, a 2px world line = 0.006 screen px (invisible)
|
|
342
|
+
// minScreenWidth = 1.5 screen px → in world coords = 1.5 / zoom
|
|
343
|
+
const baseWidth = this.#colorParams.width;
|
|
344
|
+
ctx.lineWidth = Math.max(baseWidth, 1.5 / zoom);
|
|
345
|
+
ctx.lineCap = 'round';
|
|
346
|
+
ctx.lineJoin = 'round';
|
|
347
|
+
ctx.globalAlpha = 1.0;
|
|
348
|
+
|
|
349
|
+
ctx.beginPath();
|
|
350
|
+
let coords = null;
|
|
351
|
+
try {
|
|
352
|
+
coords = this.#plotPath(ctx, connection);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.warn('Path failed:', err);
|
|
355
|
+
}
|
|
356
|
+
if (!coords) return;
|
|
357
|
+
|
|
358
|
+
// Save caps for later drawing (ensures they are on top of all paths)
|
|
359
|
+
socketsToDraw.set(`${connection.from}:${connection.out}`, { x: coords.startX, y: coords.startY, color: fromColor || this.#colorParams.normal });
|
|
360
|
+
socketsToDraw.set(`${connection.to}:${connection.in}`, { x: coords.endX, y: coords.endY, color: toColor || this.#colorParams.normal });
|
|
361
|
+
|
|
362
|
+
let finalColor;
|
|
363
|
+
if (fromColor && toColor && fromColor !== toColor) {
|
|
364
|
+
const grad = ctx.createLinearGradient(coords.startX, coords.startY, coords.endX, coords.endY);
|
|
365
|
+
grad.addColorStop(0, fromColor);
|
|
366
|
+
grad.addColorStop(1, toColor);
|
|
367
|
+
finalColor = grad;
|
|
368
|
+
} else {
|
|
369
|
+
finalColor = fromColor || this.#colorParams.normal;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (isDimmed) {
|
|
373
|
+
// Actually, color-mix doesn't work on CanvasGradient. If it's a gradient, fallback to solid fromColor.
|
|
374
|
+
let baseColor = fromColor || this.#colorParams.normal;
|
|
375
|
+
finalColor = `color-mix(in srgb, ${baseColor} 15%, ${this.#colorParams.bg})`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
ctx.strokeStyle = finalColor;
|
|
379
|
+
ctx.fillStyle = finalColor;
|
|
380
|
+
|
|
381
|
+
if (isFlowing) {
|
|
382
|
+
ctx.setLineDash([10, 10]);
|
|
383
|
+
ctx.lineDashOffset = -(time / 20) % 20;
|
|
384
|
+
hasFlowing = true;
|
|
385
|
+
} else {
|
|
386
|
+
ctx.setLineDash([]);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Apply drop shadow for selected lines to make them pop
|
|
390
|
+
if (isSelected && !isDimmed) {
|
|
391
|
+
ctx.shadowColor = ctx.strokeStyle;
|
|
392
|
+
ctx.shadowBlur = 8;
|
|
393
|
+
} else {
|
|
394
|
+
ctx.shadowBlur = 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
ctx.stroke(coords.path2D);
|
|
398
|
+
|
|
399
|
+
// draw direction arrow
|
|
400
|
+
if (coords.arrow) {
|
|
401
|
+
ctx.save();
|
|
402
|
+
ctx.translate(coords.arrow.x, coords.arrow.y);
|
|
403
|
+
// Transform from typical left-to-right arrow drawn along X axis
|
|
404
|
+
ctx.rotate(coords.arrow.angle);
|
|
405
|
+
ctx.beginPath();
|
|
406
|
+
ctx.moveTo(-5, -3.5);
|
|
407
|
+
ctx.lineTo(5, 0);
|
|
408
|
+
ctx.lineTo(-5, 3.5);
|
|
409
|
+
ctx.closePath();
|
|
410
|
+
ctx.fillStyle = ctx.strokeStyle; // inherited from path
|
|
411
|
+
ctx.fill();
|
|
412
|
+
ctx.restore();
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
if (this.#hasSelection) {
|
|
417
|
+
for (const [id, connection] of this.#connectionData) {
|
|
418
|
+
if (!this.#activeConnIds.has(connection.id)) drawConnection(id, connection);
|
|
419
|
+
}
|
|
420
|
+
for (const [id, connection] of this.#connectionData) {
|
|
421
|
+
if (this.#activeConnIds.has(connection.id)) drawConnection(id, connection);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
for (const [id, connection] of this.#connectionData) {
|
|
425
|
+
drawConnection(id, connection);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Draw caps for connected sockets to hide DOM subpixel drift
|
|
430
|
+
ctx.setLineDash([]);
|
|
431
|
+
|
|
432
|
+
for (const [, pos] of socketsToDraw) {
|
|
433
|
+
ctx.beginPath();
|
|
434
|
+
// HTML ::after is 12x12 (content) + 2px border = 16px total.
|
|
435
|
+
// Canvas r=7 (dia 14) + lineWidth 2 = 14+-1 = 12px inner fill, 16px total outer bound.
|
|
436
|
+
ctx.arc(pos.x, pos.y, 7, 0, Math.PI * 2);
|
|
437
|
+
ctx.fillStyle = pos.color;
|
|
438
|
+
ctx.fill();
|
|
439
|
+
ctx.lineWidth = 2; // Match the 2px solid CSS outline exactly
|
|
440
|
+
ctx.strokeStyle = this.#colorParams.outline; // Match var(--sn-port-outline) / var(--sn-node-bg)
|
|
441
|
+
ctx.stroke();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Draw phantom node dots (Obsidian-style) ───
|
|
445
|
+
this.#drawPhantomDots(ctx, zoom);
|
|
446
|
+
|
|
447
|
+
// Stop flow animation if none flowing to save CPU
|
|
448
|
+
if (!hasFlowing && this.#animationFrameId) {
|
|
449
|
+
cancelAnimationFrame(this.#animationFrameId);
|
|
450
|
+
this.#animationFrameId = null;
|
|
451
|
+
} else if (hasFlowing && !this.#animationFrameId) {
|
|
452
|
+
this.#animationFrameId = requestAnimationFrame(this.#renderLoop);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Draw phantom nodes as colored dots with size proportional to degree.
|
|
458
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
459
|
+
* @param {number} zoom
|
|
460
|
+
*/
|
|
461
|
+
#drawPhantomDots(ctx, zoom) {
|
|
462
|
+
if (this.#phantomNodes.length === 0) return;
|
|
463
|
+
|
|
464
|
+
ctx.shadowBlur = 0;
|
|
465
|
+
ctx.setLineDash([]);
|
|
466
|
+
|
|
467
|
+
// At very low zoom, scale dot size to maintain minimum screen visibility
|
|
468
|
+
// minScreen = 8px → in world coords = 8 / zoom
|
|
469
|
+
const minWorldW = Math.max(180, 8 / zoom);
|
|
470
|
+
const minWorldH = Math.max(60, 4 / zoom);
|
|
471
|
+
const showLabels = zoom > 0.15;
|
|
472
|
+
const labelFontSize = Math.max(9, Math.min(14, 12 / zoom));
|
|
473
|
+
|
|
474
|
+
for (const node of this.#phantomNodes) {
|
|
475
|
+
if (!node || node.w === undefined || node.h === undefined) continue;
|
|
476
|
+
|
|
477
|
+
const w = Math.max(minWorldW, node.w);
|
|
478
|
+
const h = Math.max(minWorldH, node.h);
|
|
479
|
+
// Center the enlarged dot on the original position
|
|
480
|
+
const x = (node.x || 0) - (w - node.w) / 2;
|
|
481
|
+
const y = (node.y || 0) - (h - node.h) / 2;
|
|
482
|
+
|
|
483
|
+
ctx.beginPath();
|
|
484
|
+
try {
|
|
485
|
+
const r = Math.min(6, w * 0.1, h * 0.1);
|
|
486
|
+
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
|
|
487
|
+
else ctx.rect(x, y, w, h);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
ctx.rect(x, y, w, h);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
ctx.fillStyle = node.color || this.#colorParams.normal;
|
|
493
|
+
ctx.globalAlpha = 0.85;
|
|
494
|
+
ctx.fill();
|
|
495
|
+
|
|
496
|
+
// Stroke — scale lineWidth for visibility
|
|
497
|
+
ctx.lineWidth = Math.max(1.5, 1 / zoom);
|
|
498
|
+
ctx.strokeStyle = this.#colorParams.outline;
|
|
499
|
+
ctx.stroke();
|
|
500
|
+
ctx.globalAlpha = 1.0;
|
|
501
|
+
|
|
502
|
+
// Label at medium+ zoom
|
|
503
|
+
if (showLabels && node.label) {
|
|
504
|
+
ctx.fillStyle = '#fff';
|
|
505
|
+
ctx.globalAlpha = 1;
|
|
506
|
+
ctx.font = `${labelFontSize}px sans-serif`;
|
|
507
|
+
ctx.textAlign = 'center';
|
|
508
|
+
ctx.textBaseline = 'middle';
|
|
509
|
+
|
|
510
|
+
ctx.save();
|
|
511
|
+
ctx.beginPath();
|
|
512
|
+
ctx.rect(x + 4, y, w - 8, h);
|
|
513
|
+
ctx.clip();
|
|
514
|
+
ctx.fillText(node.label, x + w / 2, y + h / 2);
|
|
515
|
+
ctx.restore();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Create a minimal proxy object for a phantom node so #plotPath can work.
|
|
522
|
+
* Mimics the shape of a DOM nodeView element with _position and _cachedW/H.
|
|
523
|
+
*/
|
|
524
|
+
#getPhantomProxy(nodeId) {
|
|
525
|
+
const phantom = this.#phantomMap.get(nodeId);
|
|
526
|
+
if (!phantom) return null;
|
|
527
|
+
return {
|
|
528
|
+
id: phantom.id,
|
|
529
|
+
_position: { x: phantom.x, y: phantom.y },
|
|
530
|
+
_cachedW: phantom.w,
|
|
531
|
+
_cachedH: phantom.h,
|
|
532
|
+
offsetWidth: phantom.w,
|
|
533
|
+
offsetHeight: phantom.h,
|
|
534
|
+
getAttribute: () => null,
|
|
535
|
+
querySelector: () => null,
|
|
536
|
+
querySelectorAll: () => [],
|
|
537
|
+
style: {},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#renderLoop = () => {
|
|
542
|
+
this.redraw();
|
|
543
|
+
this.#animationFrameId = requestAnimationFrame(this.#renderLoop);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
#plotPath(ctx, conn) {
|
|
547
|
+
let fromElNodeView = this.#nodeViews.get(conn.from);
|
|
548
|
+
let toElNodeView = this.#nodeViews.get(conn.to);
|
|
549
|
+
|
|
550
|
+
// Fallback to phantom proxy for nodes without DOM
|
|
551
|
+
if (!fromElNodeView) fromElNodeView = this.#getPhantomProxy(conn.from);
|
|
552
|
+
if (!toElNodeView) toElNodeView = this.#getPhantomProxy(conn.to);
|
|
553
|
+
if (!fromElNodeView || !toElNodeView) return;
|
|
554
|
+
|
|
555
|
+
let fromPos = fromElNodeView._position || { x: 0, y: 0 };
|
|
556
|
+
let toPos = toElNodeView._position || { x: 0, y: 0 };
|
|
557
|
+
|
|
558
|
+
let fromEl = fromElNodeView;
|
|
559
|
+
let toEl = toElNodeView;
|
|
560
|
+
|
|
561
|
+
if (this._nodeRectMap) {
|
|
562
|
+
const c1 = this._nodeRectMap.get(conn.from);
|
|
563
|
+
if (c1) { fromPos = { x: c1.x, y: c1.y }; if (c1.el) fromEl = c1.el; }
|
|
564
|
+
const c2 = this._nodeRectMap.get(conn.to);
|
|
565
|
+
if (c2) { toPos = { x: c2.x, y: c2.y }; if (c2.el) toEl = c2.el; }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const fromW = fromEl._cachedW || fromEl.offsetWidth || 180;
|
|
569
|
+
const fromH = fromEl._cachedH || fromEl.offsetHeight || 100;
|
|
570
|
+
const toW = toEl._cachedW || toEl.offsetWidth || 180;
|
|
571
|
+
const toH = toEl._cachedH || toEl.offsetHeight || 100;
|
|
572
|
+
|
|
573
|
+
const fromSize = { width: fromW, height: fromH };
|
|
574
|
+
const toSize = { width: toW, height: toH };
|
|
575
|
+
const fromNode = this.#editor?.getNode(conn.from);
|
|
576
|
+
const toNode = this.#editor?.getNode(conn.to);
|
|
577
|
+
const fromShape = getShape(fromNode?.shape);
|
|
578
|
+
const toShape = getShape(toNode?.shape);
|
|
579
|
+
|
|
580
|
+
const fromCenter = { x: fromPos.x + fromW / 2, y: fromPos.y + fromH / 2 };
|
|
581
|
+
const toCenter = { x: toPos.x + toW / 2, y: toPos.y + toH / 2 };
|
|
582
|
+
|
|
583
|
+
const fromOffset = this.getSocketOffset(fromEl, conn.out, 'output', toCenter);
|
|
584
|
+
const toOffset = this.getSocketOffset(toEl, conn.in, 'input', fromCenter);
|
|
585
|
+
|
|
586
|
+
const startX = fromPos.x + fromOffset.x;
|
|
587
|
+
const startY = fromPos.y + fromOffset.y;
|
|
588
|
+
const endX = toPos.x + toOffset.x;
|
|
589
|
+
const endY = toPos.y + toOffset.y;
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
let d;
|
|
593
|
+
let arrow = { x: endX, y: endY, angle: 0 };
|
|
594
|
+
const effectiveStyle = this.#pathStyle;
|
|
595
|
+
if (effectiveStyle === 'straight') {
|
|
596
|
+
d = `M ${startX} ${startY} L ${endX} ${endY}`;
|
|
597
|
+
arrow.x = (startX + endX) / 2;
|
|
598
|
+
arrow.y = (startY + endY) / 2;
|
|
599
|
+
arrow.angle = Math.atan2(endY - startY, endX - startX);
|
|
600
|
+
} else if (effectiveStyle === 'orthogonal') {
|
|
601
|
+
const connIndex = this._connIndexMap ? (this._connIndexMap.get(conn.id) ?? 0) : 0;
|
|
602
|
+
const traceOffset = (connIndex > -1 ? connIndex % 10 : 0) * 4;
|
|
603
|
+
|
|
604
|
+
const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
|
|
605
|
+
const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
|
|
606
|
+
|
|
607
|
+
const stubLen = 20;
|
|
608
|
+
const getDxDy = (deg) => ({
|
|
609
|
+
dx: Math.round(Math.cos(deg * Math.PI / 180)),
|
|
610
|
+
dy: Math.round(Math.sin(deg * Math.PI / 180))
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
const fDir = getDxDy(fromAngle);
|
|
614
|
+
const tDir = getDxDy(toAngle);
|
|
615
|
+
|
|
616
|
+
const p1x = startX + fDir.dx * stubLen;
|
|
617
|
+
const p1y = startY + fDir.dy * stubLen;
|
|
618
|
+
const p2x = endX + tDir.dx * stubLen;
|
|
619
|
+
const p2y = endY + tDir.dy * stubLen;
|
|
620
|
+
|
|
621
|
+
const fromH = fromEl._cachedH || 60;
|
|
622
|
+
const toH = toEl._cachedH || 60;
|
|
623
|
+
|
|
624
|
+
let pts = [{ x: startX, y: startY }, { x: p1x, y: p1y }];
|
|
625
|
+
const skipObstacles = this._nodeRectMap && this._nodeRectMap.size > 200;
|
|
626
|
+
|
|
627
|
+
if (endX < startX) {
|
|
628
|
+
const bottomY = Math.max(fromPos.y + fromH, toPos.y + toH) + 30 + traceOffset;
|
|
629
|
+
pts.push({ x: p1x, y: bottomY });
|
|
630
|
+
pts.push({ x: p2x, y: bottomY });
|
|
631
|
+
} else if (skipObstacles) {
|
|
632
|
+
// Large graph: simple mid-X routing without obstacle checks
|
|
633
|
+
const midX = (p1x + p2x) / 2 + traceOffset;
|
|
634
|
+
pts.push({ x: midX, y: p1y });
|
|
635
|
+
pts.push({ x: midX, y: p2y });
|
|
636
|
+
} else {
|
|
637
|
+
const maxH = Math.max(fromH, toH);
|
|
638
|
+
if (Math.abs(p1y - p2y) < maxH) {
|
|
639
|
+
let nodeBetween = false;
|
|
640
|
+
const obstacleIter = this._nodeRectMap ? this._nodeRectMap.values() : [];
|
|
641
|
+
for (const rect of obstacleIter) {
|
|
642
|
+
const nx = rect.x;
|
|
643
|
+
const ny = rect.y;
|
|
644
|
+
const nw = rect.w || 180;
|
|
645
|
+
const nh = rect.h || 60;
|
|
646
|
+
if (nx > p1x && nx + nw < p2x) {
|
|
647
|
+
if (Math.min(p1y, p2y) <= ny + nh && Math.max(p1y, p2y) >= ny) {
|
|
648
|
+
nodeBetween = true; break;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (nodeBetween) {
|
|
654
|
+
const detourY = Math.min(fromPos.y, toPos.y) - 30 - traceOffset;
|
|
655
|
+
pts.push({ x: p1x, y: detourY });
|
|
656
|
+
pts.push({ x: p2x, y: detourY });
|
|
657
|
+
} else {
|
|
658
|
+
const midX = (p1x + p2x) / 2 + traceOffset;
|
|
659
|
+
pts.push({ x: midX, y: p1y });
|
|
660
|
+
pts.push({ x: midX, y: p2y });
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
let midX = (p1x + p2x) / 2 + traceOffset;
|
|
664
|
+
let obstacleNode = null;
|
|
665
|
+
const minY = Math.min(p1y, p2y);
|
|
666
|
+
const maxY = Math.max(p1y, p2y);
|
|
667
|
+
|
|
668
|
+
const obstIter = this._nodeRectMap ? this._nodeRectMap.values() : [];
|
|
669
|
+
for (const rect of obstIter) {
|
|
670
|
+
const nx = rect.x;
|
|
671
|
+
const ny = rect.y;
|
|
672
|
+
const nw = rect.w || 180;
|
|
673
|
+
const nh = rect.h || 60;
|
|
674
|
+
if (midX >= nx && midX <= nx + nw) {
|
|
675
|
+
if (ny <= maxY && ny + nh >= minY) {
|
|
676
|
+
obstacleNode = { x: nx, w: nw };
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (obstacleNode) {
|
|
683
|
+
const leftDist = Math.abs(midX - obstacleNode.x);
|
|
684
|
+
const rightDist = Math.abs(midX - (obstacleNode.x + obstacleNode.w));
|
|
685
|
+
if (leftDist < rightDist) {
|
|
686
|
+
midX = obstacleNode.x - 30 - traceOffset;
|
|
687
|
+
} else {
|
|
688
|
+
midX = obstacleNode.x + obstacleNode.w + 30 + traceOffset;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
pts.push({ x: midX, y: p1y });
|
|
693
|
+
pts.push({ x: midX, y: p2y });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
pts.push({ x: p2x, y: p2y });
|
|
698
|
+
pts.push({ x: endX, y: endY });
|
|
699
|
+
|
|
700
|
+
let path = `M ${pts[0].x} ${pts[0].y}`;
|
|
701
|
+
for (let i = 1; i < pts.length; i++) {
|
|
702
|
+
const prev = pts[i - 1];
|
|
703
|
+
const curr = pts[i];
|
|
704
|
+
if (curr.x === prev.x && curr.y === prev.y) continue;
|
|
705
|
+
if (curr.x !== prev.x && curr.y !== prev.y) {
|
|
706
|
+
path += ` H ${curr.x} V ${curr.y}`;
|
|
707
|
+
} else if (curr.x !== prev.x) {
|
|
708
|
+
path += ` H ${curr.x}`;
|
|
709
|
+
} else if (curr.y !== prev.y) {
|
|
710
|
+
path += ` V ${curr.y}`;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (pts.length >= 2) {
|
|
714
|
+
const midIndex = Math.floor(pts.length / 2);
|
|
715
|
+
const p1 = pts[midIndex - 1];
|
|
716
|
+
const p2 = pts[midIndex];
|
|
717
|
+
if (p1 && p2) {
|
|
718
|
+
arrow.x = (p1.x + p2.x) / 2;
|
|
719
|
+
arrow.y = (p1.y + p2.y) / 2;
|
|
720
|
+
arrow.angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
d = path;
|
|
724
|
+
} else if (effectiveStyle === 'pcb') {
|
|
725
|
+
// ─── PCB Grid-Based Trace Routing ───
|
|
726
|
+
// All waypoints snap to a grid. Stubs exit perpendicular to node surface
|
|
727
|
+
// with a minimum length, then route on grid channels with chamfered corners.
|
|
728
|
+
|
|
729
|
+
const TRACE_GRID = 5; // Dense trace grid (5px)
|
|
730
|
+
const STUB_MIN = 20; // minimum perpendicular stub from node edge
|
|
731
|
+
const CHAMFER = 8; // 45° chamfer radius (px)
|
|
732
|
+
|
|
733
|
+
// Snap a coordinate to the trace grid
|
|
734
|
+
const snapGrid = (v) => Math.round(v / TRACE_GRID) * TRACE_GRID;
|
|
735
|
+
|
|
736
|
+
// Connection channel index for parallel trace separation
|
|
737
|
+
const connIndex = this._connIndexMap ? (this._connIndexMap.get(conn.id) ?? 0) : 0;
|
|
738
|
+
|
|
739
|
+
// Determine unique channel shift to prevent parallel traces overlapping
|
|
740
|
+
// Alternates: 0, +5, -5, +10, -10...
|
|
741
|
+
const shiftIndex = (connIndex > -1 ? connIndex % 12 : 0);
|
|
742
|
+
const channelShift = (shiftIndex % 2 === 0 ? 1 : -1) * Math.ceil(shiftIndex / 2) * TRACE_GRID;
|
|
743
|
+
|
|
744
|
+
// Compute perpendicular stub directions from surface normals
|
|
745
|
+
const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
|
|
746
|
+
const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
|
|
747
|
+
|
|
748
|
+
// Snap angle to cardinal direction (→ ↓ ← ↑)
|
|
749
|
+
const snapDir = (deg) => {
|
|
750
|
+
const r = ((deg % 360) + 360) % 360;
|
|
751
|
+
if (r < 45 || r >= 315) return { dx: 1, dy: 0 }; // right
|
|
752
|
+
if (r >= 45 && r < 135) return { dx: 0, dy: 1 }; // down
|
|
753
|
+
if (r >= 135 && r < 225) return { dx: -1, dy: 0 }; // left
|
|
754
|
+
return { dx: 0, dy: -1 }; // up
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const fDir = snapDir(fromAngle);
|
|
758
|
+
const tDir = snapDir(toAngle);
|
|
759
|
+
|
|
760
|
+
// Stub endpoints: extend strictly perpedicular, no grid snapping on the orthogonal axis
|
|
761
|
+
// to avoid diagonal stubs from pins that are floating (not grid aligned).
|
|
762
|
+
const stubFromX = fDir.dx === 0 ? startX : startX + fDir.dx * STUB_MIN;
|
|
763
|
+
const stubFromY = fDir.dy === 0 ? startY : startY + fDir.dy * STUB_MIN;
|
|
764
|
+
const stubToX = tDir.dx === 0 ? endX : endX + tDir.dx * STUB_MIN;
|
|
765
|
+
const stubToY = tDir.dy === 0 ? endY : endY + tDir.dy * STUB_MIN;
|
|
766
|
+
|
|
767
|
+
const fromH = fromEl.offsetHeight || 60;
|
|
768
|
+
const toH = toEl.offsetHeight || 60;
|
|
769
|
+
|
|
770
|
+
// Build orthogonal waypoints on grid
|
|
771
|
+
let pts = [
|
|
772
|
+
{ x: startX, y: startY },
|
|
773
|
+
{ x: stubFromX, y: stubFromY },
|
|
774
|
+
];
|
|
775
|
+
// Skip obstacle avoidance on large graphs — O(N) per connection is too expensive
|
|
776
|
+
// and produces worse visual results at high density anyway
|
|
777
|
+
const skipObstacles = this._nodeRectMap && this._nodeRectMap.size > 200;
|
|
778
|
+
|
|
779
|
+
// Very simple heuristic orthogonal router
|
|
780
|
+
if (endX < startX - 20) {
|
|
781
|
+
// Backwards routing: U-turn below obstacles in the path
|
|
782
|
+
let maxObstacleY = Math.max(fromPos.y + fromH, toPos.y + toH);
|
|
783
|
+
|
|
784
|
+
if (!skipObstacles) {
|
|
785
|
+
const minXForObstacle = Math.min(stubFromX, stubToX);
|
|
786
|
+
const maxXForObstacle = Math.max(stubFromX, stubToX);
|
|
787
|
+
const iter = this._nodeRectMap ? this._nodeRectMap.values() : [];
|
|
788
|
+
for (const rect of iter) {
|
|
789
|
+
const nx = rect.x;
|
|
790
|
+
const ny = rect.y;
|
|
791
|
+
const nw = rect.w;
|
|
792
|
+
const nh = rect.h;
|
|
793
|
+
const pad = TRACE_GRID * 2;
|
|
794
|
+
if (nx + nw + pad >= minXForObstacle && nx - pad <= maxXForObstacle) {
|
|
795
|
+
if (ny + nh > maxObstacleY) {
|
|
796
|
+
maxObstacleY = ny + nh;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const bottomY = snapGrid(maxObstacleY + 30) + Math.abs(channelShift);
|
|
803
|
+
pts.push({ x: stubFromX, y: bottomY });
|
|
804
|
+
pts.push({ x: stubToX, y: bottomY });
|
|
805
|
+
} else {
|
|
806
|
+
// Forward routing: mid-X channel
|
|
807
|
+
let midX = snapGrid((stubFromX + stubToX) / 2) + channelShift;
|
|
808
|
+
|
|
809
|
+
// Same-height shortcut
|
|
810
|
+
if (Math.abs(stubFromY - stubToY) < TRACE_GRID * 2) {
|
|
811
|
+
pts.push({ x: stubToX, y: stubFromY });
|
|
812
|
+
} else {
|
|
813
|
+
if (!skipObstacles) {
|
|
814
|
+
// Obstacle check for mid-X vertical segment
|
|
815
|
+
const minY = Math.min(stubFromY, stubToY);
|
|
816
|
+
const maxY = Math.max(stubFromY, stubToY);
|
|
817
|
+
const pad = TRACE_GRID * 4;
|
|
818
|
+
|
|
819
|
+
const iter = this._nodeRectMap ? this._nodeRectMap.values() : [];
|
|
820
|
+
for (const rect of iter) {
|
|
821
|
+
if (rect.id === conn.from || rect.id === conn.to) continue;
|
|
822
|
+
const nx = rect.x, ny = rect.y;
|
|
823
|
+
const nw = rect.w, nh = rect.h;
|
|
824
|
+
|
|
825
|
+
if (midX >= nx - pad && midX <= nx + nw + pad) {
|
|
826
|
+
if (ny - pad <= maxY && ny + nh + pad >= minY) {
|
|
827
|
+
const leftX = snapGrid(nx - pad) + channelShift;
|
|
828
|
+
const rightX = snapGrid(nx + nw + pad) + channelShift;
|
|
829
|
+
midX = Math.abs(midX - leftX) < Math.abs(midX - rightX) ? leftX : rightX;
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
pts.push({ x: midX, y: stubFromY });
|
|
837
|
+
pts.push({ x: midX, y: stubToY });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
pts.push({ x: stubToX, y: stubToY });
|
|
842
|
+
pts.push({ x: endX, y: endY });
|
|
843
|
+
|
|
844
|
+
// Path building and Chamfering
|
|
845
|
+
|
|
846
|
+
// Log route stats (debug only)
|
|
847
|
+
if (CanvasConnectionRenderer.debug) {
|
|
848
|
+
const fromLabel = fromEl._nodeData?.label || conn.from;
|
|
849
|
+
const toLabel = toEl._nodeData?.label || conn.to;
|
|
850
|
+
console.log(`[PCB] ${fromLabel} → ${toLabel} | waypoints=${pts.length}`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Build SVG path with 45° chamfered corners
|
|
854
|
+
let path = `M ${pts[0].x} ${pts[0].y}`;
|
|
855
|
+
for (let i = 1; i < pts.length; i++) {
|
|
856
|
+
const prev = pts[i - 1];
|
|
857
|
+
const curr = pts[i];
|
|
858
|
+
if (Math.abs(curr.x - prev.x) < 0.5 && Math.abs(curr.y - prev.y) < 0.5) continue;
|
|
859
|
+
|
|
860
|
+
const next = pts[i + 1];
|
|
861
|
+
if (next) {
|
|
862
|
+
// Determine if there's a turn at curr → need chamfer
|
|
863
|
+
const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
|
|
864
|
+
const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
|
|
865
|
+
const isH1 = Math.abs(dx1) > Math.abs(dy1);
|
|
866
|
+
const isH2 = Math.abs(dx2) > Math.abs(dy2);
|
|
867
|
+
|
|
868
|
+
if (isH1 !== isH2) {
|
|
869
|
+
// Corner turn — apply 45° chamfer
|
|
870
|
+
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
|
871
|
+
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
|
872
|
+
if (len1 < 1 || len2 < 1) {
|
|
873
|
+
// Degenerate segment — skip chamfer, go straight
|
|
874
|
+
path += ` L ${curr.x} ${curr.y}`;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const c = Math.min(CHAMFER, len1 / 2, len2 / 2);
|
|
878
|
+
|
|
879
|
+
// Pre-corner point
|
|
880
|
+
const nx1 = dx1 / len1, ny1 = dy1 / len1;
|
|
881
|
+
const preX = curr.x - nx1 * c;
|
|
882
|
+
const preY = curr.y - ny1 * c;
|
|
883
|
+
// Post-corner point
|
|
884
|
+
const nx2 = dx2 / len2, ny2 = dy2 / len2;
|
|
885
|
+
const postX = curr.x + nx2 * c;
|
|
886
|
+
const postY = curr.y + ny2 * c;
|
|
887
|
+
|
|
888
|
+
path += ` L ${preX} ${preY} L ${postX} ${postY}`;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Straight segment — use H/V for axis-aligned, L for diagonal stubs
|
|
894
|
+
if (Math.abs(curr.y - prev.y) < 0.5) {
|
|
895
|
+
path += ` H ${curr.x}`;
|
|
896
|
+
} else if (Math.abs(curr.x - prev.x) < 0.5) {
|
|
897
|
+
path += ` V ${curr.y}`;
|
|
898
|
+
} else {
|
|
899
|
+
path += ` L ${curr.x} ${curr.y}`;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (pts.length >= 2) {
|
|
903
|
+
const midIndex = Math.floor(pts.length / 2);
|
|
904
|
+
const p1 = pts[midIndex - 1];
|
|
905
|
+
const p2 = pts[midIndex];
|
|
906
|
+
if (p1 && p2) {
|
|
907
|
+
arrow.x = (p1.x + p2.x) / 2;
|
|
908
|
+
arrow.y = (p1.y + p2.y) / 2;
|
|
909
|
+
arrow.angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
d = path;
|
|
913
|
+
} else {
|
|
914
|
+
// Tangent direction: use dynamic edge angle if available, else fixed socket angle
|
|
915
|
+
let fromAngleDeg, toAngleDeg;
|
|
916
|
+
|
|
917
|
+
if (fromOffset.angle !== undefined) {
|
|
918
|
+
fromAngleDeg = fromOffset.angle;
|
|
919
|
+
} else {
|
|
920
|
+
const fromPortIndex = fromNode ? Object.keys(fromNode.outputs).indexOf(conn.out) : 0;
|
|
921
|
+
const fromPortTotal = fromNode ? Object.keys(fromNode.outputs).length : 1;
|
|
922
|
+
const pos = fromShape?.getSocketPosition?.('output', fromPortIndex, fromPortTotal, fromSize);
|
|
923
|
+
fromAngleDeg = pos?.angle ?? 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (toOffset.angle !== undefined) {
|
|
927
|
+
toAngleDeg = toOffset.angle;
|
|
928
|
+
} else {
|
|
929
|
+
const toPortIndex = toNode ? Object.keys(toNode.inputs).indexOf(conn.in) : 0;
|
|
930
|
+
const toPortTotal = toNode ? Object.keys(toNode.inputs).length : 1;
|
|
931
|
+
const pos = toShape?.getSocketPosition?.('input', toPortIndex, toPortTotal, toSize);
|
|
932
|
+
toAngleDeg = pos?.angle ?? 180;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
|
936
|
+
const cpLen = Math.max(50, dist * 0.4);
|
|
937
|
+
const fromRad = (fromAngleDeg * Math.PI) / 180;
|
|
938
|
+
const toRad = (toAngleDeg * Math.PI) / 180;
|
|
939
|
+
|
|
940
|
+
const cp1x = startX + Math.cos(fromRad) * cpLen;
|
|
941
|
+
const cp1y = startY + Math.sin(fromRad) * cpLen;
|
|
942
|
+
const cp2x = endX + Math.cos(toRad) * cpLen;
|
|
943
|
+
const cp2y = endY + Math.sin(toRad) * cpLen;
|
|
944
|
+
|
|
945
|
+
d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`;
|
|
946
|
+
|
|
947
|
+
arrow.x = (startX + 3 * cp1x + 3 * cp2x + endX) / 8;
|
|
948
|
+
arrow.y = (startY + 3 * cp1y + 3 * cp2y + endY) / 8;
|
|
949
|
+
arrow.angle = Math.atan2(endY + cp2y - cp1y - startY, endX + cp2x - cp1x - startX);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
const p = new Path2D(d);
|
|
954
|
+
return { startX, startY, endX, endY, path2D: p, arrow, pathStyle: effectiveStyle };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
destroy() {
|
|
958
|
+
if (this.#resizeObserver) this.#resizeObserver.disconnect();
|
|
959
|
+
if (this.#animationFrameId) cancelAnimationFrame(this.#animationFrameId);
|
|
960
|
+
this.#connectionData.clear();
|
|
961
|
+
}
|
|
962
|
+
}
|