project-graph-mcp 2.3.2 → 2.4.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/package.json +3 -2
- package/src/analysis/analysis-cache.ctx +9 -0
- package/src/analysis/analysis-cache.js +1 -1
- package/src/analysis/complexity.ctx +6 -0
- package/src/analysis/complexity.js +1 -1
- package/src/analysis/custom-rules.ctx +14 -0
- package/src/analysis/custom-rules.js +1 -1
- package/src/analysis/db-analysis.ctx +7 -0
- package/src/analysis/db-analysis.js +1 -1
- package/src/analysis/dead-code.ctx +6 -0
- package/src/analysis/dead-code.js +1 -1
- package/src/analysis/full-analysis.ctx +9 -0
- package/src/analysis/full-analysis.js +1 -1
- package/src/analysis/jsdoc-checker.ctx +10 -0
- package/src/analysis/jsdoc-checker.js +1 -1
- package/src/analysis/jsdoc-generator.ctx +9 -0
- package/src/analysis/jsdoc-generator.js +1 -1
- package/src/analysis/large-files.ctx +6 -0
- package/src/analysis/large-files.js +1 -1
- package/src/analysis/outdated-patterns.ctx +7 -0
- package/src/analysis/outdated-patterns.js +1 -1
- package/src/analysis/similar-functions.ctx +6 -0
- package/src/analysis/similar-functions.js +1 -1
- package/src/analysis/test-annotations.ctx +11 -0
- package/src/analysis/test-annotations.js +1 -1
- package/src/analysis/type-checker.ctx +6 -0
- package/src/analysis/type-checker.js +1 -1
- package/src/analysis/undocumented.ctx +8 -0
- package/src/analysis/undocumented.js +1 -1
- package/src/cli/cli-handlers.ctx +7 -0
- package/src/cli/cli-handlers.js +1 -1
- package/src/cli/cli.ctx +6 -0
- package/src/cli/cli.js +1 -1
- package/src/compact/ai-context.ctx +6 -0
- package/src/compact/ai-context.js +1 -1
- package/src/compact/compact-migrate.ctx +8 -0
- package/src/compact/compact-migrate.js +1 -1
- package/src/compact/compact.ctx +11 -0
- package/src/compact/compact.js +1 -1
- package/src/compact/compress.ctx +7 -0
- package/src/compact/compress.js +1 -1
- package/src/compact/ctx-resolver.ctx +2 -0
- package/src/compact/ctx-resolver.js +1 -1
- package/src/compact/ctx-to-jsdoc.ctx +11 -0
- package/src/compact/ctx-to-jsdoc.js +1 -1
- package/src/compact/doc-dialect.ctx +11 -0
- package/src/compact/doc-dialect.js +2 -2
- package/src/compact/expand.ctx +14 -0
- package/src/compact/expand.js +1 -1
- package/src/compact/framework-references.ctx +7 -0
- package/src/compact/framework-references.js +1 -1
- package/src/compact/instructions.ctx +6 -0
- package/src/compact/instructions.js +1 -1
- package/src/compact/jsdoc-builder.ctx +4 -0
- package/src/compact/jsdoc-builder.js +1 -1
- package/src/compact/mode-config.ctx +8 -0
- package/src/compact/mode-config.js +1 -1
- package/src/compact/split-declarations.ctx +6 -0
- package/src/compact/split-declarations.js +1 -1
- package/src/compact/validate-pipeline.ctx +12 -0
- package/src/compact/validate-pipeline.js +1 -1
- package/src/core/event-bus.ctx +9 -0
- package/src/core/event-bus.js +1 -1
- package/src/core/file-walker.ctx +1 -0
- package/src/core/file-walker.js +1 -1
- package/src/core/filters.ctx +12 -0
- package/src/core/filters.js +1 -1
- package/src/core/graph-builder.ctx +7 -0
- package/src/core/graph-builder.js +1 -1
- package/src/core/parser.ctx +12 -0
- package/src/core/parser.js +1 -1
- package/src/core/utils.ctx +1 -0
- package/src/core/utils.js +1 -1
- package/src/core/workspace.ctx +7 -0
- package/src/core/workspace.js +1 -1
- package/src/lang/lang-go.ctx +8 -0
- package/src/lang/lang-go.js +1 -1
- package/src/lang/lang-python.ctx +5 -0
- package/src/lang/lang-python.js +1 -1
- package/src/lang/lang-sql.ctx +10 -0
- package/src/lang/lang-sql.js +1 -1
- package/src/lang/lang-typescript.ctx +6 -0
- package/src/lang/lang-typescript.js +1 -1
- package/src/lang/lang-utils.ctx +5 -0
- package/src/lang/lang-utils.js +1 -1
- package/src/mcp/mcp-server.ctx +6 -0
- package/src/mcp/mcp-server.js +1 -1
- package/src/mcp/tool-defs.ctx +2 -0
- package/src/mcp/tool-defs.js +1 -1
- package/src/mcp/tools.ctx +13 -0
- package/src/mcp/tools.js +1 -1
- package/src/network/backend-lifecycle.ctx +10 -0
- package/src/network/backend-lifecycle.js +1 -1
- package/src/network/backend.ctx +5 -0
- package/src/network/backend.js +1 -1
- package/src/network/local-gateway.ctx +9 -0
- package/src/network/local-gateway.js +1 -1
- package/src/network/mdns.ctx +6 -0
- package/src/network/mdns.js +1 -1
- package/src/network/server.ctx +2 -0
- package/src/network/server.js +2 -2
- package/src/network/web-server.ctx +17 -0
- package/src/network/web-server.js +2 -2
- package/web/follow-controller.js +94 -25
- package/web/panels/dep-graph.js +207 -21
- package/project-graph-mcp-2.3.0.tgz +0 -0
- package/vendor/symbiote-node/CHANGELOG.md +0 -31
- package/vendor/symbiote-node/LICENSE +0 -21
- package/vendor/symbiote-node/README.md +0 -206
- package/vendor/symbiote-node/canvas/AutoLayout.js +0 -725
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +0 -73
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +0 -93
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +0 -9
- package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +0 -962
- package/vendor/symbiote-node/canvas/ConnectionRenderer.js +0 -1468
- package/vendor/symbiote-node/canvas/FlowSimulator.js +0 -323
- package/vendor/symbiote-node/canvas/ForceLayout.js +0 -189
- package/vendor/symbiote-node/canvas/ForceWorker.js +0 -1325
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +0 -97
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +0 -176
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +0 -12
- package/vendor/symbiote-node/canvas/LODManager.js +0 -88
- package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +0 -71
- package/vendor/symbiote-node/canvas/Minimap/Minimap.js +0 -207
- package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +0 -9
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +0 -261
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +0 -1840
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +0 -22
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +0 -97
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +0 -132
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +0 -21
- package/vendor/symbiote-node/canvas/NodeViewManager.js +0 -584
- package/vendor/symbiote-node/canvas/PinExpansion.js +0 -131
- package/vendor/symbiote-node/canvas/PseudoConnection.js +0 -80
- package/vendor/symbiote-node/canvas/SubgraphManager.js +0 -201
- package/vendor/symbiote-node/canvas/SubgraphRouter.js +0 -443
- package/vendor/symbiote-node/canvas/ViewportActions.js +0 -446
- package/vendor/symbiote-node/core/Connection.js +0 -45
- package/vendor/symbiote-node/core/Editor.js +0 -451
- package/vendor/symbiote-node/core/Frame.js +0 -31
- package/vendor/symbiote-node/core/GraphMermaid.js +0 -348
- package/vendor/symbiote-node/core/GraphText.js +0 -210
- package/vendor/symbiote-node/core/Node.js +0 -143
- package/vendor/symbiote-node/core/Portal.js +0 -104
- package/vendor/symbiote-node/core/Socket.js +0 -185
- package/vendor/symbiote-node/core/SubgraphNode.js +0 -125
- package/vendor/symbiote-node/engine/AgentUICommands.js +0 -100
- package/vendor/symbiote-node/engine/Executor.js +0 -371
- package/vendor/symbiote-node/engine/Graph.js +0 -314
- package/vendor/symbiote-node/engine/GraphServer.js +0 -353
- package/vendor/symbiote-node/engine/HandlerLoader.js +0 -145
- package/vendor/symbiote-node/engine/History.js +0 -83
- package/vendor/symbiote-node/engine/Lifecycle.js +0 -118
- package/vendor/symbiote-node/engine/Persistence.js +0 -84
- package/vendor/symbiote-node/engine/Registry.js +0 -264
- package/vendor/symbiote-node/engine/SocketTypes.js +0 -79
- package/vendor/symbiote-node/engine/cli.js +0 -404
- package/vendor/symbiote-node/engine/index.js +0 -56
- package/vendor/symbiote-node/engine/nanoid.js +0 -28
- package/vendor/symbiote-node/engine/package.json +0 -26
- package/vendor/symbiote-node/engine/packs/ai/beat-detect.handler.js +0 -215
- package/vendor/symbiote-node/engine/packs/ai/content-adapt.handler.js +0 -238
- package/vendor/symbiote-node/engine/packs/ai/face-detect.handler.js +0 -287
- package/vendor/symbiote-node/engine/packs/ai/grok-generate.handler.js +0 -565
- package/vendor/symbiote-node/engine/packs/ai/kling-lipsync.handler.js +0 -414
- package/vendor/symbiote-node/engine/packs/ai/lesson-generate.handler.js +0 -343
- package/vendor/symbiote-node/engine/packs/ai/opencode.handler.js +0 -164
- package/vendor/symbiote-node/engine/packs/ai/replicate-lipsync.handler.js +0 -341
- package/vendor/symbiote-node/engine/packs/ai/tts.handler.js +0 -241
- package/vendor/symbiote-node/engine/packs/ai/whisper.handler.js +0 -191
- package/vendor/symbiote-node/engine/packs/data/db-query.handler.js +0 -67
- package/vendor/symbiote-node/engine/packs/data/news-accumulate.handler.js +0 -281
- package/vendor/symbiote-node/engine/packs/data/personas.handler.js +0 -160
- package/vendor/symbiote-node/engine/packs/data/prompt-loader.handler.js +0 -193
- package/vendor/symbiote-node/engine/packs/data/roles.handler.js +0 -216
- package/vendor/symbiote-node/engine/packs/data/rss-feed.handler.js +0 -244
- package/vendor/symbiote-node/engine/packs/debug/inject.handler.js +0 -52
- package/vendor/symbiote-node/engine/packs/flow/agent.handler.js +0 -73
- package/vendor/symbiote-node/engine/packs/flow/if.handler.js +0 -107
- package/vendor/symbiote-node/engine/packs/flow/loop.handler.js +0 -58
- package/vendor/symbiote-node/engine/packs/flow/merge.handler.js +0 -60
- package/vendor/symbiote-node/engine/packs/flow/retry.handler.js +0 -65
- package/vendor/symbiote-node/engine/packs/flow/switch.handler.js +0 -64
- package/vendor/symbiote-node/engine/packs/flow/wait-all.handler.js +0 -39
- package/vendor/symbiote-node/engine/packs/io/http-request.handler.js +0 -82
- package/vendor/symbiote-node/engine/packs/io/read-file.handler.js +0 -60
- package/vendor/symbiote-node/engine/packs/io/write-file.handler.js +0 -63
- package/vendor/symbiote-node/engine/packs/transform/anchor-match.handler.js +0 -494
- package/vendor/symbiote-node/engine/packs/transform/effects-skeleton.handler.js +0 -417
- package/vendor/symbiote-node/engine/packs/transform/json-parse.handler.js +0 -43
- package/vendor/symbiote-node/engine/packs/transform/lipsync-select.handler.js +0 -339
- package/vendor/symbiote-node/engine/packs/transform/riopla-adapt.handler.js +0 -432
- package/vendor/symbiote-node/engine/packs/transform/set.handler.js +0 -57
- package/vendor/symbiote-node/engine/packs/transform/template-builder.handler.js +0 -134
- package/vendor/symbiote-node/engine/packs/transform/template.handler.js +0 -79
- package/vendor/symbiote-node/engine/packs/transform/timeline-build.handler.js +0 -399
- package/vendor/symbiote-node/engine/packs/util/delay.handler.js +0 -39
- package/vendor/symbiote-node/engine/packs/util/log.handler.js +0 -44
- package/vendor/symbiote-node/engine/packs/video-pack.js +0 -323
- package/vendor/symbiote-node/index.js +0 -103
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +0 -361
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +0 -332
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +0 -96
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +0 -104
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +0 -133
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +0 -33
- package/vendor/symbiote-node/interactions/ConnectFlow.js +0 -307
- package/vendor/symbiote-node/interactions/Drag.js +0 -102
- package/vendor/symbiote-node/interactions/Selector.js +0 -132
- package/vendor/symbiote-node/interactions/SnapGrid.js +0 -65
- package/vendor/symbiote-node/interactions/Zoom.js +0 -140
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +0 -88
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +0 -254
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +0 -11
- package/vendor/symbiote-node/layout/Layout/Layout.css.js +0 -88
- package/vendor/symbiote-node/layout/Layout/Layout.js +0 -622
- package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +0 -25
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +0 -293
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +0 -467
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +0 -33
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +0 -46
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +0 -102
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +0 -6
- package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +0 -156
- package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +0 -250
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +0 -379
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +0 -263
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +0 -20
- package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +0 -183
- package/vendor/symbiote-node/layout/LayoutTree.js +0 -246
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +0 -43
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +0 -89
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +0 -14
- package/vendor/symbiote-node/layout/index.js +0 -16
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +0 -61
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +0 -79
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +0 -19
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +0 -41
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +0 -24
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +0 -16
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +0 -65
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +0 -29
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +0 -13
- package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +0 -683
- package/vendor/symbiote-node/node/GraphNode/GraphNode.js +0 -92
- package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +0 -17
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +0 -25
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +0 -7
- package/vendor/symbiote-node/node/PortItem/PortItem.css.js +0 -90
- package/vendor/symbiote-node/node/PortItem/PortItem.js +0 -87
- package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +0 -10
- package/vendor/symbiote-node/package.json +0 -59
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +0 -143
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +0 -131
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +0 -16
- package/vendor/symbiote-node/plugins/History.js +0 -384
- package/vendor/symbiote-node/plugins/Readonly.js +0 -59
- package/vendor/symbiote-node/shapes/CircleShape.js +0 -80
- package/vendor/symbiote-node/shapes/CommentShape.js +0 -35
- package/vendor/symbiote-node/shapes/DiamondShape.js +0 -115
- package/vendor/symbiote-node/shapes/NodeShape.js +0 -80
- package/vendor/symbiote-node/shapes/PillShape.js +0 -91
- package/vendor/symbiote-node/shapes/RectShape.js +0 -72
- package/vendor/symbiote-node/shapes/SVGShape.js +0 -494
- package/vendor/symbiote-node/shapes/index.js +0 -53
- package/vendor/symbiote-node/themes/Palette.js +0 -32
- package/vendor/symbiote-node/themes/Skin.js +0 -113
- package/vendor/symbiote-node/themes/Theme.js +0 -84
- package/vendor/symbiote-node/themes/carbon.js +0 -137
- package/vendor/symbiote-node/themes/dark.js +0 -137
- package/vendor/symbiote-node/themes/ebook.js +0 -138
- package/vendor/symbiote-node/themes/grey.js +0 -137
- package/vendor/symbiote-node/themes/light.js +0 -137
- package/vendor/symbiote-node/themes/neon.js +0 -138
- package/vendor/symbiote-node/themes/pcb.js +0 -273
- package/vendor/symbiote-node/themes/synthwave.js +0 -137
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +0 -86
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +0 -128
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +0 -29
|
@@ -1,1468 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ConnectionRenderer — SVG connection path manager
|
|
3
|
-
*
|
|
4
|
-
* Handles rendering Bézier curves between sockets,
|
|
5
|
-
* gradient coloring, flow animation, socket offset calculation.
|
|
6
|
-
* Extracted from NodeCanvas to reduce complexity.
|
|
7
|
-
*
|
|
8
|
-
* @module symbiote-node/canvas/ConnectionRenderer
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { getShape } from '../shapes/index.js';
|
|
12
|
-
|
|
13
|
-
export class ConnectionRenderer {
|
|
14
|
-
|
|
15
|
-
/** @type {Map<string, import('../core/Connection.js').Connection>} */
|
|
16
|
-
#connectionData = new Map();
|
|
17
|
-
|
|
18
|
-
/** @type {SVGElement} */
|
|
19
|
-
#svgLayer;
|
|
20
|
-
|
|
21
|
-
/** @type {SVGElement} - overlay layer for dots (z-index above nodes) */
|
|
22
|
-
#dotLayer;
|
|
23
|
-
|
|
24
|
-
/** @type {Map<string, HTMLElement>} */
|
|
25
|
-
#nodeViews;
|
|
26
|
-
|
|
27
|
-
/** @type {import('../core/Editor.js').NodeEditor} */
|
|
28
|
-
#editor;
|
|
29
|
-
|
|
30
|
-
/** @type {function} */
|
|
31
|
-
#onConnectionClick;
|
|
32
|
-
|
|
33
|
-
/** @type {function} */
|
|
34
|
-
#getZoom;
|
|
35
|
-
|
|
36
|
-
/** @type {function|null} - callback when dot is dragged: (socketData) => void */
|
|
37
|
-
#onDotDrag = null;
|
|
38
|
-
|
|
39
|
-
/** @type {'bezier'|'orthogonal'|'straight'|'pcb'} */
|
|
40
|
-
#pathStyle = 'bezier';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @param {object} config
|
|
44
|
-
* @param {SVGElement} config.svgLayer
|
|
45
|
-
* @param {Map<string, HTMLElement>} config.nodeViews
|
|
46
|
-
* @param {import('../core/Editor.js').NodeEditor} config.editor
|
|
47
|
-
* @param {function} config.onConnectionClick - (connId, event)
|
|
48
|
-
* @param {function} config.getZoom - Returns current zoom level
|
|
49
|
-
*/
|
|
50
|
-
constructor({ svgLayer, dotLayer, nodeViews, editor, onConnectionClick, getZoom, onDotDrag }) {
|
|
51
|
-
this.#svgLayer = svgLayer;
|
|
52
|
-
this.#dotLayer = dotLayer || svgLayer;
|
|
53
|
-
this.#nodeViews = nodeViews;
|
|
54
|
-
this.#editor = editor;
|
|
55
|
-
this.#onConnectionClick = onConnectionClick;
|
|
56
|
-
this.#getZoom = getZoom || (() => 1);
|
|
57
|
-
this.#onDotDrag = onDotDrag || null;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** @returns {Map<string, import('../core/Connection.js').Connection>} */
|
|
61
|
-
get data() {
|
|
62
|
-
return this.#connectionData;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Add a connection and render its SVG path
|
|
67
|
-
* @param {import('../core/Connection.js').Connection} conn
|
|
68
|
-
*/
|
|
69
|
-
add(conn) {
|
|
70
|
-
this.#connectionData.set(conn.id, conn);
|
|
71
|
-
// Remove free dots for now-connected ports
|
|
72
|
-
this.removeFreeDot(conn.from, conn.out, 'output');
|
|
73
|
-
this.removeFreeDot(conn.to, conn.in, 'input');
|
|
74
|
-
// Full re-render for affected nodes (slot pool needs full context)
|
|
75
|
-
this.#fullRerenderForNodes(new Set([conn.from, conn.to]));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Bulk add connections and render them in a single batch.
|
|
80
|
-
* Greatly improves performance when inflating large graphs.
|
|
81
|
-
* @param {import('../core/Connection.js').Connection[]} conns
|
|
82
|
-
*/
|
|
83
|
-
addBatch(conns) {
|
|
84
|
-
if (!conns || conns.length === 0) return;
|
|
85
|
-
for (const conn of conns) {
|
|
86
|
-
this.#connectionData.set(conn.id, conn);
|
|
87
|
-
}
|
|
88
|
-
this.refreshAll();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Clear slot registries and caches for all known nodes
|
|
93
|
-
*/
|
|
94
|
-
#clearAllSlots() {
|
|
95
|
-
for (const [, el] of this.#nodeViews) {
|
|
96
|
-
el._usedCoords = [];
|
|
97
|
-
el._slotCache = new Map();
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Full re-render: clear all slots for affected nodes, recalculate everything
|
|
103
|
-
* @param {Set<string>} nodeIds
|
|
104
|
-
*/
|
|
105
|
-
#fullRerenderForNodes(nodeIds) {
|
|
106
|
-
const allNodes = new Set(nodeIds);
|
|
107
|
-
const conns = [];
|
|
108
|
-
for (const [, conn] of this.#connectionData) {
|
|
109
|
-
if (nodeIds.has(conn.from) || nodeIds.has(conn.to)) {
|
|
110
|
-
allNodes.add(conn.from);
|
|
111
|
-
allNodes.add(conn.to);
|
|
112
|
-
conns.push(conn);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
for (const nid of allNodes) {
|
|
116
|
-
const el = this.#nodeViews.get(nid);
|
|
117
|
-
if (el) {
|
|
118
|
-
el._usedCoords = [];
|
|
119
|
-
el._slotCache = new Map();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
for (const conn of conns) {
|
|
123
|
-
this.#render(conn);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Remove a connection path
|
|
129
|
-
* @param {import('../core/Connection.js').Connection} conn
|
|
130
|
-
*/
|
|
131
|
-
remove(conn) {
|
|
132
|
-
const fromId = conn.from;
|
|
133
|
-
const toId = conn.to;
|
|
134
|
-
this.#connectionData.delete(conn.id);
|
|
135
|
-
const path = this.#svgLayer.querySelector(`[data-conn-id="${conn.id}"]`);
|
|
136
|
-
if (path) {
|
|
137
|
-
// Fade out using existing CSS opacity transition
|
|
138
|
-
path.style.opacity = '0';
|
|
139
|
-
path.addEventListener('transitionend', () => path.remove(), { once: true });
|
|
140
|
-
// Fallback removal if transition doesn't fire
|
|
141
|
-
setTimeout(() => { if (path.parentNode) path.remove(); }, 200);
|
|
142
|
-
}
|
|
143
|
-
// Remove endpoint dots and arrow
|
|
144
|
-
for (const end of ['start', 'end']) {
|
|
145
|
-
const dot = this.#dotLayer.querySelector(`[data-conn-dot="${conn.id}-${end}"]`);
|
|
146
|
-
if (dot) dot.remove();
|
|
147
|
-
}
|
|
148
|
-
const arrow = this.#svgLayer.querySelector(`[data-conn-arrow="${conn.id}"]`);
|
|
149
|
-
if (arrow) arrow.remove();
|
|
150
|
-
|
|
151
|
-
// Re-render free dots for freed ports
|
|
152
|
-
this.renderFreeDots(fromId);
|
|
153
|
-
this.renderFreeDots(toId);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Highlight overlay dots belonging to compatible nodes during drag
|
|
158
|
-
* @param {Set<string>} compatibleNodeIds - set of node IDs that have compatible ports
|
|
159
|
-
*/
|
|
160
|
-
highlightDotsForNodes(compatibleNodeIds) {
|
|
161
|
-
// Highlight connected dots
|
|
162
|
-
const connDots = this.#dotLayer.querySelectorAll('.sn-conn-dot');
|
|
163
|
-
for (const dot of connDots) {
|
|
164
|
-
const dotId = dot.getAttribute('data-conn-dot') || '';
|
|
165
|
-
const connId = dotId.replace(/-(?:start|end)$/, '');
|
|
166
|
-
const conn = this.#connectionData.get(connId);
|
|
167
|
-
if (!conn) continue;
|
|
168
|
-
const end = dotId.endsWith('-start') ? 'start' : 'end';
|
|
169
|
-
const nodeId = end === 'start' ? conn.from : conn.to;
|
|
170
|
-
if (compatibleNodeIds.has(nodeId)) {
|
|
171
|
-
dot.classList.add('sn-dot-hint');
|
|
172
|
-
} else {
|
|
173
|
-
dot.classList.remove('sn-dot-hint');
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Highlight free dots
|
|
178
|
-
const freeDots = this.#dotLayer.querySelectorAll('.sn-free-dot');
|
|
179
|
-
for (const dot of freeDots) {
|
|
180
|
-
const nodeId = dot.getAttribute('data-node-id');
|
|
181
|
-
if (compatibleNodeIds.has(nodeId)) {
|
|
182
|
-
dot.classList.add('sn-dot-hint');
|
|
183
|
-
} else {
|
|
184
|
-
dot.classList.remove('sn-dot-hint');
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Clear all dot highlights
|
|
191
|
-
*/
|
|
192
|
-
clearDotHighlights() {
|
|
193
|
-
const dots = this.#dotLayer.querySelectorAll('.sn-dot-hint');
|
|
194
|
-
for (const dot of dots) {
|
|
195
|
-
dot.classList.remove('sn-dot-hint');
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Update all connections touching a node
|
|
201
|
-
* @param {string} nodeId
|
|
202
|
-
*/
|
|
203
|
-
updateForNode(nodeId) {
|
|
204
|
-
// Only clear and recalculate the DRAGGED node.
|
|
205
|
-
// Non-dragged nodes use cached slot assignments (no jitter).
|
|
206
|
-
const draggedEl = this.#nodeViews.get(nodeId);
|
|
207
|
-
if (draggedEl) {
|
|
208
|
-
draggedEl._usedCoords = [];
|
|
209
|
-
draggedEl._slotCache = new Map();
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Collect touched connections for re-render
|
|
213
|
-
const touchedConns = [];
|
|
214
|
-
for (const [, conn] of this.#connectionData) {
|
|
215
|
-
if (conn.from === nodeId || conn.to === nodeId) {
|
|
216
|
-
touchedConns.push(conn);
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
for (const conn of touchedConns) {
|
|
222
|
-
this.#render(conn, nodeId);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
static _refreshCycleCount = 0;
|
|
227
|
-
static _lastRefreshTime = 0;
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Clear all caches and re-render every connection + free dots.
|
|
231
|
-
* Call after initial node positioning to let SVG connectors settle.
|
|
232
|
-
*/
|
|
233
|
-
refreshAll() {
|
|
234
|
-
const t0 = performance.now();
|
|
235
|
-
ConnectionRenderer._refreshCycleCount = (ConnectionRenderer._refreshCycleCount || 0) + 1;
|
|
236
|
-
|
|
237
|
-
this.#clearAllSlots();
|
|
238
|
-
|
|
239
|
-
// Clear stale free dots from previous render
|
|
240
|
-
const staleDots = this.#dotLayer.querySelectorAll('.sn-free-dot');
|
|
241
|
-
for (const dot of staleDots) dot.remove();
|
|
242
|
-
|
|
243
|
-
// Detach layers from layout tree to prevent O(N²) thrashing during Read/Write mix
|
|
244
|
-
const originalSvgDisplay = this.#svgLayer.style.display;
|
|
245
|
-
const originalDotDisplay = this.#dotLayer.style.display;
|
|
246
|
-
this.#svgLayer.style.display = 'none';
|
|
247
|
-
this.#dotLayer.style.display = 'none';
|
|
248
|
-
|
|
249
|
-
// Pre-cache node rects for routing (prevents O(N^2) Layout Thrashing)
|
|
250
|
-
this._nodeRectCache = new Map();
|
|
251
|
-
for (const [nid, el] of this.#nodeViews) {
|
|
252
|
-
if (el) {
|
|
253
|
-
this._nodeRectCache.set(nid, {
|
|
254
|
-
id: nid,
|
|
255
|
-
x: el._position?.x || 0,
|
|
256
|
-
y: el._position?.y || 0,
|
|
257
|
-
w: el.offsetWidth || 180,
|
|
258
|
-
h: el.offsetHeight || 100,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ─── Three-Pass Pipeline: Side-Based Pin Assignment ───
|
|
264
|
-
// Pass 1: Assign sides and distribute pins
|
|
265
|
-
// Pass 2: Render connections (pins from _slotCache)
|
|
266
|
-
// Pass 3: Render free dots on remaining edges
|
|
267
|
-
|
|
268
|
-
const conns = Array.from(this.#connectionData.values());
|
|
269
|
-
|
|
270
|
-
// ─── Pass 1: Side-Based Pin Assignment ───
|
|
271
|
-
// Group all connection endpoints by node → then by side
|
|
272
|
-
/** @type {Map<string, Array<{portKey: string, portSide: string, targetPos: {x:number, y:number}}>>} */
|
|
273
|
-
const nodeJobs = new Map();
|
|
274
|
-
|
|
275
|
-
for (const conn of conns) {
|
|
276
|
-
const fromEl = this.#nodeViews.get(conn.from);
|
|
277
|
-
const toEl = this.#nodeViews.get(conn.to);
|
|
278
|
-
if (!fromEl || !toEl) continue;
|
|
279
|
-
|
|
280
|
-
const fromPos = fromEl._position;
|
|
281
|
-
const toPos = toEl._position;
|
|
282
|
-
if (!fromPos || !toPos) continue;
|
|
283
|
-
|
|
284
|
-
const toCenter = {
|
|
285
|
-
x: toPos.x + (toEl._cachedW || 180) / 2,
|
|
286
|
-
y: toPos.y + (toEl._cachedH || 100) / 2,
|
|
287
|
-
};
|
|
288
|
-
const fromCenter = {
|
|
289
|
-
x: fromPos.x + (fromEl._cachedW || 180) / 2,
|
|
290
|
-
y: fromPos.y + (fromEl._cachedH || 100) / 2,
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
if (!nodeJobs.has(conn.from)) nodeJobs.set(conn.from, []);
|
|
294
|
-
nodeJobs.get(conn.from).push({ portKey: conn.out, portSide: 'output', targetPos: toCenter });
|
|
295
|
-
|
|
296
|
-
if (!nodeJobs.has(conn.to)) nodeJobs.set(conn.to, []);
|
|
297
|
-
nodeJobs.get(conn.to).push({ portKey: conn.in, portSide: 'input', targetPos: fromCenter });
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// For each node: determine side per connection, group by side, distribute pins
|
|
301
|
-
for (const [nodeId, jobs] of nodeJobs) {
|
|
302
|
-
const el = this.#nodeViews.get(nodeId);
|
|
303
|
-
if (!el?._position) continue;
|
|
304
|
-
|
|
305
|
-
const shape = getShape(el.getAttribute('node-shape'));
|
|
306
|
-
if (!shape?.getSidePosition) continue;
|
|
307
|
-
|
|
308
|
-
const size = { width: el._cachedW || 180, height: el._cachedH || 100 };
|
|
309
|
-
const cx = el._position.x + size.width / 2;
|
|
310
|
-
const cy = el._position.y + size.height / 2;
|
|
311
|
-
|
|
312
|
-
if (!el._slotCache) el._slotCache = new Map();
|
|
313
|
-
|
|
314
|
-
// Step 1: Determine side for each connection
|
|
315
|
-
/** @type {Map<string, Array<{portKey: string, portSide: string, angle: number}>>} */
|
|
316
|
-
const sideBuckets = new Map();
|
|
317
|
-
for (const job of jobs) {
|
|
318
|
-
const dx = job.targetPos.x - cx;
|
|
319
|
-
const dy = job.targetPos.y - cy;
|
|
320
|
-
const angle = Math.atan2(dy, dx);
|
|
321
|
-
|
|
322
|
-
// Determine side from angle quadrant
|
|
323
|
-
let nodeSide;
|
|
324
|
-
if (Math.abs(dx) > Math.abs(dy)) {
|
|
325
|
-
nodeSide = dx > 0 ? 'right' : 'left';
|
|
326
|
-
} else {
|
|
327
|
-
nodeSide = dy > 0 ? 'bottom' : 'top';
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
if (!sideBuckets.has(nodeSide)) sideBuckets.set(nodeSide, []);
|
|
331
|
-
sideBuckets.get(nodeSide).push({ portKey: job.portKey, portSide: job.portSide, angle });
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Step 2: Within each side, sort by perpendicular angle and distribute
|
|
335
|
-
for (const [nodeSide, bucket] of sideBuckets) {
|
|
336
|
-
// Sort by perpendicular component for natural spacing
|
|
337
|
-
// For left/right sides: sort by Y (angle's vertical component)
|
|
338
|
-
// For top/bottom sides: sort by X (angle's horizontal component)
|
|
339
|
-
if (nodeSide === 'left' || nodeSide === 'right') {
|
|
340
|
-
bucket.sort((a, b) => Math.sin(a.angle) - Math.sin(b.angle));
|
|
341
|
-
} else {
|
|
342
|
-
bucket.sort((a, b) => Math.cos(a.angle) - Math.cos(b.angle));
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Distribute pins evenly along the side edge
|
|
346
|
-
const total = bucket.length;
|
|
347
|
-
bucket.forEach((item, index) => {
|
|
348
|
-
const t = total === 1 ? 0.5 : index / (total - 1);
|
|
349
|
-
const pos = shape.getSidePosition(nodeSide, t, size);
|
|
350
|
-
const cacheKey = `${item.portKey}:${item.portSide}`;
|
|
351
|
-
el._slotCache.set(cacheKey, { x: pos.x, y: pos.y, angle: pos.angle });
|
|
352
|
-
|
|
353
|
-
if (ConnectionRenderer.debug) {
|
|
354
|
-
const label = el._nodeData?.label || nodeId;
|
|
355
|
-
console.log(`[PIN] ${label} | ${item.portSide}:${item.portKey} → side=${nodeSide} t=${t.toFixed(2)} pos=(${pos.x.toFixed(1)}, ${pos.y.toFixed(1)}) angle=${pos.angle}°`);
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// ─── Pass 2: Render connections (pins pre-assigned from _slotCache) ───
|
|
362
|
-
for (const conn of conns) {
|
|
363
|
-
this.#render(conn);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ─── Pass 3: Render free dots for SVG nodes ───
|
|
367
|
-
for (const [nodeId, el] of this.#nodeViews) {
|
|
368
|
-
if (el.getAttribute('data-svg-shape')) {
|
|
369
|
-
this.renderFreeDots(nodeId);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// ─── Final Debug Pass: Inter-Trace Overlaps ───
|
|
374
|
-
if (ConnectionRenderer.debug && this._allSegments) {
|
|
375
|
-
let overlaps = 0;
|
|
376
|
-
for (let i = 0; i < this._allSegments.length; i++) {
|
|
377
|
-
for (let j = i + 1; j < this._allSegments.length; j++) {
|
|
378
|
-
const s1 = this._allSegments[i];
|
|
379
|
-
const s2 = this._allSegments[j];
|
|
380
|
-
if (s1.connId === s2.connId) continue;
|
|
381
|
-
|
|
382
|
-
// Check if both are horizontal
|
|
383
|
-
if (s1.p1.y === s1.p2.y && s2.p1.y === s2.p2.y && s1.p1.y === s2.p1.y) {
|
|
384
|
-
const minX1 = Math.min(s1.p1.x, s1.p2.x), maxX1 = Math.max(s1.p1.x, s1.p2.x);
|
|
385
|
-
const minX2 = Math.min(s2.p1.x, s2.p2.x), maxX2 = Math.max(s2.p1.x, s2.p2.x);
|
|
386
|
-
if (Math.max(minX1, minX2) + 5 < Math.min(maxX1, maxX2)) {
|
|
387
|
-
console.warn(`[PCB DEBUG] Trace Overlap (Horizontal) Y=${s1.p1.y}: conn[${s1.connId}] overlaps conn[${s2.connId}]`);
|
|
388
|
-
overlaps++;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// Check if both are vertical
|
|
392
|
-
if (s1.p1.x === s1.p2.x && s2.p1.x === s2.p2.x && s1.p1.x === s2.p1.x) {
|
|
393
|
-
const minY1 = Math.min(s1.p1.y, s1.p2.y), maxY1 = Math.max(s1.p1.y, s1.p2.y);
|
|
394
|
-
const minY2 = Math.min(s2.p1.y, s2.p2.y), maxY2 = Math.max(s2.p1.y, s2.p2.y);
|
|
395
|
-
if (Math.max(minY1, minY2) + 5 < Math.min(maxY1, maxY2)) {
|
|
396
|
-
console.warn(`[PCB DEBUG] Trace Overlap (Vertical) X=${s1.p1.x}: conn[${s1.connId}] overlaps conn[${s2.connId}]`);
|
|
397
|
-
overlaps++;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
if (overlaps > 0) console.warn(`[PCB DEBUG] Found ${overlaps} inter-trace overlaps.`);
|
|
403
|
-
}
|
|
404
|
-
this._allSegments = null;
|
|
405
|
-
this._nodeRectCache = null;
|
|
406
|
-
|
|
407
|
-
// Restore layers
|
|
408
|
-
this.#svgLayer.style.display = originalSvgDisplay;
|
|
409
|
-
this.#dotLayer.style.display = originalDotDisplay;
|
|
410
|
-
|
|
411
|
-
// ─── Performance Monitoring ───
|
|
412
|
-
if (ConnectionRenderer.debug) {
|
|
413
|
-
const t1 = performance.now();
|
|
414
|
-
const mem = (performance?.memory?.usedJSHeapSize)
|
|
415
|
-
? (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + 'MB'
|
|
416
|
-
: 'N/A';
|
|
417
|
-
console.log(`[PCB PERF] refreshAll cycle #${ConnectionRenderer._refreshCycleCount} took ${(t1 - t0).toFixed(2)}ms | Mem: ${mem}`);
|
|
418
|
-
|
|
419
|
-
const dt = t0 - (ConnectionRenderer._lastRefreshTime || 0);
|
|
420
|
-
if (ConnectionRenderer._lastRefreshTime > 0 && dt < 16) {
|
|
421
|
-
console.warn(`[PCB PERF] High refresh rate detected! dt=${dt.toFixed(2)}ms (possible rendering loop or layout oscillation)`);
|
|
422
|
-
}
|
|
423
|
-
ConnectionRenderer._lastRefreshTime = t0;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Set data flow animation on a connection
|
|
429
|
-
* @param {string} connId
|
|
430
|
-
* @param {boolean} active
|
|
431
|
-
*/
|
|
432
|
-
setFlowing(connId, active) {
|
|
433
|
-
const path = this.#svgLayer.querySelector(`[data-conn-id="${connId}"]`);
|
|
434
|
-
if (!path) return;
|
|
435
|
-
if (active) {
|
|
436
|
-
path.setAttribute('data-flowing', '');
|
|
437
|
-
} else {
|
|
438
|
-
path.removeAttribute('data-flowing');
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Set data flow animation on all connections
|
|
444
|
-
* @param {boolean} active
|
|
445
|
-
*/
|
|
446
|
-
setAllFlowing(active) {
|
|
447
|
-
for (const [connId] of this.#connectionData) {
|
|
448
|
-
this.setFlowing(connId, active);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Set connection path style
|
|
454
|
-
* @param {'bezier'|'orthogonal'|'straight'} style
|
|
455
|
-
*/
|
|
456
|
-
setPathStyle(style) {
|
|
457
|
-
this.#pathStyle = style;
|
|
458
|
-
this.#clearAllSlots();
|
|
459
|
-
for (const [, conn] of this.#connectionData) {
|
|
460
|
-
this.#render(conn);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
/** @returns {'bezier'|'orthogonal'|'straight'} */
|
|
466
|
-
get pathStyle() { return this.#pathStyle; }
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Get socket offset relative to graph-node.
|
|
470
|
-
* For SVG shapes with a target position, computes dynamic edge point
|
|
471
|
-
* in the direction of the connected node (connector slides along perimeter).
|
|
472
|
-
*
|
|
473
|
-
* @param {HTMLElement} nodeEl
|
|
474
|
-
* @param {string} portKey
|
|
475
|
-
* @param {'input'|'output'} side
|
|
476
|
-
* @param {{ x: number, y: number }} [targetPos] - center of the connected node (for dynamic edge)
|
|
477
|
-
* @returns {{ x: number, y: number }}
|
|
478
|
-
*/
|
|
479
|
-
getSocketOffset(nodeEl, portKey, side, targetPos) {
|
|
480
|
-
// SVG shapes: compute edge position mathematically
|
|
481
|
-
const shape = getShape(nodeEl.getAttribute('node-shape'));
|
|
482
|
-
const nodeData = nodeEl._nodeData;
|
|
483
|
-
if (shape && shape.pathData && nodeData) {
|
|
484
|
-
const size = { width: nodeEl._cachedW || 180, height: nodeEl._cachedH || 100 };
|
|
485
|
-
|
|
486
|
-
// Dynamic mode — side-based pin placement
|
|
487
|
-
if (targetPos && shape.getSidePosition) {
|
|
488
|
-
// Check cache first (set by refreshAll two-pass pipeline)
|
|
489
|
-
if (!nodeEl._slotCache) nodeEl._slotCache = new Map();
|
|
490
|
-
const cacheKey = `${portKey}:${side}`;
|
|
491
|
-
if (nodeEl._slotCache.has(cacheKey)) {
|
|
492
|
-
return nodeEl._slotCache.get(cacheKey);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Fallback: immediate side-based calculation (for single-connection render)
|
|
496
|
-
const nodePos = nodeEl._position;
|
|
497
|
-
const cx = nodePos.x + size.width / 2;
|
|
498
|
-
const cy = nodePos.y + size.height / 2;
|
|
499
|
-
const dx = targetPos.x - cx;
|
|
500
|
-
const dy = targetPos.y - cy;
|
|
501
|
-
|
|
502
|
-
// Determine side from angle to target
|
|
503
|
-
const nodeSide = Math.abs(dx) > Math.abs(dy)
|
|
504
|
-
? (dx > 0 ? 'right' : 'left')
|
|
505
|
-
: (dy > 0 ? 'bottom' : 'top');
|
|
506
|
-
|
|
507
|
-
const pos = shape.getSidePosition(nodeSide, 0.5, size);
|
|
508
|
-
const result = { x: pos.x, y: pos.y, angle: pos.angle };
|
|
509
|
-
nodeEl._slotCache.set(cacheKey, result);
|
|
510
|
-
return result;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// Also support getEdgePoint fallback with smart routing
|
|
514
|
-
if (targetPos && shape.getEdgePoint) {
|
|
515
|
-
const ports = side === 'output' ? nodeData.outputs : nodeData.inputs;
|
|
516
|
-
const keys = ports ? Object.keys(ports) : [portKey];
|
|
517
|
-
const index = keys.indexOf(portKey);
|
|
518
|
-
const total = keys.length;
|
|
519
|
-
|
|
520
|
-
const nodePos = nodeEl._position;
|
|
521
|
-
const cx = nodePos.x + size.width / 2;
|
|
522
|
-
const cy = nodePos.y + size.height / 2;
|
|
523
|
-
const baseAngle = Math.atan2(targetPos.y - cy, targetPos.x - cx);
|
|
524
|
-
|
|
525
|
-
// 1. Separate input/output zones: gap between types
|
|
526
|
-
const sideGap = Math.PI / 6; // 30° gap = one full slot between input/output
|
|
527
|
-
const adjustedBase = baseAngle + (side === 'output' ? -sideGap : sideGap);
|
|
528
|
-
|
|
529
|
-
// 2. Anti-crossing: reverse port order based on perpendicular direction
|
|
530
|
-
const dx = targetPos.x - cx;
|
|
531
|
-
const dy = targetPos.y - cy;
|
|
532
|
-
const shouldReverse = (side === 'output') ? (dy < 0) : (dy > 0);
|
|
533
|
-
const effectiveIndex = shouldReverse ? (total - 1 - index) : index;
|
|
534
|
-
|
|
535
|
-
// 3. Spread ports around adjusted base angle
|
|
536
|
-
let angle = adjustedBase;
|
|
537
|
-
if (total > 1) {
|
|
538
|
-
const segment = (2 * Math.PI) / (total * 2);
|
|
539
|
-
const offset = (effectiveIndex - (total - 1) / 2) * segment;
|
|
540
|
-
angle = adjustedBase + offset;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// 4. Quantize to 15° grid for stable discrete movement
|
|
544
|
-
const step = Math.PI / 12; // 15° grid
|
|
545
|
-
angle = Math.round(angle / step) * step;
|
|
546
|
-
|
|
547
|
-
// Check cache first
|
|
548
|
-
if (!nodeEl._slotCache) nodeEl._slotCache = new Map();
|
|
549
|
-
const cacheKey = `${portKey}:${side}`;
|
|
550
|
-
if (nodeEl._slotCache.has(cacheKey)) {
|
|
551
|
-
return nodeEl._slotCache.get(cacheKey);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// 5. Collision avoidance by PIXEL COORDINATES
|
|
555
|
-
if (!nodeEl._usedCoords) nodeEl._usedCoords = [];
|
|
556
|
-
const MIN_PIX = 5;
|
|
557
|
-
let nudged = angle;
|
|
558
|
-
let attempts = 0;
|
|
559
|
-
while (attempts < 24) {
|
|
560
|
-
const testPos = shape.getEdgePoint(nudged, size);
|
|
561
|
-
const tooClose = nodeEl._usedCoords.some(
|
|
562
|
-
c => Math.abs(testPos.x - c.x) < MIN_PIX && Math.abs(testPos.y - c.y) < MIN_PIX
|
|
563
|
-
);
|
|
564
|
-
if (!tooClose) break;
|
|
565
|
-
nudged += step;
|
|
566
|
-
attempts++;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
const pos = shape.getEdgePoint(nudged, size);
|
|
570
|
-
nodeEl._usedCoords.push({ x: pos.x, y: pos.y });
|
|
571
|
-
const result = { x: pos.x, y: pos.y, angle: pos.angle };
|
|
572
|
-
nodeEl._slotCache.set(cacheKey, result);
|
|
573
|
-
return result;
|
|
574
|
-
}
|
|
575
|
-
// Fixed mode: distribute ports at preset angles
|
|
576
|
-
const ports = side === 'output' ? nodeData.outputs : nodeData.inputs;
|
|
577
|
-
if (ports) {
|
|
578
|
-
const keys = Object.keys(ports);
|
|
579
|
-
const index = keys.indexOf(portKey);
|
|
580
|
-
const total = keys.length;
|
|
581
|
-
if (index >= 0) {
|
|
582
|
-
const pos = shape.getSocketPosition(side, index, total, size);
|
|
583
|
-
return { x: pos.x, y: pos.y };
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Fast path: if node is culled, skip forced layout resolution
|
|
589
|
-
if (nodeEl.style.contentVisibility === 'hidden') {
|
|
590
|
-
return {
|
|
591
|
-
x: side === 'output' ? (nodeEl._cachedW || 180) : 0,
|
|
592
|
-
y: (nodeEl._cachedH || 100) / 2,
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// Standard shapes: read from DOM socket elements
|
|
597
|
-
const container = side === 'output'
|
|
598
|
-
? nodeEl.querySelector('.outputs')
|
|
599
|
-
: nodeEl.querySelector('.inputs');
|
|
600
|
-
|
|
601
|
-
if (container) {
|
|
602
|
-
const portItems = container.querySelectorAll('port-item');
|
|
603
|
-
for (const portItem of portItems) {
|
|
604
|
-
if (portItem.$.key === portKey) {
|
|
605
|
-
const socket = portItem.querySelector('.sn-socket');
|
|
606
|
-
if (socket) {
|
|
607
|
-
const nodeRect = nodeEl.getBoundingClientRect();
|
|
608
|
-
const socketRect = socket.getBoundingClientRect();
|
|
609
|
-
const z = this.#getZoom();
|
|
610
|
-
return {
|
|
611
|
-
x: (socketRect.left - nodeRect.left + socketRect.width / 2) / z,
|
|
612
|
-
y: (socketRect.top - nodeRect.top + socketRect.height / 2) / z,
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
return {
|
|
620
|
-
x: side === 'output' ? (nodeEl._cachedW || nodeEl.offsetWidth || 180) : 0,
|
|
621
|
-
y: (nodeEl._cachedH || nodeEl.offsetHeight || 100) / 2,
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* Render a single connection SVG path with tangent-aware Bézier and gradient coloring
|
|
627
|
-
* @param {import('../core/Connection.js').Connection} conn
|
|
628
|
-
*/
|
|
629
|
-
#render(conn, draggedNodeId = null) {
|
|
630
|
-
const fromEl = this.#nodeViews.get(conn.from);
|
|
631
|
-
const toEl = this.#nodeViews.get(conn.to);
|
|
632
|
-
if (!fromEl || !toEl) return;
|
|
633
|
-
|
|
634
|
-
const fromPos = fromEl._position;
|
|
635
|
-
const toPos = toEl._position;
|
|
636
|
-
|
|
637
|
-
// Compute centers for dynamic edge positioning on SVG shapes
|
|
638
|
-
const fromW = fromEl._cachedW || fromEl.offsetWidth || 180;
|
|
639
|
-
const fromH = fromEl._cachedH || fromEl.offsetHeight || 100;
|
|
640
|
-
const toW = toEl._cachedW || toEl.offsetWidth || 180;
|
|
641
|
-
const toH = toEl._cachedH || toEl.offsetHeight || 100;
|
|
642
|
-
const fromCenter = {
|
|
643
|
-
x: fromPos.x + fromW / 2,
|
|
644
|
-
y: fromPos.y + fromH / 2,
|
|
645
|
-
};
|
|
646
|
-
const toCenter = {
|
|
647
|
-
x: toPos.x + toW / 2,
|
|
648
|
-
y: toPos.y + toH / 2,
|
|
649
|
-
};
|
|
650
|
-
|
|
651
|
-
// Always recalculate both sides (slot pool makes this cheap and deterministic)
|
|
652
|
-
const fromOffset = this.getSocketOffset(fromEl, conn.out, 'output', toCenter);
|
|
653
|
-
const toOffset = this.getSocketOffset(toEl, conn.in, 'input', fromCenter);
|
|
654
|
-
|
|
655
|
-
const startX = fromPos.x + fromOffset.x;
|
|
656
|
-
const startY = fromPos.y + fromOffset.y;
|
|
657
|
-
const endX = toPos.x + toOffset.x;
|
|
658
|
-
const endY = toPos.y + toOffset.y;
|
|
659
|
-
|
|
660
|
-
// Tangent-aware Bézier using shape angles
|
|
661
|
-
const fromNode = this.#editor.getNode(conn.from);
|
|
662
|
-
const toNode = this.#editor.getNode(conn.to);
|
|
663
|
-
const fromShape = getShape(fromNode?.shape);
|
|
664
|
-
const toShape = getShape(toNode?.shape);
|
|
665
|
-
|
|
666
|
-
const fromSize = { width: fromW, height: fromH };
|
|
667
|
-
const toSize = { width: toW, height: toH };
|
|
668
|
-
|
|
669
|
-
// Generate path based on style
|
|
670
|
-
let d;
|
|
671
|
-
if (this.#pathStyle === 'straight') {
|
|
672
|
-
d = `M ${startX} ${startY} L ${endX} ${endY}`;
|
|
673
|
-
} else if (this.#pathStyle === 'orthogonal') {
|
|
674
|
-
const connKeys = Array.from(this.#connectionData.keys());
|
|
675
|
-
const connIndex = connKeys.indexOf(conn.id);
|
|
676
|
-
const traceOffset = (connIndex > -1 ? connIndex % 10 : 0) * 4;
|
|
677
|
-
|
|
678
|
-
const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
|
|
679
|
-
const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
|
|
680
|
-
|
|
681
|
-
const stubLen = 20;
|
|
682
|
-
const getDxDy = (deg) => ({
|
|
683
|
-
dx: Math.round(Math.cos(deg * Math.PI / 180)),
|
|
684
|
-
dy: Math.round(Math.sin(deg * Math.PI / 180))
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
const fDir = getDxDy(fromAngle);
|
|
688
|
-
const tDir = getDxDy(toAngle);
|
|
689
|
-
|
|
690
|
-
const p1x = startX + fDir.dx * stubLen;
|
|
691
|
-
const p1y = startY + fDir.dy * stubLen;
|
|
692
|
-
const p2x = endX + tDir.dx * stubLen;
|
|
693
|
-
const p2y = endY + tDir.dy * stubLen;
|
|
694
|
-
|
|
695
|
-
const fromH = fromEl._cachedH || 60;
|
|
696
|
-
const toH = toEl._cachedH || 60;
|
|
697
|
-
|
|
698
|
-
let pts = [{x: startX, y: startY}, {x: p1x, y: p1y}];
|
|
699
|
-
|
|
700
|
-
if (endX < startX) {
|
|
701
|
-
const bottomY = Math.max(fromPos.y + fromH, toPos.y + toH) + 30 + traceOffset;
|
|
702
|
-
pts.push({x: p1x, y: bottomY});
|
|
703
|
-
pts.push({x: p2x, y: bottomY});
|
|
704
|
-
} else {
|
|
705
|
-
const maxH = Math.max(fromH, toH);
|
|
706
|
-
if (Math.abs(p1y - p2y) < maxH) {
|
|
707
|
-
let nodeBetween = false;
|
|
708
|
-
for (const [, node] of this.#nodeViews) {
|
|
709
|
-
if (!node._position) continue;
|
|
710
|
-
const nx = node._position.x;
|
|
711
|
-
const ny = node._position.y;
|
|
712
|
-
const nw = node._cachedW || 180;
|
|
713
|
-
const nh = node._cachedH || 60;
|
|
714
|
-
if (nx > p1x && nx + nw < p2x) {
|
|
715
|
-
if (Math.min(p1y, p2y) <= ny + nh && Math.max(p1y, p2y) >= ny) {
|
|
716
|
-
nodeBetween = true; break;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
if (nodeBetween) {
|
|
722
|
-
const detourY = Math.min(fromPos.y, toPos.y) - 30 - traceOffset;
|
|
723
|
-
pts.push({x: p1x, y: detourY});
|
|
724
|
-
pts.push({x: p2x, y: detourY});
|
|
725
|
-
} else {
|
|
726
|
-
const midX = (p1x + p2x) / 2 + traceOffset;
|
|
727
|
-
pts.push({x: midX, y: p1y});
|
|
728
|
-
pts.push({x: midX, y: p2y});
|
|
729
|
-
}
|
|
730
|
-
} else {
|
|
731
|
-
let midX = (p1x + p2x) / 2 + traceOffset;
|
|
732
|
-
let obstacleNode = null;
|
|
733
|
-
const minY = Math.min(p1y, p2y);
|
|
734
|
-
const maxY = Math.max(p1y, p2y);
|
|
735
|
-
|
|
736
|
-
for (const [, node] of this.#nodeViews) {
|
|
737
|
-
if (!node._position) continue;
|
|
738
|
-
const nx = node._position.x;
|
|
739
|
-
const ny = node._position.y;
|
|
740
|
-
const nw = node._cachedW || 180;
|
|
741
|
-
const nh = node._cachedH || 60;
|
|
742
|
-
if (midX >= nx && midX <= nx + nw) {
|
|
743
|
-
if (ny <= maxY && ny + nh >= minY) {
|
|
744
|
-
obstacleNode = {x: nx, w: nw};
|
|
745
|
-
break;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (obstacleNode) {
|
|
751
|
-
const leftDist = Math.abs(midX - obstacleNode.x);
|
|
752
|
-
const rightDist = Math.abs(midX - (obstacleNode.x + obstacleNode.w));
|
|
753
|
-
if (leftDist < rightDist) {
|
|
754
|
-
midX = obstacleNode.x - 30 - traceOffset;
|
|
755
|
-
} else {
|
|
756
|
-
midX = obstacleNode.x + obstacleNode.w + 30 + traceOffset;
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
pts.push({x: midX, y: p1y});
|
|
761
|
-
pts.push({x: midX, y: p2y});
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
pts.push({x: p2x, y: p2y});
|
|
766
|
-
pts.push({x: endX, y: endY});
|
|
767
|
-
|
|
768
|
-
let path = `M ${pts[0].x} ${pts[0].y}`;
|
|
769
|
-
for (let i = 1; i < pts.length; i++) {
|
|
770
|
-
const prev = pts[i-1];
|
|
771
|
-
const curr = pts[i];
|
|
772
|
-
if (curr.x === prev.x && curr.y === prev.y) continue;
|
|
773
|
-
if (curr.x !== prev.x && curr.y !== prev.y) {
|
|
774
|
-
path += ` H ${curr.x} V ${curr.y}`;
|
|
775
|
-
} else if (curr.x !== prev.x) {
|
|
776
|
-
path += ` H ${curr.x}`;
|
|
777
|
-
} else if (curr.y !== prev.y) {
|
|
778
|
-
path += ` V ${curr.y}`;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
d = path;
|
|
782
|
-
} else if (this.#pathStyle === 'pcb') {
|
|
783
|
-
// ─── PCB Grid-Based Trace Routing ───
|
|
784
|
-
// All waypoints snap to a grid. Stubs exit perpendicular to node surface
|
|
785
|
-
// with a minimum length, then route on grid channels with chamfered corners.
|
|
786
|
-
|
|
787
|
-
const TRACE_GRID = 5; // Dense trace grid (5px)
|
|
788
|
-
const STUB_MIN = 20; // minimum perpendicular stub from node edge
|
|
789
|
-
const CHAMFER = 8; // 45° chamfer radius (px)
|
|
790
|
-
|
|
791
|
-
// Snap a coordinate to the trace grid
|
|
792
|
-
const snapGrid = (v) => Math.round(v / TRACE_GRID) * TRACE_GRID;
|
|
793
|
-
|
|
794
|
-
// Connection channel index for parallel trace separation
|
|
795
|
-
const connKeys = Array.from(this.#connectionData.keys());
|
|
796
|
-
const connIndex = connKeys.indexOf(conn.id);
|
|
797
|
-
|
|
798
|
-
// Determine unique channel shift to prevent parallel traces overlapping
|
|
799
|
-
// Alternates: 0, +5, -5, +10, -10...
|
|
800
|
-
const shiftIndex = (connIndex > -1 ? connIndex % 12 : 0);
|
|
801
|
-
const channelShift = (shiftIndex % 2 === 0 ? 1 : -1) * Math.ceil(shiftIndex / 2) * TRACE_GRID;
|
|
802
|
-
|
|
803
|
-
// Compute perpendicular stub directions from surface normals
|
|
804
|
-
const fromAngle = fromOffset.angle !== undefined ? fromOffset.angle : 0;
|
|
805
|
-
const toAngle = toOffset.angle !== undefined ? toOffset.angle : 180;
|
|
806
|
-
|
|
807
|
-
// Snap angle to cardinal direction (→ ↓ ← ↑)
|
|
808
|
-
const snapDir = (deg) => {
|
|
809
|
-
const r = ((deg % 360) + 360) % 360;
|
|
810
|
-
if (r < 45 || r >= 315) return { dx: 1, dy: 0 }; // right
|
|
811
|
-
if (r >= 45 && r < 135) return { dx: 0, dy: 1 }; // down
|
|
812
|
-
if (r >= 135 && r < 225) return { dx: -1, dy: 0 }; // left
|
|
813
|
-
return { dx: 0, dy: -1 }; // up
|
|
814
|
-
};
|
|
815
|
-
|
|
816
|
-
const fDir = snapDir(fromAngle);
|
|
817
|
-
const tDir = snapDir(toAngle);
|
|
818
|
-
|
|
819
|
-
// Stub endpoints: extend strictly perpedicular, no grid snapping on the orthogonal axis
|
|
820
|
-
// to avoid diagonal stubs from pins that are floating (not grid aligned).
|
|
821
|
-
const stubFromX = fDir.dx === 0 ? startX : startX + fDir.dx * STUB_MIN;
|
|
822
|
-
const stubFromY = fDir.dy === 0 ? startY : startY + fDir.dy * STUB_MIN;
|
|
823
|
-
const stubToX = tDir.dx === 0 ? endX : endX + tDir.dx * STUB_MIN;
|
|
824
|
-
const stubToY = tDir.dy === 0 ? endY : endY + tDir.dy * STUB_MIN;
|
|
825
|
-
|
|
826
|
-
const fromH = fromEl.offsetHeight || 60;
|
|
827
|
-
const toH = toEl.offsetHeight || 60;
|
|
828
|
-
|
|
829
|
-
// Build orthogonal waypoints on grid
|
|
830
|
-
let pts = [
|
|
831
|
-
{ x: startX, y: startY },
|
|
832
|
-
{ x: stubFromX, y: stubFromY },
|
|
833
|
-
];
|
|
834
|
-
|
|
835
|
-
// Very simple heuristic orthogonal router
|
|
836
|
-
if (endX < startX - 20) {
|
|
837
|
-
// Backwards routing: U-turn below obstacles in the path
|
|
838
|
-
const minXForObstacle = Math.min(stubFromX, stubToX);
|
|
839
|
-
const maxXForObstacle = Math.max(stubFromX, stubToX);
|
|
840
|
-
let maxObstacleY = Math.max(fromPos.y + fromH, toPos.y + toH);
|
|
841
|
-
|
|
842
|
-
const iter = this._nodeRectCache ? this._nodeRectCache.values() : [];
|
|
843
|
-
for (const rect of iter) {
|
|
844
|
-
const nx = rect.x;
|
|
845
|
-
const ny = rect.y;
|
|
846
|
-
const nw = rect.w;
|
|
847
|
-
const nh = rect.h;
|
|
848
|
-
// Check if node is in the horizontal path of the detour
|
|
849
|
-
const pad = TRACE_GRID * 2;
|
|
850
|
-
if (nx + nw + pad >= minXForObstacle && nx - pad <= maxXForObstacle) {
|
|
851
|
-
if (ny + nh > maxObstacleY) {
|
|
852
|
-
maxObstacleY = ny + nh;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// Detour deeply below all nodes in the path to avoid overlaps
|
|
858
|
-
// We use absolute channelShift so tracks stack neatly downward
|
|
859
|
-
const bottomY = snapGrid(maxObstacleY + 30) + Math.abs(channelShift);
|
|
860
|
-
pts.push({ x: stubFromX, y: bottomY });
|
|
861
|
-
pts.push({ x: stubToX, y: bottomY });
|
|
862
|
-
} else {
|
|
863
|
-
// Forward routing: mid-X channel
|
|
864
|
-
let midX = snapGrid((stubFromX + stubToX) / 2) + channelShift;
|
|
865
|
-
|
|
866
|
-
// Same-height shortcut: if stubs are roughly aligned (in same track cell), connect via single horizontal
|
|
867
|
-
if (Math.abs(stubFromY - stubToY) < TRACE_GRID * 2) {
|
|
868
|
-
// Keep strictly horizontal
|
|
869
|
-
pts.push({ x: stubToX, y: stubFromY });
|
|
870
|
-
} else {
|
|
871
|
-
// Obstacle check for mid-X vertical segment
|
|
872
|
-
const minY = Math.min(stubFromY, stubToY);
|
|
873
|
-
const maxY = Math.max(stubFromY, stubToY);
|
|
874
|
-
const pad = TRACE_GRID * 4;
|
|
875
|
-
|
|
876
|
-
const iter = this._nodeRectCache ? this._nodeRectCache.values() : [];
|
|
877
|
-
for (const rect of iter) {
|
|
878
|
-
if (rect.id === conn.from || rect.id === conn.to) continue;
|
|
879
|
-
const nx = rect.x, ny = rect.y;
|
|
880
|
-
const nw = rect.w, nh = rect.h;
|
|
881
|
-
|
|
882
|
-
if (midX >= nx - pad && midX <= nx + nw + pad) {
|
|
883
|
-
if (ny - pad <= maxY && ny + nh + pad >= minY) {
|
|
884
|
-
// Detour around obstacle
|
|
885
|
-
const leftX = snapGrid(nx - pad) + channelShift;
|
|
886
|
-
const rightX = snapGrid(nx + nw + pad) + channelShift;
|
|
887
|
-
midX = Math.abs(midX - leftX) < Math.abs(midX - rightX) ? leftX : rightX;
|
|
888
|
-
break;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
pts.push({ x: midX, y: stubFromY });
|
|
894
|
-
pts.push({ x: midX, y: stubToY });
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
pts.push({ x: stubToX, y: stubToY });
|
|
899
|
-
pts.push({ x: endX, y: endY });
|
|
900
|
-
|
|
901
|
-
// Path building and Chamfering
|
|
902
|
-
let debugCollisions = [];
|
|
903
|
-
|
|
904
|
-
// 1. Check if line segments intersect any nodes
|
|
905
|
-
for (let i = 0; i < pts.length - 1; i++) {
|
|
906
|
-
const segX1 = Math.min(pts[i].x, pts[i + 1].x);
|
|
907
|
-
const segY1 = Math.min(pts[i].y, pts[i + 1].y);
|
|
908
|
-
const segX2 = Math.max(pts[i].x, pts[i + 1].x);
|
|
909
|
-
const segY2 = Math.max(pts[i].y, pts[i + 1].y);
|
|
910
|
-
|
|
911
|
-
const iter = this._nodeRectCache ? this._nodeRectCache.values() : [];
|
|
912
|
-
for (const rect of iter) {
|
|
913
|
-
if (rect.id === conn.from || rect.id === conn.to) continue;
|
|
914
|
-
|
|
915
|
-
const nx = rect.x, ny = rect.y;
|
|
916
|
-
const nw = rect.w, nh = rect.h;
|
|
917
|
-
|
|
918
|
-
if (segX1 < nx + nw && segX2 > nx && segY1 < ny + nh && segY2 > ny) {
|
|
919
|
-
debugCollisions.push(`Node Collision: (${pts[i].x},${pts[i].y})->(${pts[i+1].x},${pts[i+1].y}) intersects Node[${rect.id}]`);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// 2. Self-overlap (180 degree turn)
|
|
925
|
-
for (let i = 0; i < pts.length - 2; i++) {
|
|
926
|
-
const p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2];
|
|
927
|
-
const v1x = p2.x - p1.x, v1y = p2.y - p1.y;
|
|
928
|
-
const v2x = p3.x - p2.x, v2y = p3.y - p2.y;
|
|
929
|
-
if (v1x * v2x < 0 || v1y * v2y < 0) {
|
|
930
|
-
debugCollisions.push(`180° Fold: at (${p2.x},${p2.y}) turning back toward (${p3.x},${p3.y})`);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// Store generated segments for global overlap checks
|
|
935
|
-
if (!this._allSegments) this._allSegments = [];
|
|
936
|
-
const segments = [];
|
|
937
|
-
for (let i = 0; i < pts.length - 1; i++) {
|
|
938
|
-
segments.push({
|
|
939
|
-
p1: pts[i], p2: pts[i+1],
|
|
940
|
-
connId: conn.id,
|
|
941
|
-
channel: connIndex
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
this._allSegments.push(...segments);
|
|
945
|
-
|
|
946
|
-
// Log route stats
|
|
947
|
-
if (ConnectionRenderer.debug) {
|
|
948
|
-
const fromLabel = fromEl._nodeData?.label || conn.from;
|
|
949
|
-
const toLabel = toEl._nodeData?.label || conn.to;
|
|
950
|
-
let msg = `[PCB] ${fromLabel} → ${toLabel} | waypoints=${pts.length}`;
|
|
951
|
-
if (debugCollisions.length > 0) {
|
|
952
|
-
msg += ` | ERRS: ` + debugCollisions.join(' | ');
|
|
953
|
-
}
|
|
954
|
-
console.log(msg);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Build SVG path with 45° chamfered corners
|
|
958
|
-
let path = `M ${pts[0].x} ${pts[0].y}`;
|
|
959
|
-
for (let i = 1; i < pts.length; i++) {
|
|
960
|
-
const prev = pts[i - 1];
|
|
961
|
-
const curr = pts[i];
|
|
962
|
-
if (Math.abs(curr.x - prev.x) < 0.5 && Math.abs(curr.y - prev.y) < 0.5) continue;
|
|
963
|
-
|
|
964
|
-
const next = pts[i + 1];
|
|
965
|
-
if (next) {
|
|
966
|
-
// Determine if there's a turn at curr → need chamfer
|
|
967
|
-
const dx1 = curr.x - prev.x, dy1 = curr.y - prev.y;
|
|
968
|
-
const dx2 = next.x - curr.x, dy2 = next.y - curr.y;
|
|
969
|
-
const isH1 = Math.abs(dx1) > Math.abs(dy1);
|
|
970
|
-
const isH2 = Math.abs(dx2) > Math.abs(dy2);
|
|
971
|
-
|
|
972
|
-
if (isH1 !== isH2) {
|
|
973
|
-
// Corner turn — apply 45° chamfer
|
|
974
|
-
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
|
975
|
-
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
|
976
|
-
if (len1 < 1 || len2 < 1) {
|
|
977
|
-
// Degenerate segment — skip chamfer, go straight
|
|
978
|
-
path += ` L ${curr.x} ${curr.y}`;
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
const c = Math.min(CHAMFER, len1 / 2, len2 / 2);
|
|
982
|
-
|
|
983
|
-
// Pre-corner point
|
|
984
|
-
const nx1 = dx1 / len1, ny1 = dy1 / len1;
|
|
985
|
-
const preX = curr.x - nx1 * c;
|
|
986
|
-
const preY = curr.y - ny1 * c;
|
|
987
|
-
// Post-corner point
|
|
988
|
-
const nx2 = dx2 / len2, ny2 = dy2 / len2;
|
|
989
|
-
const postX = curr.x + nx2 * c;
|
|
990
|
-
const postY = curr.y + ny2 * c;
|
|
991
|
-
|
|
992
|
-
path += ` L ${preX} ${preY} L ${postX} ${postY}`;
|
|
993
|
-
continue;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// Straight segment — use H/V for axis-aligned, L for diagonal stubs
|
|
998
|
-
if (Math.abs(curr.y - prev.y) < 0.5) {
|
|
999
|
-
path += ` H ${curr.x}`;
|
|
1000
|
-
} else if (Math.abs(curr.x - prev.x) < 0.5) {
|
|
1001
|
-
path += ` V ${curr.y}`;
|
|
1002
|
-
} else {
|
|
1003
|
-
path += ` L ${curr.x} ${curr.y}`;
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
d = path;
|
|
1007
|
-
} else {
|
|
1008
|
-
// Tangent direction: use dynamic edge angle if available, else fixed socket angle
|
|
1009
|
-
let fromAngleDeg, toAngleDeg;
|
|
1010
|
-
|
|
1011
|
-
if (fromOffset.angle !== undefined) {
|
|
1012
|
-
fromAngleDeg = fromOffset.angle;
|
|
1013
|
-
} else {
|
|
1014
|
-
const fromPortIndex = fromNode ? Object.keys(fromNode.outputs).indexOf(conn.out) : 0;
|
|
1015
|
-
const fromPortTotal = fromNode ? Object.keys(fromNode.outputs).length : 1;
|
|
1016
|
-
const pos = fromShape?.getSocketPosition?.('output', fromPortIndex, fromPortTotal, fromSize);
|
|
1017
|
-
fromAngleDeg = pos?.angle ?? 0;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
if (toOffset.angle !== undefined) {
|
|
1021
|
-
toAngleDeg = toOffset.angle;
|
|
1022
|
-
} else {
|
|
1023
|
-
const toPortIndex = toNode ? Object.keys(toNode.inputs).indexOf(conn.in) : 0;
|
|
1024
|
-
const toPortTotal = toNode ? Object.keys(toNode.inputs).length : 1;
|
|
1025
|
-
const pos = toShape?.getSocketPosition?.('input', toPortIndex, toPortTotal, toSize);
|
|
1026
|
-
toAngleDeg = pos?.angle ?? 180;
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
const dist = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
|
1030
|
-
const cpLen = Math.max(50, dist * 0.4);
|
|
1031
|
-
const fromRad = (fromAngleDeg * Math.PI) / 180;
|
|
1032
|
-
const toRad = (toAngleDeg * Math.PI) / 180;
|
|
1033
|
-
|
|
1034
|
-
const cp1x = startX + Math.cos(fromRad) * cpLen;
|
|
1035
|
-
const cp1y = startY + Math.sin(fromRad) * cpLen;
|
|
1036
|
-
const cp2x = endX + Math.cos(toRad) * cpLen;
|
|
1037
|
-
const cp2y = endY + Math.sin(toRad) * cpLen;
|
|
1038
|
-
|
|
1039
|
-
d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
let path = this.#svgLayer.querySelector(`[data-conn-id="${conn.id}"]`);
|
|
1043
|
-
if (!path) {
|
|
1044
|
-
path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1045
|
-
path.setAttribute('class', 'sn-conn-path');
|
|
1046
|
-
path.setAttribute('data-conn-id', conn.id);
|
|
1047
|
-
path.addEventListener('click', (e) => {
|
|
1048
|
-
e.stopPropagation();
|
|
1049
|
-
this.#onConnectionClick(conn.id, e);
|
|
1050
|
-
});
|
|
1051
|
-
this.#svgLayer.appendChild(path);
|
|
1052
|
-
}
|
|
1053
|
-
path.setAttribute('d', d);
|
|
1054
|
-
|
|
1055
|
-
// Wire type styling — thicker for exec, normal for data
|
|
1056
|
-
const fromSocketName = fromNode?.outputs[conn.out]?.socket?.name || 'data';
|
|
1057
|
-
if (fromSocketName === 'exec' || fromSocketName === 'execution' || fromSocketName === 'trigger') {
|
|
1058
|
-
path.setAttribute('data-wire-type', 'exec');
|
|
1059
|
-
path.style.strokeWidth = '3';
|
|
1060
|
-
path.style.strokeDasharray = '8 4';
|
|
1061
|
-
} else if (fromSocketName === 'array' || fromSocketName === 'object' || fromSocketName === 'json') {
|
|
1062
|
-
path.setAttribute('data-wire-type', 'data-heavy');
|
|
1063
|
-
path.style.strokeWidth = '2.5';
|
|
1064
|
-
path.style.strokeDasharray = '';
|
|
1065
|
-
} else {
|
|
1066
|
-
path.removeAttribute('data-wire-type');
|
|
1067
|
-
path.style.strokeWidth = '';
|
|
1068
|
-
path.style.strokeDasharray = '';
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Gradient connection coloring
|
|
1072
|
-
this.#applyGradient(path, conn, fromNode, toNode, startX, startY, endX, endY);
|
|
1073
|
-
|
|
1074
|
-
// Determine socket type for visual dot styling
|
|
1075
|
-
const outSocketName = fromNode?.outputs?.[conn.out]?.socket?.name || 'data';
|
|
1076
|
-
const inSocketName = toNode?.inputs?.[conn.in]?.socket?.name || outSocketName;
|
|
1077
|
-
|
|
1078
|
-
// Endpoint dots with side and type coloring
|
|
1079
|
-
this.#updateDot(conn.id, 'start', startX, startY, 'output', outSocketName);
|
|
1080
|
-
this.#updateDot(conn.id, 'end', endX, endY, 'input', inSocketName);
|
|
1081
|
-
|
|
1082
|
-
// Direction arrow at wire midpoint
|
|
1083
|
-
this.#updateArrow(conn.id, d);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/**
|
|
1087
|
-
* Create or update a small circle dot at a connector endpoint
|
|
1088
|
-
* @param {string} connId
|
|
1089
|
-
* @param {'start'|'end'} end
|
|
1090
|
-
* @param {number} x
|
|
1091
|
-
* @param {number} y
|
|
1092
|
-
* @param {'input'|'output'} side
|
|
1093
|
-
* @param {string} socketType
|
|
1094
|
-
*/
|
|
1095
|
-
#updateDot(connId, end, x, y, side = 'output', socketType = 'data') {
|
|
1096
|
-
const dotId = `${connId}-${end}`;
|
|
1097
|
-
let dot = this.#dotLayer.querySelector(`[data-conn-dot="${dotId}"]`);
|
|
1098
|
-
if (!dot) {
|
|
1099
|
-
dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
1100
|
-
dot.setAttribute('data-conn-dot', dotId);
|
|
1101
|
-
dot.setAttribute('r', '5');
|
|
1102
|
-
this.#dotLayer.appendChild(dot);
|
|
1103
|
-
}
|
|
1104
|
-
dot.setAttribute('cx', x);
|
|
1105
|
-
dot.setAttribute('cy', y);
|
|
1106
|
-
|
|
1107
|
-
// Dots are hidden by default (CSS). Only show for SVG nodes.
|
|
1108
|
-
// Runs on every update to handle timing — NodeViewManager may set
|
|
1109
|
-
// data-svg-shape after initial connection render
|
|
1110
|
-
if (!dot.hasAttribute('data-svg-wired')) {
|
|
1111
|
-
const conn = this.#connectionData.get(connId);
|
|
1112
|
-
if (conn) {
|
|
1113
|
-
const nodeId = end === 'start' ? conn.from : conn.to;
|
|
1114
|
-
const nodeEl = this.#nodeViews.get(nodeId);
|
|
1115
|
-
if (nodeEl?.hasAttribute('data-svg-shape')) {
|
|
1116
|
-
dot.setAttribute('data-svg-wired', '');
|
|
1117
|
-
dot.style.display = '';
|
|
1118
|
-
if (this.#onDotDrag) {
|
|
1119
|
-
dot.style.pointerEvents = 'auto';
|
|
1120
|
-
dot.style.cursor = 'crosshair';
|
|
1121
|
-
dot.addEventListener('pointerdown', (e) => {
|
|
1122
|
-
e.stopPropagation();
|
|
1123
|
-
e.preventDefault();
|
|
1124
|
-
const dotX = parseFloat(dot.getAttribute('cx')) || 0;
|
|
1125
|
-
const dotY = parseFloat(dot.getAttribute('cy')) || 0;
|
|
1126
|
-
const socketData = end === 'start'
|
|
1127
|
-
? { nodeId: conn.from, key: conn.out, side: 'output', worldX: dotX, worldY: dotY }
|
|
1128
|
-
: { nodeId: conn.to, key: conn.in, side: 'input', worldX: dotX, worldY: dotY };
|
|
1129
|
-
this.#onDotDrag(socketData);
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// Classify socket type
|
|
1137
|
-
let typeClass = 'sn-dot-data';
|
|
1138
|
-
if (socketType === 'exec' || socketType === 'execution' || socketType === 'trigger') {
|
|
1139
|
-
typeClass = 'sn-dot-exec';
|
|
1140
|
-
} else if (socketType === 'ctrl' || socketType === 'control' || socketType === 'signal') {
|
|
1141
|
-
typeClass = 'sn-dot-ctrl';
|
|
1142
|
-
}
|
|
1143
|
-
const sideClass = side === 'input' ? 'sn-dot-input' : 'sn-dot-output';
|
|
1144
|
-
dot.setAttribute('class', `sn-conn-dot ${sideClass} ${typeClass}`);
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* Create or update a direction arrow at the midpoint of a bezier path
|
|
1149
|
-
* @param {string} connId
|
|
1150
|
-
* @param {string} pathD - SVG path d attribute
|
|
1151
|
-
*/
|
|
1152
|
-
#updateArrow(connId, pathD) {
|
|
1153
|
-
// Universal midpoint calculation using SVG path API (works for all path styles)
|
|
1154
|
-
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1155
|
-
tempPath.setAttribute('d', pathD);
|
|
1156
|
-
|
|
1157
|
-
// Need to briefly attach to DOM for getPointAtLength to work
|
|
1158
|
-
this.#svgLayer.appendChild(tempPath);
|
|
1159
|
-
const totalLen = tempPath.getTotalLength();
|
|
1160
|
-
if (totalLen < 1) {
|
|
1161
|
-
tempPath.remove();
|
|
1162
|
-
return;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// Midpoint at 50% of path length
|
|
1166
|
-
const mid = tempPath.getPointAtLength(totalLen * 0.5);
|
|
1167
|
-
|
|
1168
|
-
// Tangent: sample two close points (0.5% before/after midpoint)
|
|
1169
|
-
const delta = Math.max(0.5, totalLen * 0.005);
|
|
1170
|
-
const p1 = tempPath.getPointAtLength(Math.max(0, totalLen * 0.5 - delta));
|
|
1171
|
-
const p2 = tempPath.getPointAtLength(Math.min(totalLen, totalLen * 0.5 + delta));
|
|
1172
|
-
tempPath.remove();
|
|
1173
|
-
|
|
1174
|
-
const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
|
|
1175
|
-
|
|
1176
|
-
let arrow = this.#svgLayer.querySelector(`[data-conn-arrow="${connId}"]`);
|
|
1177
|
-
if (!arrow) {
|
|
1178
|
-
arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
1179
|
-
arrow.setAttribute('data-conn-arrow', connId);
|
|
1180
|
-
arrow.setAttribute('class', 'sn-conn-arrow');
|
|
1181
|
-
arrow.setAttribute('points', '-5,-3.5 5,0 -5,3.5');
|
|
1182
|
-
this.#svgLayer.appendChild(arrow);
|
|
1183
|
-
}
|
|
1184
|
-
arrow.setAttribute('transform', `translate(${mid.x},${mid.y}) rotate(${angle})`);
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
/**
|
|
1188
|
-
* Apply socket-color gradient to connection path
|
|
1189
|
-
* @param {SVGPathElement} path
|
|
1190
|
-
* @param {import('../core/Connection.js').Connection} conn
|
|
1191
|
-
* @param {import('../core/Node.js').Node|undefined} fromNode
|
|
1192
|
-
* @param {import('../core/Node.js').Node|undefined} toNode
|
|
1193
|
-
* @param {number} startX
|
|
1194
|
-
* @param {number} startY
|
|
1195
|
-
* @param {number} endX
|
|
1196
|
-
* @param {number} endY
|
|
1197
|
-
*/
|
|
1198
|
-
#applyGradient(path, conn, fromNode, toNode, startX, startY, endX, endY) {
|
|
1199
|
-
const fromColor = fromNode?.outputs[conn.out]?.socket?.color;
|
|
1200
|
-
const toColor = toNode?.inputs[conn.in]?.socket?.color;
|
|
1201
|
-
|
|
1202
|
-
if (fromColor && toColor && fromColor !== toColor) {
|
|
1203
|
-
const gradId = `grad-${conn.id}`;
|
|
1204
|
-
let defs = this.#svgLayer.querySelector('defs');
|
|
1205
|
-
if (!defs) {
|
|
1206
|
-
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
1207
|
-
this.#svgLayer.prepend(defs);
|
|
1208
|
-
}
|
|
1209
|
-
let grad = defs.querySelector(`#${gradId}`);
|
|
1210
|
-
if (!grad) {
|
|
1211
|
-
grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
|
|
1212
|
-
grad.setAttribute('id', gradId);
|
|
1213
|
-
const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
|
1214
|
-
stop1.setAttribute('offset', '0%');
|
|
1215
|
-
const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
|
|
1216
|
-
stop2.setAttribute('offset', '100%');
|
|
1217
|
-
grad.appendChild(stop1);
|
|
1218
|
-
grad.appendChild(stop2);
|
|
1219
|
-
defs.appendChild(grad);
|
|
1220
|
-
}
|
|
1221
|
-
grad.setAttribute('gradientUnits', 'userSpaceOnUse');
|
|
1222
|
-
grad.setAttribute('x1', String(startX));
|
|
1223
|
-
grad.setAttribute('y1', String(startY));
|
|
1224
|
-
grad.setAttribute('x2', String(endX));
|
|
1225
|
-
grad.setAttribute('y2', String(endY));
|
|
1226
|
-
grad.children[0].setAttribute('stop-color', fromColor);
|
|
1227
|
-
grad.children[1].setAttribute('stop-color', toColor);
|
|
1228
|
-
path.setAttribute('stroke', `url(#${gradId})`);
|
|
1229
|
-
} else if (fromColor) {
|
|
1230
|
-
path.setAttribute('stroke', fromColor);
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
/**
|
|
1234
|
-
* Render persistent free dots for all ports of an SVG node.
|
|
1235
|
-
* Called after SVG shape setup. Free dots are interactive drag sources.
|
|
1236
|
-
* @param {string} nodeId
|
|
1237
|
-
*/
|
|
1238
|
-
renderFreeDots(nodeId) {
|
|
1239
|
-
const nodeEl = this.#nodeViews.get(nodeId);
|
|
1240
|
-
const node = this.#editor.getNode(nodeId);
|
|
1241
|
-
if (!nodeEl || !node) return;
|
|
1242
|
-
|
|
1243
|
-
const shapeName = nodeEl.getAttribute('data-svg-shape') || nodeEl.getAttribute('node-shape');
|
|
1244
|
-
const shape = getShape(shapeName);
|
|
1245
|
-
if (!shape?.pathData || !shape.getEdgePoint) return;
|
|
1246
|
-
|
|
1247
|
-
const size = { width: nodeEl.offsetWidth || 100, height: nodeEl.offsetHeight || 100 };
|
|
1248
|
-
const pos = nodeEl._position;
|
|
1249
|
-
if (!pos) return;
|
|
1250
|
-
|
|
1251
|
-
// Collect already-connected port keys
|
|
1252
|
-
const connectedPorts = new Set();
|
|
1253
|
-
for (const [, conn] of this.#connectionData) {
|
|
1254
|
-
if (conn.from === nodeId) connectedPorts.add(`output:${conn.out}`);
|
|
1255
|
-
if (conn.to === nodeId) connectedPorts.add(`input:${conn.in}`);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// Ensure collision tracking exists
|
|
1259
|
-
if (!nodeEl._usedCoords) nodeEl._usedCoords = [];
|
|
1260
|
-
const MIN_PIX = 12;
|
|
1261
|
-
const step = Math.PI / 12; // 15° grid
|
|
1262
|
-
|
|
1263
|
-
// Place free dots using edge-point system (same as connections)
|
|
1264
|
-
const placeDot = (key, side, baseAngle, portData) => {
|
|
1265
|
-
// Find a free position using collision avoidance
|
|
1266
|
-
let angle = Math.round(baseAngle / step) * step;
|
|
1267
|
-
let nudged = angle;
|
|
1268
|
-
let attempts = 0;
|
|
1269
|
-
|
|
1270
|
-
while (attempts < 24) {
|
|
1271
|
-
const testPos = shape.getEdgePoint(nudged, size);
|
|
1272
|
-
const tooClose = nodeEl._usedCoords.some(
|
|
1273
|
-
c => Math.abs(testPos.x - c.x) < MIN_PIX && Math.abs(testPos.y - c.y) < MIN_PIX
|
|
1274
|
-
);
|
|
1275
|
-
if (!tooClose) break;
|
|
1276
|
-
attempts++;
|
|
1277
|
-
const offset = Math.ceil(attempts / 2) * step;
|
|
1278
|
-
const dir = (attempts % 2 === 1) ? 1 : -1;
|
|
1279
|
-
nudged = angle + dir * offset;
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
const ep = shape.getEdgePoint(nudged, size);
|
|
1283
|
-
nodeEl._usedCoords.push({ x: ep.x, y: ep.y });
|
|
1284
|
-
|
|
1285
|
-
this.#createFreeDot(nodeId, key, side, pos.x + ep.x, pos.y + ep.y, portData);
|
|
1286
|
-
};
|
|
1287
|
-
|
|
1288
|
-
// Render dots for unconnected inputs (left side baseline angle = π)
|
|
1289
|
-
const inputKeys = Object.keys(node.inputs);
|
|
1290
|
-
inputKeys.forEach((key, i) => {
|
|
1291
|
-
if (connectedPorts.has(`input:${key}`)) return;
|
|
1292
|
-
const spread = Math.PI * 0.4;
|
|
1293
|
-
const baseAngle = Math.PI + (inputKeys.length > 1
|
|
1294
|
-
? (i / (inputKeys.length - 1) - 0.5) * spread
|
|
1295
|
-
: 0);
|
|
1296
|
-
placeDot(key, 'input', baseAngle, node.inputs[key]);
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
// Render dots for unconnected outputs (right side baseline angle = 0)
|
|
1300
|
-
const outputKeys = Object.keys(node.outputs);
|
|
1301
|
-
outputKeys.forEach((key, i) => {
|
|
1302
|
-
if (connectedPorts.has(`output:${key}`)) return;
|
|
1303
|
-
const spread = Math.PI * 0.4;
|
|
1304
|
-
const baseAngle = 0 + (outputKeys.length > 1
|
|
1305
|
-
? (i / (outputKeys.length - 1) - 0.5) * spread
|
|
1306
|
-
: 0);
|
|
1307
|
-
placeDot(key, 'output', baseAngle, node.outputs[key]);
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
/**
|
|
1312
|
-
* Create a single free dot element
|
|
1313
|
-
* @param {string} nodeId
|
|
1314
|
-
* @param {string} key
|
|
1315
|
-
* @param {'input'|'output'} side
|
|
1316
|
-
* @param {number} wx - world X
|
|
1317
|
-
* @param {number} wy - world Y
|
|
1318
|
-
* @param {object} portData - Input/Output instance
|
|
1319
|
-
*/
|
|
1320
|
-
#createFreeDot(nodeId, key, side, wx, wy, portData) {
|
|
1321
|
-
const dotId = `free-${nodeId}-${side}-${key}`;
|
|
1322
|
-
// Skip if already exists
|
|
1323
|
-
if (this.#dotLayer.querySelector(`[data-free-dot="${dotId}"]`)) return;
|
|
1324
|
-
|
|
1325
|
-
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
1326
|
-
dot.setAttribute('data-free-dot', dotId);
|
|
1327
|
-
dot.setAttribute('data-node-id', nodeId);
|
|
1328
|
-
dot.setAttribute('data-port-key', key);
|
|
1329
|
-
dot.setAttribute('data-port-side', side);
|
|
1330
|
-
dot.setAttribute('r', '4');
|
|
1331
|
-
dot.setAttribute('cx', wx);
|
|
1332
|
-
dot.setAttribute('cy', wy);
|
|
1333
|
-
dot.style.pointerEvents = 'auto';
|
|
1334
|
-
dot.style.cursor = 'crosshair';
|
|
1335
|
-
|
|
1336
|
-
const socketName = portData?.socket?.name || 'data';
|
|
1337
|
-
let typeClass = 'sn-dot-data';
|
|
1338
|
-
if (socketName === 'exec' || socketName === 'execution' || socketName === 'trigger') {
|
|
1339
|
-
typeClass = 'sn-dot-exec';
|
|
1340
|
-
} else if (socketName === 'ctrl' || socketName === 'control' || socketName === 'signal') {
|
|
1341
|
-
typeClass = 'sn-dot-ctrl';
|
|
1342
|
-
}
|
|
1343
|
-
const sideClass = side === 'input' ? 'sn-dot-input' : 'sn-dot-output';
|
|
1344
|
-
dot.setAttribute('class', `sn-free-dot ${sideClass} ${typeClass}`);
|
|
1345
|
-
|
|
1346
|
-
if (this.#onDotDrag) {
|
|
1347
|
-
dot.addEventListener('pointerdown', (e) => {
|
|
1348
|
-
e.stopPropagation();
|
|
1349
|
-
e.preventDefault();
|
|
1350
|
-
this.#onDotDrag({
|
|
1351
|
-
nodeId,
|
|
1352
|
-
key,
|
|
1353
|
-
side,
|
|
1354
|
-
worldX: parseFloat(dot.getAttribute('cx')) || 0,
|
|
1355
|
-
worldY: parseFloat(dot.getAttribute('cy')) || 0,
|
|
1356
|
-
});
|
|
1357
|
-
});
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
this.#dotLayer.appendChild(dot);
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
/**
|
|
1364
|
-
* Remove free dot when a connection fills this port
|
|
1365
|
-
* @param {string} nodeId
|
|
1366
|
-
* @param {string} key
|
|
1367
|
-
* @param {'input'|'output'} side
|
|
1368
|
-
*/
|
|
1369
|
-
removeFreeDot(nodeId, key, side) {
|
|
1370
|
-
const dotId = `free-${nodeId}-${side}-${key}`;
|
|
1371
|
-
const dot = this.#dotLayer.querySelector(`[data-free-dot="${dotId}"]`);
|
|
1372
|
-
if (dot) dot.remove();
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
/**
|
|
1376
|
-
* Refresh free dot positions after node move (updates coords without recreating)
|
|
1377
|
-
* @param {string} nodeId
|
|
1378
|
-
*/
|
|
1379
|
-
refreshFreeDots(nodeId) {
|
|
1380
|
-
const dots = this.#dotLayer.querySelectorAll(`[data-node-id="${nodeId}"][data-free-dot]`);
|
|
1381
|
-
if (!dots.length) {
|
|
1382
|
-
// No dots yet — initial render (position was likely missing at shape setup time)
|
|
1383
|
-
this.renderFreeDots(nodeId);
|
|
1384
|
-
return;
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
const nodeEl = this.#nodeViews.get(nodeId);
|
|
1388
|
-
const node = this.#editor.getNode(nodeId);
|
|
1389
|
-
if (!nodeEl || !node) return;
|
|
1390
|
-
|
|
1391
|
-
const shapeName = nodeEl.getAttribute('data-svg-shape') || nodeEl.getAttribute('node-shape');
|
|
1392
|
-
const shape = getShape(shapeName);
|
|
1393
|
-
if (!shape?.pathData) return;
|
|
1394
|
-
|
|
1395
|
-
const size = { width: nodeEl.offsetWidth || 100, height: nodeEl.offsetHeight || 100 };
|
|
1396
|
-
const pos = nodeEl._position;
|
|
1397
|
-
if (!pos) return;
|
|
1398
|
-
|
|
1399
|
-
for (const dot of dots) {
|
|
1400
|
-
const key = dot.getAttribute('data-port-key');
|
|
1401
|
-
const side = dot.getAttribute('data-port-side');
|
|
1402
|
-
const ports = side === 'output' ? node.outputs : node.inputs;
|
|
1403
|
-
const keys = Object.keys(ports);
|
|
1404
|
-
const index = keys.indexOf(key);
|
|
1405
|
-
if (index < 0) continue;
|
|
1406
|
-
const sp = shape.getSocketPosition(side, index, keys.length, size);
|
|
1407
|
-
dot.setAttribute('cx', pos.x + sp.x);
|
|
1408
|
-
dot.setAttribute('cy', pos.y + sp.y);
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
/**
|
|
1413
|
-
* Find nearest SVG dot (free or connected) to world position within radius.
|
|
1414
|
-
* Used as drop target for connections.
|
|
1415
|
-
* @param {number} wx - world X
|
|
1416
|
-
* @param {number} wy - world Y
|
|
1417
|
-
* @param {number} [radius=20] - search radius in world units
|
|
1418
|
-
* @returns {{ nodeId: string, key: string, side: string }|null}
|
|
1419
|
-
*/
|
|
1420
|
-
findNearestDot(wx, wy, radius = 20) {
|
|
1421
|
-
let bestDist = radius;
|
|
1422
|
-
let best = null;
|
|
1423
|
-
|
|
1424
|
-
// Search free dots
|
|
1425
|
-
const freeDots = this.#dotLayer.querySelectorAll('[data-free-dot]');
|
|
1426
|
-
for (const dot of freeDots) {
|
|
1427
|
-
const cx = parseFloat(dot.getAttribute('cx')) || 0;
|
|
1428
|
-
const cy = parseFloat(dot.getAttribute('cy')) || 0;
|
|
1429
|
-
const dist = Math.hypot(cx - wx, cy - wy);
|
|
1430
|
-
if (dist < bestDist) {
|
|
1431
|
-
bestDist = dist;
|
|
1432
|
-
best = {
|
|
1433
|
-
nodeId: dot.getAttribute('data-node-id'),
|
|
1434
|
-
key: dot.getAttribute('data-port-key'),
|
|
1435
|
-
side: dot.getAttribute('data-port-side'),
|
|
1436
|
-
};
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
// Search connected SVG dots
|
|
1441
|
-
const wiredDots = this.#dotLayer.querySelectorAll('[data-svg-wired=""]');
|
|
1442
|
-
for (const dot of wiredDots) {
|
|
1443
|
-
const cx = parseFloat(dot.getAttribute('cx')) || 0;
|
|
1444
|
-
const cy = parseFloat(dot.getAttribute('cy')) || 0;
|
|
1445
|
-
const dist = Math.hypot(cx - wx, cy - wy);
|
|
1446
|
-
if (dist < bestDist) {
|
|
1447
|
-
bestDist = dist;
|
|
1448
|
-
const connDotId = dot.getAttribute('data-conn-dot');
|
|
1449
|
-
// Parse connDotId: "connId-start" or "connId-end"
|
|
1450
|
-
const isStart = connDotId.endsWith('-start');
|
|
1451
|
-
const connId = connDotId.replace(/-(?:start|end)$/, '');
|
|
1452
|
-
const conn = this.#connectionData.get(connId);
|
|
1453
|
-
if (conn) {
|
|
1454
|
-
best = {
|
|
1455
|
-
nodeId: isStart ? conn.from : conn.to,
|
|
1456
|
-
key: isStart ? conn.out : conn.in,
|
|
1457
|
-
side: isStart ? 'output' : 'input',
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
return best;
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
/** @type {boolean} Set to true to enable debug logging for pin placement and routing */
|
|
1468
|
-
ConnectionRenderer.debug = false;
|