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,1666 @@
|
|
|
1
|
+
import Symbiote from '@symbiotejs/symbiote';
|
|
2
|
+
|
|
3
|
+
const INIT_NODE_COUNT = 40;
|
|
4
|
+
const EDGE_RATIO = 1.2;
|
|
5
|
+
const DOT_RADIUS = 6;
|
|
6
|
+
const HIT_RADIUS = 14;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Universal node radius calculator.
|
|
10
|
+
* @param {object} node - Node object with isGroup, children, aScale
|
|
11
|
+
* @param {number} conns - Number of connections (from adjMap)
|
|
12
|
+
* @param {object} [opts] - Options
|
|
13
|
+
* @param {number} [opts.scale] - Override aScale (default: node.aScale || 1)
|
|
14
|
+
* @returns {number} Visual radius in world units
|
|
15
|
+
*/
|
|
16
|
+
function getNodeRadius(node, conns, opts = {}) {
|
|
17
|
+
const hubScale = 1 + Math.min(conns, 8) * 0.1;
|
|
18
|
+
const aScale = opts.scale ?? (node.aScale || 1);
|
|
19
|
+
let r = DOT_RADIUS * hubScale * aScale;
|
|
20
|
+
if (node.isGroup) {
|
|
21
|
+
const childCount = node.children?.length || 1;
|
|
22
|
+
r *= 1.0 + Math.sqrt(Math.max(1, Math.min(childCount, 25))) * 0.5;
|
|
23
|
+
}
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const NODE_TYPES = ['data', 'action', 'output', 'config', 'external', 'style', 'docs', 'asset'];
|
|
28
|
+
const TYPE_COLORS = {
|
|
29
|
+
action: [255, 150, 140], // Soft coral (JS/TS logic)
|
|
30
|
+
output: [120, 210, 170], // Sage green (HTML/Entry/UI)
|
|
31
|
+
data: [120, 180, 255], // Pastel blue (JSON data)
|
|
32
|
+
config: [255, 200, 120], // Warm amber (Configs, Env)
|
|
33
|
+
external: [190, 150, 255], // Lavender (Tests, Specs)
|
|
34
|
+
style: [255, 180, 220], // Pastel pink (CSS/SCSS)
|
|
35
|
+
docs: [200, 210, 215], // Slate/grey-blue (MD/TXT)
|
|
36
|
+
asset: [150, 230, 230], // Mint/Cyan (SVG/PNG)
|
|
37
|
+
group: [230, 180, 110], // Golden pastel orange
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MENU_ITEMS = [
|
|
41
|
+
{ action: 'drill', label: 'Enter Group', path: 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' },
|
|
42
|
+
{ action: 'explore', label: 'Explore', path: 'M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z' },
|
|
43
|
+
{ action: 'view-code', label: 'View Code', path: 'M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0L19.2 12l-4.6-4.6L16 6l6 6-6 6-1.4-1.4z' },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export class CanvasGraph extends Symbiote {
|
|
47
|
+
init$ = {
|
|
48
|
+
// These defaults will be updated from external controller if needed
|
|
49
|
+
chargeStrength: -150,
|
|
50
|
+
linkDistance: 150,
|
|
51
|
+
linkStrength: 0.25,
|
|
52
|
+
centerStrength: 0,
|
|
53
|
+
velocityDecay: 0.92,
|
|
54
|
+
collideStrength: 1.0,
|
|
55
|
+
alphaDecay: 0.015,
|
|
56
|
+
theta: 0.7,
|
|
57
|
+
alphaFloor: 0.0001,
|
|
58
|
+
alphaTarget: 0.0001,
|
|
59
|
+
brownian: 0,
|
|
60
|
+
brownianThresh: 0.001,
|
|
61
|
+
pinReheat: 0.02,
|
|
62
|
+
pinCap: 0.08,
|
|
63
|
+
wellStrength: 0.8,
|
|
64
|
+
centerPull: 0.3,
|
|
65
|
+
wellRepulsion: 5.0,
|
|
66
|
+
crossLinkScale: 0.2,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
_bgR = 15;
|
|
70
|
+
_bgG = 23;
|
|
71
|
+
_bgB = 42;
|
|
72
|
+
_ghostColor = 'rgb(22,30,50)';
|
|
73
|
+
|
|
74
|
+
initCallback() {
|
|
75
|
+
this.nodes = [];
|
|
76
|
+
this.edges = [];
|
|
77
|
+
this.nodeMap = new Map();
|
|
78
|
+
this.adjMap = new Map();
|
|
79
|
+
this.interactionDepths = new Map();
|
|
80
|
+
this.nodePositions = new Map();
|
|
81
|
+
this.nodeIds = [];
|
|
82
|
+
|
|
83
|
+
this.worker = null;
|
|
84
|
+
this.paused = false;
|
|
85
|
+
this.dragNode = null;
|
|
86
|
+
this.activeNode = null;
|
|
87
|
+
this.hoverNode = null;
|
|
88
|
+
this.nextActiveNode = null;
|
|
89
|
+
this.deactivating = false;
|
|
90
|
+
this.menuAnim = 0;
|
|
91
|
+
this.dragOffset = { x: 0, y: 0 };
|
|
92
|
+
this.renderMode = 'dots';
|
|
93
|
+
|
|
94
|
+
this.focusX = 0;
|
|
95
|
+
this.focusY = 0;
|
|
96
|
+
this.focusActive = false;
|
|
97
|
+
|
|
98
|
+
this.panX = 0;
|
|
99
|
+
this.panY = 0;
|
|
100
|
+
this.zoom = 0.5;
|
|
101
|
+
this._targetZoom = 0.5;
|
|
102
|
+
this._targetPanX = null; // null = no animation target
|
|
103
|
+
this._targetPanY = null;
|
|
104
|
+
this._zoomAnchor = null; // {mx, my} — screen point to keep stable during zoom
|
|
105
|
+
this.isPanning = false;
|
|
106
|
+
this.panStart = { x: 0, y: 0, px: 0, py: 0 };
|
|
107
|
+
|
|
108
|
+
this.frameCount = 0;
|
|
109
|
+
this.tickCount = 0;
|
|
110
|
+
this.lastFpsTime = performance.now();
|
|
111
|
+
this.lastAlpha = 0;
|
|
112
|
+
|
|
113
|
+
this.smoothPositions = new Map();
|
|
114
|
+
this.prevPositions = new Map();
|
|
115
|
+
this.smoothing = 0.99;
|
|
116
|
+
|
|
117
|
+
this.graphDB = { nodes: new Map(), edges: [], rootNodes: [] };
|
|
118
|
+
this.currentGroupId = null;
|
|
119
|
+
this._loopRunning = false; // Whether the rAF draw loop is active
|
|
120
|
+
this._idleFrames = 0; // Count consecutive frames with no visual change
|
|
121
|
+
this._prevDragDeltaX = 0; // Previous frame's focus drag delta X
|
|
122
|
+
this._prevDragDeltaY = 0; // Previous frame's focus drag delta Y
|
|
123
|
+
this._skeleton = null; // Skeleton data reference for metadata
|
|
124
|
+
|
|
125
|
+
// Info panel state (typewriter HUD to the right of active node)
|
|
126
|
+
this._infoPanel = {
|
|
127
|
+
nodeId: null,
|
|
128
|
+
lines: [],
|
|
129
|
+
opacity: 0,
|
|
130
|
+
startTime: 0,
|
|
131
|
+
totalExtent: 0,
|
|
132
|
+
totalExtentY: 0,
|
|
133
|
+
_centeredForNode: null, // Track which node we've centered for
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
this.canvas = document.createElement('canvas');
|
|
137
|
+
this.appendChild(this.canvas);
|
|
138
|
+
this.ctx = this.canvas.getContext('2d');
|
|
139
|
+
|
|
140
|
+
this.offscreenCanvases = {};
|
|
141
|
+
for (let i = 1; i <= 4; i++) {
|
|
142
|
+
const oc = document.createElement('canvas');
|
|
143
|
+
this.offscreenCanvases[i] = { canvas: oc, ctx: oc.getContext('2d', { alpha: true }) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.layerAnim = {
|
|
147
|
+
0: { scale: 1, opacity: 1, parallax: 0 },
|
|
148
|
+
1: { scale: 1, opacity: 1, parallax: 0 },
|
|
149
|
+
2: { scale: 1, opacity: 1, parallax: 0 },
|
|
150
|
+
3: { scale: 1, opacity: 1, parallax: 0 },
|
|
151
|
+
4: { scale: 1, opacity: 1, parallax: 0 }
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
this.LAYER_TARGETS = {
|
|
155
|
+
scale: [1.12, 1.0, 0.95, 0.88, 0.78],
|
|
156
|
+
opacity: [1.0, 0.9, 0.55, 0.06, 0.03],
|
|
157
|
+
blur: [0, 0, 1, 3, 5],
|
|
158
|
+
parallax: [0, 0, 0.02, 0.04, 0.07]
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
this.depthGroups = {
|
|
162
|
+
0: { edges: [], nodes: [] },
|
|
163
|
+
1: { edges: [], nodes: [] },
|
|
164
|
+
2: { edges: [], nodes: [] },
|
|
165
|
+
3: { edges: [], nodes: [] },
|
|
166
|
+
4: { edges: [], nodes: [] }
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const resizeObserver = new ResizeObserver(() => this.resizeCanvas());
|
|
170
|
+
resizeObserver.observe(this);
|
|
171
|
+
this.resizeCanvas();
|
|
172
|
+
|
|
173
|
+
this.bindEvents();
|
|
174
|
+
|
|
175
|
+
this._wakeLoop();
|
|
176
|
+
|
|
177
|
+
// Bind existing graph-breadcrumb from symbiote-node
|
|
178
|
+
if (this.ref.breadcrumb) {
|
|
179
|
+
this.ref.breadcrumb.onNavigate((levelStr) => {
|
|
180
|
+
// levelStr is the path string we passed into 'level' property
|
|
181
|
+
this.setPath(levelStr || null);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
let rawBg = getComputedStyle(document.body).getPropertyValue('--sn-bg').trim();
|
|
187
|
+
if (!rawBg) rawBg = getComputedStyle(document.body).backgroundColor;
|
|
188
|
+
|
|
189
|
+
// Robust way to parse ANY color in browser
|
|
190
|
+
const tempCtx = document.createElement('canvas').getContext('2d');
|
|
191
|
+
tempCtx.fillStyle = '#1a1a1a'; // fallback
|
|
192
|
+
tempCtx.fillStyle = rawBg;
|
|
193
|
+
this._bgR = 26; this._bgG = 26; this._bgB = 26; // Default
|
|
194
|
+
|
|
195
|
+
if (tempCtx.fillStyle.startsWith('#')) {
|
|
196
|
+
const hex = tempCtx.fillStyle;
|
|
197
|
+
this._bgR = parseInt(hex.length === 4 ? hex[1]+hex[1] : hex.slice(1,3), 16);
|
|
198
|
+
this._bgG = parseInt(hex.length === 4 ? hex[2]+hex[2] : hex.slice(3,5), 16);
|
|
199
|
+
this._bgB = parseInt(hex.length === 4 ? hex[3]+hex[3] : hex.slice(5,7), 16);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// If the background is extremely dark, we need a larger boost to be visible
|
|
203
|
+
const boost = 25;
|
|
204
|
+
this._ghostColor = `rgb(${Math.min(255, this._bgR + boost)}, ${Math.min(255, this._bgG + boost)}, ${Math.min(255, this._bgB + boost)})`;
|
|
205
|
+
}, 100);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
disconnectedCallback() {
|
|
209
|
+
this._loopRunning = false;
|
|
210
|
+
if (this._animationFrame) cancelAnimationFrame(this._animationFrame);
|
|
211
|
+
if (this.worker) this.worker.terminate();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Ensure the rAF draw loop is running. Safe to call repeatedly.
|
|
216
|
+
* Called by all state-changing entry points (interaction, worker, resize).
|
|
217
|
+
*/
|
|
218
|
+
_wakeLoop() {
|
|
219
|
+
if (this._loopRunning) return;
|
|
220
|
+
this._loopRunning = true;
|
|
221
|
+
this._idleFrames = 0;
|
|
222
|
+
this._animationFrame = requestAnimationFrame(() => this.draw());
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
resizeCanvas() {
|
|
226
|
+
const dpr = window.devicePixelRatio || 1;
|
|
227
|
+
const rect = this.getBoundingClientRect();
|
|
228
|
+
this._wakeLoop(); // Dimensions changed — redraw
|
|
229
|
+
if (rect.width === 0) return;
|
|
230
|
+
this.canvas.style.width = rect.width + 'px';
|
|
231
|
+
this.canvas.style.height = rect.height + 'px';
|
|
232
|
+
this.canvas.width = rect.width * dpr;
|
|
233
|
+
this.canvas.height = rect.height * dpr;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
resetView() {
|
|
237
|
+
this.fitView();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fitView(padding = 60, animate = true) {
|
|
241
|
+
if (!this.nodePositions.size) return;
|
|
242
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
243
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
244
|
+
|
|
245
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
246
|
+
for (const pos of this.nodePositions.values()) {
|
|
247
|
+
if (pos.x < minX) minX = pos.x;
|
|
248
|
+
if (pos.y < minY) minY = pos.y;
|
|
249
|
+
if (pos.x > maxX) maxX = pos.x;
|
|
250
|
+
if (pos.y > maxY) maxY = pos.y;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const graphW = maxX - minX || 1;
|
|
254
|
+
const graphH = maxY - minY || 1;
|
|
255
|
+
const cx = (minX + maxX) / 2;
|
|
256
|
+
const cy = (minY + maxY) / 2;
|
|
257
|
+
|
|
258
|
+
const newZoom = Math.max(0.02, Math.min(
|
|
259
|
+
(rect.width - padding * 2) / graphW,
|
|
260
|
+
(rect.height - padding * 2) / graphH,
|
|
261
|
+
2.0
|
|
262
|
+
));
|
|
263
|
+
const newPanX = rect.width / 2 - cx * newZoom;
|
|
264
|
+
const newPanY = rect.height / 2 - cy * newZoom;
|
|
265
|
+
|
|
266
|
+
if (animate) {
|
|
267
|
+
this._targetZoom = newZoom;
|
|
268
|
+
this._targetPanX = newPanX;
|
|
269
|
+
this._targetPanY = newPanY;
|
|
270
|
+
this._zoomAnchor = null;
|
|
271
|
+
} else {
|
|
272
|
+
this.zoom = newZoom;
|
|
273
|
+
this._targetZoom = newZoom;
|
|
274
|
+
this.panX = newPanX;
|
|
275
|
+
this.panY = newPanY;
|
|
276
|
+
this._targetPanX = null;
|
|
277
|
+
this._targetPanY = null;
|
|
278
|
+
}
|
|
279
|
+
this.needsDraw = true;
|
|
280
|
+
this._wakeLoop();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
flyToNode(nodeId, options = {}) {
|
|
284
|
+
const node = this.graphDB?.nodes.get(nodeId);
|
|
285
|
+
if (node && node.parentId) {
|
|
286
|
+
if (node.parentId !== this.currentGroupId) {
|
|
287
|
+
this.loadLevel(node.parentId);
|
|
288
|
+
setTimeout(() => this.flyToNode(nodeId, options), 500);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const pos = this.getSmooth(nodeId) || this.nodePositions.get(nodeId);
|
|
294
|
+
if (!pos) return;
|
|
295
|
+
|
|
296
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
297
|
+
if (rect.width === 0) return;
|
|
298
|
+
|
|
299
|
+
// Set zoom target: use provided zoom level, or force a comfortable minimum for focus
|
|
300
|
+
const targetZoom = options.zoom || Math.max(1.2, Math.min(2.0, this.zoom));
|
|
301
|
+
this._targetZoom = targetZoom;
|
|
302
|
+
this._targetPanX = rect.width / 2 - pos.x * targetZoom;
|
|
303
|
+
this._targetPanY = rect.height / 2 - pos.y * targetZoom;
|
|
304
|
+
this._zoomAnchor = null;
|
|
305
|
+
|
|
306
|
+
// Activate the node
|
|
307
|
+
const foundNode = this.nodeMap?.get(nodeId);
|
|
308
|
+
if (foundNode) {
|
|
309
|
+
this.activeNode = foundNode;
|
|
310
|
+
this.updateInteractionDepths();
|
|
311
|
+
}
|
|
312
|
+
this.needsDraw = true;
|
|
313
|
+
this._wakeLoop();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
setPath(pathStr) {
|
|
317
|
+
if (!pathStr) {
|
|
318
|
+
if (this.currentGroupId) this.loadLevel(null);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The pathStr is exactly the group ID in our new universal routing model
|
|
323
|
+
if (pathStr !== this.currentGroupId) {
|
|
324
|
+
this.loadLevel(pathStr);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── HELPERS ───
|
|
329
|
+
_dirOf(filePath) {
|
|
330
|
+
const idx = filePath.lastIndexOf('/');
|
|
331
|
+
return idx >= 0 ? filePath.slice(0, idx + 1) : './';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_resolveImport(importPath, fromFile, knownFiles) {
|
|
335
|
+
if (knownFiles.has(importPath)) return importPath;
|
|
336
|
+
if (knownFiles.has(importPath + '.js')) return importPath + '.js';
|
|
337
|
+
if (importPath.startsWith('.')) {
|
|
338
|
+
const dir = this._dirOf(fromFile);
|
|
339
|
+
let resolved = dir + importPath.replace(/^\.\//, '');
|
|
340
|
+
const parts = resolved.split('/');
|
|
341
|
+
const normalized = [];
|
|
342
|
+
for (const part of parts) {
|
|
343
|
+
if (part === '..') normalized.pop();
|
|
344
|
+
else if (part !== '.') normalized.push(part);
|
|
345
|
+
}
|
|
346
|
+
resolved = normalized.join('/');
|
|
347
|
+
if (knownFiles.has(resolved)) return resolved;
|
|
348
|
+
if (knownFiles.has(resolved + '.js')) return resolved + '.js';
|
|
349
|
+
if (knownFiles.has(resolved + '/index.js')) return resolved + '/index.js';
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_classifyFile(file, classFiles) {
|
|
355
|
+
const name = file.split('/').pop().toLowerCase();
|
|
356
|
+
const ext = name.split('.').pop();
|
|
357
|
+
|
|
358
|
+
// Explicit file names
|
|
359
|
+
if (name.includes('test') || name.includes('spec')) return 'external';
|
|
360
|
+
if (name === 'index.js' || name === 'index.mjs') return 'output';
|
|
361
|
+
if (name === 'package.json' || name.startsWith('.env') || name.startsWith('.git')) return 'config';
|
|
362
|
+
|
|
363
|
+
// By extension
|
|
364
|
+
if (ext === 'css' || ext === 'scss' || ext === 'less') return 'style';
|
|
365
|
+
if (ext === 'html' || ext === 'tpl' || ext === 'vue' || ext === 'jsx' || ext === 'tsx') return 'output';
|
|
366
|
+
if (ext === 'json' || ext === 'yaml' || ext === 'yml' || ext === 'toml') return 'config';
|
|
367
|
+
if (ext === 'md' || ext === 'txt' || ext === 'csv') return 'docs';
|
|
368
|
+
if (ext === 'svg' || ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif' || ext === 'ico') return 'asset';
|
|
369
|
+
|
|
370
|
+
// Default code files
|
|
371
|
+
if (ext === 'js' || ext === 'ts' || ext === 'mjs' || ext === 'py' || ext === 'go' || ext === 'rs') return 'action';
|
|
372
|
+
|
|
373
|
+
if (classFiles.has(file)) return 'action';
|
|
374
|
+
return 'data';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── SKELETON PARSER ───
|
|
378
|
+
setSkeleton(skeleton) {
|
|
379
|
+
this._skeleton = skeleton;
|
|
380
|
+
this.graphDB = { nodes: new Map(), edges: [], rootNodes: [] };
|
|
381
|
+
const N = skeleton.n || {};
|
|
382
|
+
const X = skeleton.X || {};
|
|
383
|
+
const I = skeleton.I || {};
|
|
384
|
+
const L = skeleton.L || {};
|
|
385
|
+
|
|
386
|
+
// 1. Collect all known files
|
|
387
|
+
const allFiles = new Set();
|
|
388
|
+
const classFiles = new Set(); // files that have classes/exports
|
|
389
|
+
for (const data of Object.values(N)) {
|
|
390
|
+
if (data.f) { allFiles.add(data.f); classFiles.add(data.f); }
|
|
391
|
+
}
|
|
392
|
+
for (const file of Object.keys(X)) allFiles.add(file);
|
|
393
|
+
// skeleton.f = { "dirPath/": ["file1.js", ...] }
|
|
394
|
+
for (const [dir, names] of Object.entries(skeleton.f || {})) {
|
|
395
|
+
for (const name of names) allFiles.add(dir === './' ? name : dir + name);
|
|
396
|
+
}
|
|
397
|
+
// skeleton.a = asset files (non-source)
|
|
398
|
+
for (const [dir, names] of Object.entries(skeleton.a || {})) {
|
|
399
|
+
for (const name of names) allFiles.add(dir === './' ? name : dir + name);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 2. Build directory hierarchy from file paths
|
|
403
|
+
const dirs = new Set();
|
|
404
|
+
for (const file of allFiles) {
|
|
405
|
+
const parts = file.split('/');
|
|
406
|
+
for (let i = 1; i < parts.length; i++) {
|
|
407
|
+
dirs.add(parts.slice(0, i).join('/'));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Create directory group nodes
|
|
411
|
+
for (const dir of [...dirs].sort()) {
|
|
412
|
+
const parentDir = dir.includes('/') ? dir.substring(0, dir.lastIndexOf('/')) : null;
|
|
413
|
+
const label = dir.split('/').pop();
|
|
414
|
+
const node = { id: dir, label, w: 160, h: 40, type: 'group', isGroup: true, parentId: parentDir, children: [] };
|
|
415
|
+
this.graphDB.nodes.set(dir, node);
|
|
416
|
+
if (!parentDir || !dirs.has(parentDir)) {
|
|
417
|
+
this.graphDB.rootNodes.push(dir);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Link child directories to parents
|
|
421
|
+
for (const node of this.graphDB.nodes.values()) {
|
|
422
|
+
if (node.parentId && this.graphDB.nodes.has(node.parentId)) {
|
|
423
|
+
this.graphDB.nodes.get(node.parentId).children.push(node.id);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 3. Create file nodes
|
|
428
|
+
for (const file of allFiles) {
|
|
429
|
+
const parentId = this._dirOf(file).replace(/\/$/, '') || null;
|
|
430
|
+
const actualParent = parentId && this.graphDB.nodes.has(parentId) ? parentId : null;
|
|
431
|
+
const type = this._classifyFile(file, classFiles);
|
|
432
|
+
const label = file.split('/').pop();
|
|
433
|
+
const node = { id: file, label, w: 160, h: 40, type, isGroup: false, parentId: actualParent, children: [] };
|
|
434
|
+
this.graphDB.nodes.set(file, node);
|
|
435
|
+
if (actualParent) {
|
|
436
|
+
this.graphDB.nodes.get(actualParent).children.push(file);
|
|
437
|
+
} else {
|
|
438
|
+
this.graphDB.rootNodes.push(file);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 4. Extract edges from skeleton.I (import sources)
|
|
443
|
+
const edgeList = [];
|
|
444
|
+
const edgeSet = new Set();
|
|
445
|
+
for (const [srcFile, imports] of Object.entries(I)) {
|
|
446
|
+
if (!allFiles.has(srcFile)) continue;
|
|
447
|
+
for (const impPath of imports) {
|
|
448
|
+
// Skip bare module imports (node_modules)
|
|
449
|
+
if (!impPath.startsWith('.') && !impPath.startsWith('/')) continue;
|
|
450
|
+
const targetFile = this._resolveImport(impPath, srcFile, allFiles);
|
|
451
|
+
if (!targetFile || targetFile === srcFile) continue;
|
|
452
|
+
const key = srcFile + '>' + targetFile;
|
|
453
|
+
if (edgeSet.has(key)) continue;
|
|
454
|
+
edgeSet.add(key);
|
|
455
|
+
edgeList.push({ from: srcFile, to: targetFile });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
this.graphDB.edges = edgeList;
|
|
459
|
+
|
|
460
|
+
// Center viewport BEFORE worker starts — prevents nodes flashing at top-left
|
|
461
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
462
|
+
if (rect.width > 0) {
|
|
463
|
+
this.panX = rect.width / 2;
|
|
464
|
+
this.panY = rect.height / 2;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this.loadLevel(null);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ... (Other test-force-sim logic converted to class methods with `this.`)
|
|
471
|
+
rebuildNodeMap() { this.nodeMap = new Map(this.nodes.map(n => [n.id, n])); }
|
|
472
|
+
|
|
473
|
+
rebuildAdjMap() {
|
|
474
|
+
this.adjMap.clear();
|
|
475
|
+
for (const n of this.nodes) this.adjMap.set(n.id, new Set());
|
|
476
|
+
for (const e of this.edges) {
|
|
477
|
+
if (this.adjMap.has(e.from)) this.adjMap.get(e.from).add(e.to);
|
|
478
|
+
if (this.adjMap.has(e.to)) this.adjMap.get(e.to).add(e.from);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
updateInteractionDepths() {
|
|
483
|
+
this.interactionDepths.clear();
|
|
484
|
+
const activeGroupId = this.currentGroupId;
|
|
485
|
+
const focusNode = this.activeNode || this.dragNode;
|
|
486
|
+
|
|
487
|
+
// Establish baseline target depths for all nodes
|
|
488
|
+
for (const node of this.nodes) {
|
|
489
|
+
if (activeGroupId) {
|
|
490
|
+
if (node.parentId === activeGroupId) node.targetDepth = focusNode ? 3 : 0;
|
|
491
|
+
else if (node.id === activeGroupId) node.targetDepth = 4; // Hide the container group itself
|
|
492
|
+
else node.targetDepth = 4; // Other nodes hidden when inside a group
|
|
493
|
+
} else {
|
|
494
|
+
node.targetDepth = focusNode ? 3 : 0; // Dim to 3 if focused, 0 otherwise
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
for (const edge of this.edges) { edge.targetDepth = 4; edge.minTargetDepth = 4; }
|
|
499
|
+
|
|
500
|
+
if (!focusNode) {
|
|
501
|
+
for (const edge of this.edges) {
|
|
502
|
+
const d1 = this.nodeMap.get(edge.from)?.targetDepth ?? 4;
|
|
503
|
+
const d2 = this.nodeMap.get(edge.to)?.targetDepth ?? 4;
|
|
504
|
+
edge.targetDepth = Math.max(d1, d2);
|
|
505
|
+
edge.minTargetDepth = Math.min(d1, d2);
|
|
506
|
+
}
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// BFS from focusNode
|
|
511
|
+
const queue = [[focusNode.id, 0]];
|
|
512
|
+
const visited = new Set([focusNode.id]);
|
|
513
|
+
this.interactionDepths.set(focusNode.id, 0);
|
|
514
|
+
|
|
515
|
+
while (queue.length > 0) {
|
|
516
|
+
const [curr, depth] = queue.shift();
|
|
517
|
+
const currNode = this.nodeMap.get(curr);
|
|
518
|
+
if (currNode) currNode.targetDepth = depth;
|
|
519
|
+
|
|
520
|
+
if (depth >= 3) continue;
|
|
521
|
+
const neighbors = this.adjMap.get(curr) || new Set();
|
|
522
|
+
for (const n of neighbors) {
|
|
523
|
+
if (!visited.has(n)) {
|
|
524
|
+
visited.add(n);
|
|
525
|
+
this.interactionDepths.set(n, depth + 1);
|
|
526
|
+
queue.push([n, depth + 1]);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for (const edge of this.edges) {
|
|
532
|
+
const d1 = this.interactionDepths.has(edge.from) ? this.interactionDepths.get(edge.from) : 4;
|
|
533
|
+
const d2 = this.interactionDepths.has(edge.to) ? this.interactionDepths.get(edge.to) : 4;
|
|
534
|
+
edge.targetDepth = Math.max(d1, d2);
|
|
535
|
+
edge.minTargetDepth = Math.min(d1, d2);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
loadLevel(groupId = null) {
|
|
540
|
+
this._wakeLoop(); // View changed — resume rendering
|
|
541
|
+
this.activeNode = null;
|
|
542
|
+
this.dragNode = null;
|
|
543
|
+
this.hoverNode = null;
|
|
544
|
+
this.menuAnim = 0;
|
|
545
|
+
this.deactivating = false;
|
|
546
|
+
|
|
547
|
+
for (const node of this.graphDB.nodes.values()) {
|
|
548
|
+
if (node.isGroup) {
|
|
549
|
+
const groupR = getNodeRadius(node, 0);
|
|
550
|
+
node.w = groupR * 2;
|
|
551
|
+
node.h = groupR * 2;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
let activeIds = [...this.graphDB.rootNodes];
|
|
556
|
+
|
|
557
|
+
if (!groupId) {
|
|
558
|
+
this.currentGroupId = null;
|
|
559
|
+
if (this.ref.breadcrumb) this.ref.breadcrumb.setPath([]);
|
|
560
|
+
} else {
|
|
561
|
+
const group = this.graphDB.nodes.get(groupId);
|
|
562
|
+
if (group) {
|
|
563
|
+
this.currentGroupId = groupId;
|
|
564
|
+
if (!activeIds.includes(groupId)) activeIds.push(groupId);
|
|
565
|
+
activeIds.push(...group.children);
|
|
566
|
+
|
|
567
|
+
const childR = DOT_RADIUS * 1.5;
|
|
568
|
+
const dynamicSize = Math.sqrt(group.children.length) * childR * 3 + childR * 4;
|
|
569
|
+
group.w = dynamicSize;
|
|
570
|
+
group.h = dynamicSize;
|
|
571
|
+
|
|
572
|
+
// Render existing symbiote-node breadcrumbs
|
|
573
|
+
if (this.ref.breadcrumb) {
|
|
574
|
+
const parts = groupId.split('/');
|
|
575
|
+
const pathArr = [{ label: 'Root', level: '' }];
|
|
576
|
+
let acc = '';
|
|
577
|
+
for (let i = 0; i < parts.length; i++) {
|
|
578
|
+
if (!parts[i]) continue;
|
|
579
|
+
acc += (acc ? '/' : '') + parts[i];
|
|
580
|
+
pathArr.push({ label: parts[i], level: acc });
|
|
581
|
+
}
|
|
582
|
+
this.ref.breadcrumb.setPath(pathArr);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
} else {
|
|
586
|
+
// Fallback to root if group not found
|
|
587
|
+
this.currentGroupId = null;
|
|
588
|
+
if (this.ref.breadcrumb) this.ref.breadcrumb.setPath([]);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
this.nodes = activeIds.map(id => this.graphDB.nodes.get(id)).filter(Boolean);
|
|
593
|
+
|
|
594
|
+
for (const n of this.nodes) {
|
|
595
|
+
if (n.parentId && n.parentId === groupId) {
|
|
596
|
+
n.w = this.renderMode === 'dots' ? DOT_RADIUS * 1.5 : 160 * 0.6;
|
|
597
|
+
n.h = this.renderMode === 'dots' ? DOT_RADIUS * 1.5 : 40 * 0.6;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const activeSet = new Set(activeIds);
|
|
602
|
+
this.edges = this.graphDB.edges.filter(e => activeSet.has(e.from) || activeSet.has(e.to));
|
|
603
|
+
|
|
604
|
+
this.rebuildNodeMap();
|
|
605
|
+
this.rebuildAdjMap();
|
|
606
|
+
this.updateInteractionDepths();
|
|
607
|
+
|
|
608
|
+
const options = {
|
|
609
|
+
chargeStrength: this.$.chargeStrength,
|
|
610
|
+
linkDistance: this.$.linkDistance,
|
|
611
|
+
linkStrength: this.$.linkStrength,
|
|
612
|
+
centerStrength: this.$.centerStrength,
|
|
613
|
+
velocityDecay: this.$.velocityDecay,
|
|
614
|
+
collideStrength: this.$.collideStrength,
|
|
615
|
+
alphaDecay: this.$.alphaDecay,
|
|
616
|
+
theta: this.$.theta,
|
|
617
|
+
nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
|
|
618
|
+
nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
|
|
619
|
+
mode: 'continuous',
|
|
620
|
+
activeGroupId: groupId,
|
|
621
|
+
boundaryRadius: groupId ? this.graphDB.nodes.get(groupId).w / 2 : null,
|
|
622
|
+
attractors: null,
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
this.startWorker(options);
|
|
626
|
+
|
|
627
|
+
this.dispatchEvent(new CustomEvent('path-changed', { detail: { path: this.currentGroupId || '' } }));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
startWorker(customOptions = null) {
|
|
631
|
+
if (this.worker) this.worker.terminate();
|
|
632
|
+
const workerUrl = new URL('../../vendor/symbiote-node/canvas/ForceWorker.js', location.href).href;
|
|
633
|
+
this.worker = new Worker(workerUrl);
|
|
634
|
+
|
|
635
|
+
this.worker.onmessage = (e) => {
|
|
636
|
+
const { type } = e.data;
|
|
637
|
+
if (type === 'nodeIds') this.nodeIds = e.data.ids;
|
|
638
|
+
if (type === 'tick') {
|
|
639
|
+
const draggedId = this.dragNode ? this.dragNode.id : null;
|
|
640
|
+
if (e.data.packed) {
|
|
641
|
+
const buf = new Float32Array(e.data.packed);
|
|
642
|
+
for (let i = 0; i < this.nodeIds.length; i++) {
|
|
643
|
+
const id = this.nodeIds[i];
|
|
644
|
+
if (id === draggedId) continue;
|
|
645
|
+
const pos = this.nodePositions.get(id);
|
|
646
|
+
if (pos) { pos.x = buf[i * 2]; pos.y = buf[i * 2 + 1]; }
|
|
647
|
+
else this.nodePositions.set(id, { x: buf[i * 2], y: buf[i * 2 + 1] });
|
|
648
|
+
}
|
|
649
|
+
} else if (e.data.positions) {
|
|
650
|
+
for (const [id, p] of Object.entries(e.data.positions)) {
|
|
651
|
+
if (id === draggedId) continue;
|
|
652
|
+
const pos = this.nodePositions.get(id);
|
|
653
|
+
if (pos) { pos.x = p.x; pos.y = p.y; }
|
|
654
|
+
else this.nodePositions.set(id, p);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
this.lastAlpha = e.data.alpha || 0;
|
|
658
|
+
this.tickCount++;
|
|
659
|
+
this.frameCount++;
|
|
660
|
+
this._wakeLoop(); // Worker sent new positions — resume rendering
|
|
661
|
+
this.dispatchEvent(new CustomEvent('layout-tick', { detail: { alpha: this.lastAlpha } }));
|
|
662
|
+
}
|
|
663
|
+
if (type === 'done' && e.data.positions) {
|
|
664
|
+
for (const [id, pos] of Object.entries(e.data.positions)) this.nodePositions.set(id, pos);
|
|
665
|
+
this.dispatchEvent(new CustomEvent('layout-done'));
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const options = customOptions || {
|
|
670
|
+
chargeStrength: this.$.chargeStrength,
|
|
671
|
+
linkDistance: this.$.linkDistance,
|
|
672
|
+
linkStrength: this.$.linkStrength,
|
|
673
|
+
centerStrength: this.$.centerStrength,
|
|
674
|
+
velocityDecay: this.$.velocityDecay,
|
|
675
|
+
collideStrength: this.$.collideStrength,
|
|
676
|
+
alphaDecay: this.$.alphaDecay,
|
|
677
|
+
theta: this.$.theta,
|
|
678
|
+
wellStrength: this.$.wellStrength,
|
|
679
|
+
centerPull: this.$.centerPull,
|
|
680
|
+
wellRepulsion: this.$.wellRepulsion,
|
|
681
|
+
crossLinkScale: this.$.crossLinkScale,
|
|
682
|
+
nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
|
|
683
|
+
nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
|
|
684
|
+
mode: 'continuous',
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
this.worker.postMessage({
|
|
688
|
+
type: 'init',
|
|
689
|
+
nodes: this.nodes.map(n => {
|
|
690
|
+
const pos = this.smoothPositions.get(n.id);
|
|
691
|
+
let finalW = n.w, finalH = n.h;
|
|
692
|
+
if (this.renderMode === 'dots') {
|
|
693
|
+
const conns = this.adjMap.get(n.id)?.size || 0;
|
|
694
|
+
const r = getNodeRadius(n, conns);
|
|
695
|
+
finalW = finalH = r * 2;
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
id: n.id, type: n.type, parentId: n.parentId, isGroup: !!n.isGroup,
|
|
699
|
+
children: n.children || [], x: pos?.x, y: pos?.y, w: finalW, h: finalH,
|
|
700
|
+
};
|
|
701
|
+
}),
|
|
702
|
+
edges: this.edges.filter(e => this.nodeMap.has(e.from) && this.nodeMap.has(e.to)),
|
|
703
|
+
groups: {}, options
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
this.worker.postMessage({ type: 'updateConfig', config: {
|
|
707
|
+
contAlphaFloor: this.$.alphaFloor, contAlphaTarget: this.$.alphaTarget,
|
|
708
|
+
brownian: this.$.brownian, brownianThresh: this.$.brownianThresh,
|
|
709
|
+
pinReheat: this.$.pinReheat, pinCap: this.$.pinCap,
|
|
710
|
+
}});
|
|
711
|
+
|
|
712
|
+
this.smoothPositions.clear();
|
|
713
|
+
this.paused = false;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
getSmooth(id) { return this.smoothPositions.get(id) || this.nodePositions.get(id); }
|
|
717
|
+
|
|
718
|
+
nodeCenter(id) {
|
|
719
|
+
const pos = this.getSmooth(id);
|
|
720
|
+
if (!pos) return null;
|
|
721
|
+
if (this.renderMode === 'dots') return { x: pos.x, y: pos.y };
|
|
722
|
+
const node = this.nodeMap.get(id);
|
|
723
|
+
if (!node) return { x: pos.x, y: pos.y };
|
|
724
|
+
return { x: pos.x + node.w / 2, y: pos.y + node.h / 2 };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
resizeOffscreenCanvases() {
|
|
728
|
+
const dpr = window.devicePixelRatio || 1;
|
|
729
|
+
for (let i = 1; i <= 4; i++) {
|
|
730
|
+
const oc = this.offscreenCanvases[i].canvas;
|
|
731
|
+
if (oc.width !== this.canvas.width || oc.height !== this.canvas.height) {
|
|
732
|
+
oc.width = this.canvas.width;
|
|
733
|
+
oc.height = this.canvas.height;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
blendBg(r, g, b, alpha) {
|
|
739
|
+
const br = this._bgR, bg = this._bgG, bb = this._bgB;
|
|
740
|
+
const rr = (r * alpha + br * (1 - alpha)) | 0;
|
|
741
|
+
const gg = (g * alpha + bg * (1 - alpha)) | 0;
|
|
742
|
+
const bbb = (b * alpha + bb * (1 - alpha)) | 0;
|
|
743
|
+
return `rgb(${rr},${gg},${bbb})`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
draw() {
|
|
747
|
+
if (!this.canvas) return;
|
|
748
|
+
const dpr = window.devicePixelRatio || 1;
|
|
749
|
+
|
|
750
|
+
// Smooth zoom interpolation
|
|
751
|
+
const zoomAnimating = Math.abs(this._targetZoom - this.zoom) > 0.0001;
|
|
752
|
+
if (zoomAnimating) {
|
|
753
|
+
const oldZoom = this.zoom;
|
|
754
|
+
this.zoom += (this._targetZoom - this.zoom) * 0.15;
|
|
755
|
+
// Keep anchor point stable during wheel zoom
|
|
756
|
+
if (this._zoomAnchor) {
|
|
757
|
+
const { mx, my } = this._zoomAnchor;
|
|
758
|
+
this.panX = mx - (mx - this.panX) * (this.zoom / oldZoom);
|
|
759
|
+
this.panY = my - (my - this.panY) * (this.zoom / oldZoom);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Smooth pan interpolation (for fitView / flyToNode animations)
|
|
764
|
+
if (this._targetPanX !== null) {
|
|
765
|
+
const panDx = this._targetPanX - this.panX;
|
|
766
|
+
const panDy = this._targetPanY - this.panY;
|
|
767
|
+
if (Math.abs(panDx) < 0.5 && Math.abs(panDy) < 0.5) {
|
|
768
|
+
this.panX = this._targetPanX;
|
|
769
|
+
this.panY = this._targetPanY;
|
|
770
|
+
this._targetPanX = null;
|
|
771
|
+
this._targetPanY = null;
|
|
772
|
+
} else {
|
|
773
|
+
this.panX += panDx * 0.15;
|
|
774
|
+
this.panY += panDy * 0.15;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
779
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
780
|
+
|
|
781
|
+
this.resizeOffscreenCanvases();
|
|
782
|
+
const mainCtx = this.ctx;
|
|
783
|
+
const isIdle = (!this.activeNode && !this.currentGroupId) || this.deactivating;
|
|
784
|
+
|
|
785
|
+
if (this.deactivating && this.activeNode) {
|
|
786
|
+
const settled = Math.abs(this.layerAnim[0].scale - 1) < 0.01 && Math.abs(this.layerAnim[4].scale - 1) < 0.01;
|
|
787
|
+
if (settled) {
|
|
788
|
+
if (this.nextActiveNode) {
|
|
789
|
+
this.activeNode = this.nextActiveNode;
|
|
790
|
+
this.nextActiveNode = null;
|
|
791
|
+
} else {
|
|
792
|
+
this.activeNode = null;
|
|
793
|
+
this.dispatchEvent(new CustomEvent('node-deselected'));
|
|
794
|
+
}
|
|
795
|
+
this.deactivating = false;
|
|
796
|
+
this.updateInteractionDepths();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const inGroupMode = !!this.currentGroupId;
|
|
801
|
+
const lerpSpeed = isIdle ? 0.08 : 0.06;
|
|
802
|
+
for (let d = 0; d <= 4; d++) {
|
|
803
|
+
const la = this.layerAnim[d];
|
|
804
|
+
const tScale = isIdle ? 1 : this.LAYER_TARGETS.scale[d];
|
|
805
|
+
const tOpacity = isIdle ? 1 : this.LAYER_TARGETS.opacity[d];
|
|
806
|
+
const tParallax = isIdle ? 0 : this.LAYER_TARGETS.parallax[d];
|
|
807
|
+
|
|
808
|
+
const speed = (inGroupMode && d >= 3) ? 0.3 : lerpSpeed;
|
|
809
|
+
la.scale += (tScale - la.scale) * speed;
|
|
810
|
+
la.opacity += (tOpacity - la.opacity) * speed;
|
|
811
|
+
la.parallax += (tParallax - la.parallax) * speed;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const vcx = this.canvas.width / 2;
|
|
815
|
+
const vcy = this.canvas.height / 2;
|
|
816
|
+
let dragDeltaX = 0, dragDeltaY = 0;
|
|
817
|
+
|
|
818
|
+
if (this.activeNode && !this.deactivating) {
|
|
819
|
+
const dp = this.nodePositions.get(this.activeNode.id);
|
|
820
|
+
if (dp) {
|
|
821
|
+
// Center the combined node+panel area — only once per node activation
|
|
822
|
+
if (this._infoPanel._centeredForNode !== this.activeNode.id && this._infoPanel.totalExtent > 0) {
|
|
823
|
+
this._infoPanel._centeredForNode = this.activeNode.id;
|
|
824
|
+
const panelOffsetX = this._infoPanel.totalExtent / 2;
|
|
825
|
+
const panelOffsetY = this._infoPanel.totalExtentY / 2;
|
|
826
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
827
|
+
if (rect.width > 0) {
|
|
828
|
+
this._targetPanX = rect.width / 2 - (dp.x + panelOffsetX) * this.zoom;
|
|
829
|
+
this._targetPanY = rect.height / 2 - (dp.y + panelOffsetY) * this.zoom;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const targetFX = dpr * this.zoom * dp.x + dpr * this.panX;
|
|
834
|
+
const targetFY = dpr * this.zoom * dp.y + dpr * this.panY;
|
|
835
|
+
if (!this.focusActive) {
|
|
836
|
+
this.focusX = targetFX;
|
|
837
|
+
this.focusY = targetFY;
|
|
838
|
+
this.focusActive = true;
|
|
839
|
+
} else {
|
|
840
|
+
this.focusX += (targetFX - this.focusX) * 0.12;
|
|
841
|
+
this.focusY += (targetFY - this.focusY) * 0.12;
|
|
842
|
+
}
|
|
843
|
+
dragDeltaX = this.focusX - vcx;
|
|
844
|
+
dragDeltaY = this.focusY - vcy;
|
|
845
|
+
}
|
|
846
|
+
} else {
|
|
847
|
+
this.focusX += (vcx - this.focusX) * 0.08;
|
|
848
|
+
this.focusY += (vcy - this.focusY) * 0.08;
|
|
849
|
+
dragDeltaX = this.focusX - vcx;
|
|
850
|
+
dragDeltaY = this.focusY - vcy;
|
|
851
|
+
if (Math.abs(dragDeltaX) < 1 && Math.abs(dragDeltaY) < 1) {
|
|
852
|
+
this.focusActive = false;
|
|
853
|
+
dragDeltaX = 0;
|
|
854
|
+
dragDeltaY = 0;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
for (let i = 1; i <= 4; i++) {
|
|
859
|
+
const octx = this.offscreenCanvases[i].ctx;
|
|
860
|
+
const la = this.layerAnim[i];
|
|
861
|
+
const s = la.scale;
|
|
862
|
+
const pOffX = -la.parallax * dragDeltaX;
|
|
863
|
+
const pOffY = -la.parallax * dragDeltaY;
|
|
864
|
+
|
|
865
|
+
octx.setTransform(1, 0, 0, 1, 0, 0);
|
|
866
|
+
octx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
867
|
+
octx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom,
|
|
868
|
+
s * dpr * this.panX + vcx * (1 - s) + pOffX,
|
|
869
|
+
s * dpr * this.panY + vcy * (1 - s) + pOffY);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const t = 1 - this.smoothing;
|
|
873
|
+
for (const [id, raw] of this.nodePositions) {
|
|
874
|
+
const prev = this.smoothPositions.get(id);
|
|
875
|
+
if (!prev) {
|
|
876
|
+
this.smoothPositions.set(id, { x: raw.x, y: raw.y });
|
|
877
|
+
} else {
|
|
878
|
+
if (this.dragNode && this.dragNode.id === id) {
|
|
879
|
+
prev.x = raw.x; prev.y = raw.y;
|
|
880
|
+
} else {
|
|
881
|
+
prev.x += (raw.x - prev.x) * t;
|
|
882
|
+
prev.y += (raw.y - prev.y) * t;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
for (let i = 0; i <= 4; i++) {
|
|
888
|
+
this.depthGroups[i].edges.length = 0;
|
|
889
|
+
this.depthGroups[i].nodes.length = 0;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
for (const edge of this.edges) {
|
|
893
|
+
this.depthGroups[edge.targetDepth !== undefined ? edge.targetDepth : 4].edges.push(edge);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const focusNodes = [];
|
|
897
|
+
for (const node of this.nodes) {
|
|
898
|
+
if (node === this.activeNode || node === this.dragNode || node === this.hoverNode) {
|
|
899
|
+
focusNodes.push(node);
|
|
900
|
+
} else {
|
|
901
|
+
this.depthGroups[node.targetDepth !== undefined ? node.targetDepth : 4].nodes.push(node);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
for (const node of focusNodes) {
|
|
905
|
+
this.depthGroups[node.targetDepth !== undefined ? node.targetDepth : 4].nodes.push(node);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const getLayerTransform = (d) => {
|
|
909
|
+
const s = this.layerAnim[d].scale;
|
|
910
|
+
if (d > 0) {
|
|
911
|
+
const pOffX = -this.layerAnim[d].parallax * dragDeltaX;
|
|
912
|
+
const pOffY = -this.layerAnim[d].parallax * dragDeltaY;
|
|
913
|
+
return { A: s * dpr * this.zoom, E: s * dpr * this.panX + vcx * (1 - s) + pOffX, F: s * dpr * this.panY + vcy * (1 - s) + pOffY };
|
|
914
|
+
} else {
|
|
915
|
+
if (this.focusActive && Math.abs(s - 1) > 0.001) {
|
|
916
|
+
return { A: s * dpr * this.zoom, E: this.focusX * (1 - s) + s * dpr * this.panX, F: this.focusY * (1 - s) + s * dpr * this.panY };
|
|
917
|
+
} else {
|
|
918
|
+
return { A: dpr * this.zoom, E: dpr * this.panX, F: dpr * this.panY };
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
const drawDepth = (d, currentCtx) => {
|
|
924
|
+
const la = this.layerAnim[d];
|
|
925
|
+
const layerOpacity = la.opacity;
|
|
926
|
+
const isGhost = inGroupMode && d >= 3;
|
|
927
|
+
const GHOST_COLOR = this._ghostColor;
|
|
928
|
+
const tCurrent = getLayerTransform(d);
|
|
929
|
+
|
|
930
|
+
const mapPosToEdgeLayer = (pos, nodeDepth) => {
|
|
931
|
+
if (!pos || nodeDepth === d) return pos;
|
|
932
|
+
const tNode = getLayerTransform(nodeDepth);
|
|
933
|
+
const screenX = tNode.A * pos.x + tNode.E;
|
|
934
|
+
const screenY = tNode.A * pos.y + tNode.F;
|
|
935
|
+
return { x: (screenX - tCurrent.E) / tCurrent.A, y: (screenY - tCurrent.F) / tCurrent.A };
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
currentCtx.strokeStyle = 'rgba(74, 158, 255, 0.25)';
|
|
939
|
+
currentCtx.lineWidth = 1.5;
|
|
940
|
+
|
|
941
|
+
// Edges
|
|
942
|
+
for (const edge of this.depthGroups[d].edges) {
|
|
943
|
+
let from = this.nodeCenter(edge.from);
|
|
944
|
+
let to = this.nodeCenter(edge.to);
|
|
945
|
+
|
|
946
|
+
if ((!from || !to) && this.currentGroupId) {
|
|
947
|
+
const activeId = this.currentGroupId;
|
|
948
|
+
const activePos = this.smoothPositions.get(activeId);
|
|
949
|
+
const activeNode = this.graphDB.nodes.get(activeId);
|
|
950
|
+
if (activePos && activeNode) {
|
|
951
|
+
const radius = activeNode.w / 2;
|
|
952
|
+
if (!from && to) {
|
|
953
|
+
const angle = parseInt(edge.from.slice(-1), 16) || 0;
|
|
954
|
+
from = { x: activePos.x + Math.cos(angle) * radius, y: activePos.y + Math.sin(angle) * radius };
|
|
955
|
+
} else if (from && !to) {
|
|
956
|
+
const angle = parseInt(edge.to.slice(-1), 16) || 0;
|
|
957
|
+
to = { x: activePos.x + Math.cos(angle) * radius, y: activePos.y + Math.sin(angle) * radius };
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (!from || !to) continue;
|
|
963
|
+
|
|
964
|
+
let tAlpha = 0.5, tWidth = 1.5;
|
|
965
|
+
if (this.dragNode) {
|
|
966
|
+
const minD = edge.minTargetDepth;
|
|
967
|
+
if (minD === 0) { tAlpha = 1; tWidth = 3.0; }
|
|
968
|
+
else if (minD === 1) { tAlpha = 0.8; tWidth = 2.0; }
|
|
969
|
+
else if (minD === 2) { tAlpha = 0.4; tWidth = 1.5; }
|
|
970
|
+
else { tAlpha = 0.05; tWidth = 1.0; }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const edgeOpacity = tAlpha * layerOpacity;
|
|
974
|
+
edge.aAlpha = edge.aAlpha !== undefined ? edge.aAlpha : 0.5;
|
|
975
|
+
edge.aWidth = edge.aWidth || 1.5;
|
|
976
|
+
edge.aAlpha += (edgeOpacity - edge.aAlpha) * 0.1;
|
|
977
|
+
edge.aWidth += (tWidth - edge.aWidth) * 0.1;
|
|
978
|
+
|
|
979
|
+
const nodeFrom = this.nodeMap ? this.nodeMap.get(edge.from) : null;
|
|
980
|
+
const nodeTo = this.nodeMap ? this.nodeMap.get(edge.to) : null;
|
|
981
|
+
const fromDepth = nodeFrom?.targetDepth ?? 4;
|
|
982
|
+
const toDepth = nodeTo?.targetDepth ?? 4;
|
|
983
|
+
|
|
984
|
+
from = mapPosToEdgeLayer(from, fromDepth);
|
|
985
|
+
to = mapPosToEdgeLayer(to, toDepth);
|
|
986
|
+
|
|
987
|
+
const zoomFactor = this.zoom * (this.layerAnim[d]?.scale || 1);
|
|
988
|
+
const wFrom = (edge.aWidth * 2.0) / zoomFactor, wTo = wFrom;
|
|
989
|
+
const dx = to.x - from.x, dy = to.y - from.y;
|
|
990
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
991
|
+
if (len < 0.1) continue;
|
|
992
|
+
|
|
993
|
+
const nx = -dy / len, ny = dx / len;
|
|
994
|
+
|
|
995
|
+
let fillStyle;
|
|
996
|
+
if (isGhost) {
|
|
997
|
+
fillStyle = GHOST_COLOR;
|
|
998
|
+
} else if (this.dragNode || this.activeNode) {
|
|
999
|
+
const fromOpacity = this.layerAnim[fromDepth].opacity;
|
|
1000
|
+
const toOpacity = this.layerAnim[toDepth].opacity;
|
|
1001
|
+
const fromTC = TYPE_COLORS[nodeFrom?.type] || TYPE_COLORS.data;
|
|
1002
|
+
const toTC = TYPE_COLORS[nodeTo?.type] || TYPE_COLORS.data;
|
|
1003
|
+
const grad = currentCtx.createLinearGradient(from.x, from.y, to.x, to.y);
|
|
1004
|
+
grad.addColorStop(0, this.blendBg(fromTC[0], fromTC[1], fromTC[2], fromOpacity * 0.7));
|
|
1005
|
+
grad.addColorStop(1, this.blendBg(toTC[0], toTC[1], toTC[2], toOpacity * 0.7));
|
|
1006
|
+
fillStyle = grad;
|
|
1007
|
+
} else {
|
|
1008
|
+
const fromTC = TYPE_COLORS[nodeFrom?.type] || TYPE_COLORS.data;
|
|
1009
|
+
fillStyle = this.blendBg(fromTC[0], fromTC[1], fromTC[2], 0.35);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
currentCtx.fillStyle = fillStyle;
|
|
1013
|
+
currentCtx.beginPath();
|
|
1014
|
+
const midX = from.x + dx * 0.5, midY = from.y + dy * 0.5;
|
|
1015
|
+
const pinchRatio = Math.max(0.001, Math.pow(20 / Math.max(20, len), 2.8));
|
|
1016
|
+
const pinchW = Math.min(wFrom, wTo) * pinchRatio;
|
|
1017
|
+
const ang = Math.atan2(dy, dx);
|
|
1018
|
+
|
|
1019
|
+
currentCtx.moveTo(from.x + nx * wFrom, from.y + ny * wFrom);
|
|
1020
|
+
currentCtx.quadraticCurveTo(midX + nx * pinchW, midY + ny * pinchW, to.x + nx * wTo, to.y + ny * wTo);
|
|
1021
|
+
currentCtx.arc(to.x, to.y, wTo, ang + Math.PI/2, ang - Math.PI/2, true);
|
|
1022
|
+
currentCtx.quadraticCurveTo(midX - nx * pinchW, midY - ny * pinchW, from.x - nx * wFrom, from.y - ny * wFrom);
|
|
1023
|
+
currentCtx.arc(from.x, from.y, wFrom, ang - Math.PI/2, ang - Math.PI * 1.5, true);
|
|
1024
|
+
currentCtx.closePath();
|
|
1025
|
+
currentCtx.fill();
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// Nodes
|
|
1029
|
+
for (const node of this.depthGroups[d].nodes) {
|
|
1030
|
+
if (this.currentGroupId && node.id === this.currentGroupId) continue;
|
|
1031
|
+
const pos = this.getSmooth(node.id);
|
|
1032
|
+
if (!pos) continue;
|
|
1033
|
+
const isActive = this.activeNode && this.activeNode.id === node.id;
|
|
1034
|
+
const tc = TYPE_COLORS[node.type] || TYPE_COLORS.data;
|
|
1035
|
+
const conns = this.adjMap.get(node.id)?.size || 0;
|
|
1036
|
+
const hubScale = 1 + Math.min(conns, 8) * 0.1;
|
|
1037
|
+
|
|
1038
|
+
const targetScale = isActive ? 1.5 : 1;
|
|
1039
|
+
node.aScale = node.aScale !== undefined ? node.aScale : 1;
|
|
1040
|
+
node.aScale += (targetScale - node.aScale) * 0.12;
|
|
1041
|
+
|
|
1042
|
+
node.aGlow = node.aGlow !== undefined ? node.aGlow : 0;
|
|
1043
|
+
node.aGlow += ((isActive ? 1 : 0) - node.aGlow) * 0.1;
|
|
1044
|
+
|
|
1045
|
+
if (this.renderMode === 'dots') {
|
|
1046
|
+
let r = getNodeRadius(node, conns, { scale: node.aScale });
|
|
1047
|
+
|
|
1048
|
+
if (isGhost) {
|
|
1049
|
+
currentCtx.beginPath();
|
|
1050
|
+
currentCtx.arc(pos.x, pos.y, r * 0.7, 0, Math.PI * 2);
|
|
1051
|
+
currentCtx.fillStyle = GHOST_COLOR;
|
|
1052
|
+
currentCtx.fill();
|
|
1053
|
+
} else if (node.isGroup) {
|
|
1054
|
+
const ringW = r * 0.12;
|
|
1055
|
+
currentCtx.beginPath();
|
|
1056
|
+
currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
|
|
1057
|
+
currentCtx.fillStyle = `rgba(${this._bgR}, ${this._bgG}, ${this._bgB}, ${layerOpacity})`;
|
|
1058
|
+
currentCtx.fill();
|
|
1059
|
+
|
|
1060
|
+
currentCtx.beginPath();
|
|
1061
|
+
currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
|
|
1062
|
+
currentCtx.arc(pos.x, pos.y, r - ringW, 0, Math.PI * 2, true);
|
|
1063
|
+
currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
|
|
1064
|
+
currentCtx.fill();
|
|
1065
|
+
|
|
1066
|
+
const childCount = Math.max(2, Math.min(12, node.children?.length || 3));
|
|
1067
|
+
const innerR = r * Math.max(0.1, 0.18 - (childCount - 3) * 0.008);
|
|
1068
|
+
|
|
1069
|
+
// Calculate perfect orbit radius to maintain consistent spacing between dots
|
|
1070
|
+
const spacing = innerR * 2.5; // Gap between dots
|
|
1071
|
+
const idealOrbitR = spacing / (2 * Math.sin(Math.PI / childCount));
|
|
1072
|
+
// Ensure they never spill out of the golden ring
|
|
1073
|
+
const maxOrbitR = Math.max(0, r - ringW - innerR - 2);
|
|
1074
|
+
const orbitR = Math.min(idealOrbitR, maxOrbitR);
|
|
1075
|
+
const isHovered = this.hoverNode && this.hoverNode.id === node.id;
|
|
1076
|
+
node.aRotSpeed = node.aRotSpeed || 0;
|
|
1077
|
+
const targetRotSpeed = (isActive || isHovered) ? 0.025 : 0;
|
|
1078
|
+
node.aRotSpeed += (targetRotSpeed - node.aRotSpeed) * 0.05;
|
|
1079
|
+
node.aRot = (node.aRot || 0) + node.aRotSpeed;
|
|
1080
|
+
|
|
1081
|
+
for (let k = 0; k < childCount; k++) {
|
|
1082
|
+
const angle = (k * Math.PI * 2 / childCount) - Math.PI / 2 + node.aRot;
|
|
1083
|
+
const cx = pos.x + Math.cos(angle) * orbitR;
|
|
1084
|
+
const cy = pos.y + Math.sin(angle) * orbitR;
|
|
1085
|
+
currentCtx.beginPath();
|
|
1086
|
+
currentCtx.arc(cx, cy, innerR, 0, Math.PI * 2);
|
|
1087
|
+
currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity * 0.7);
|
|
1088
|
+
currentCtx.fill();
|
|
1089
|
+
}
|
|
1090
|
+
if (node.aGlow > 0.01) {
|
|
1091
|
+
currentCtx.strokeStyle = `rgba(${tc[0]},${tc[1]},${tc[2]},${layerOpacity * 0.6 * node.aGlow})`;
|
|
1092
|
+
currentCtx.lineWidth = 2 * node.aGlow;
|
|
1093
|
+
currentCtx.beginPath();
|
|
1094
|
+
currentCtx.arc(pos.x, pos.y, r + 4 * node.aGlow, 0, Math.PI * 2);
|
|
1095
|
+
currentCtx.stroke();
|
|
1096
|
+
}
|
|
1097
|
+
} else {
|
|
1098
|
+
currentCtx.beginPath();
|
|
1099
|
+
currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
|
|
1100
|
+
currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
|
|
1101
|
+
currentCtx.fill();
|
|
1102
|
+
if (node.aGlow > 0.01) {
|
|
1103
|
+
currentCtx.strokeStyle = `rgba(${tc[0]},${tc[1]},${tc[2]},${layerOpacity * 0.6 * node.aGlow})`;
|
|
1104
|
+
currentCtx.lineWidth = 2 * node.aGlow;
|
|
1105
|
+
currentCtx.beginPath();
|
|
1106
|
+
currentCtx.arc(pos.x, pos.y, r + 4 * node.aGlow, 0, Math.PI * 2);
|
|
1107
|
+
currentCtx.stroke();
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
for (let d = 4; d >= 1; d--) drawDepth(d, this.offscreenCanvases[d].ctx);
|
|
1115
|
+
|
|
1116
|
+
mainCtx.setTransform(1, 0, 0, 1, 0, 0);
|
|
1117
|
+
for (let d = 4; d >= 1; d--) {
|
|
1118
|
+
const blurPx = this.LAYER_TARGETS.blur[d];
|
|
1119
|
+
const blurIntensity = Math.abs(1 - this.layerAnim[d].scale) * blurPx * 8;
|
|
1120
|
+
mainCtx.filter = blurIntensity > 0.3 ? `blur(${blurIntensity.toFixed(1)}px)` : 'none';
|
|
1121
|
+
mainCtx.drawImage(this.offscreenCanvases[d].canvas, 0, 0);
|
|
1122
|
+
}
|
|
1123
|
+
mainCtx.filter = 'none';
|
|
1124
|
+
|
|
1125
|
+
{
|
|
1126
|
+
const s = this.layerAnim[0].scale;
|
|
1127
|
+
if (this.focusActive && Math.abs(s - 1) > 0.001) {
|
|
1128
|
+
mainCtx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom, this.focusX * (1 - s) + s * dpr * this.panX, this.focusY * (1 - s) + s * dpr * this.panY);
|
|
1129
|
+
} else {
|
|
1130
|
+
mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
|
|
1131
|
+
}
|
|
1132
|
+
drawDepth(0, mainCtx);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const showMenu = this.activeNode && !this.dragNode && !this.deactivating;
|
|
1136
|
+
if (showMenu) {
|
|
1137
|
+
this.menuAnim = Math.min(1, this.menuAnim + 0.08);
|
|
1138
|
+
} else {
|
|
1139
|
+
this.menuAnim = Math.max(0, this.menuAnim - 0.15);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (this.menuAnim > 0.01 && this.activeNode) {
|
|
1143
|
+
const apos = this.getSmooth(this.activeNode.id);
|
|
1144
|
+
if (apos) {
|
|
1145
|
+
const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
|
|
1146
|
+
const nodeR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
|
|
1147
|
+
const menuDist = nodeR + 14;
|
|
1148
|
+
const itemR = 6;
|
|
1149
|
+
|
|
1150
|
+
const easeOut = 1 - Math.pow(1 - this.menuAnim, 3);
|
|
1151
|
+
const mr = menuDist * easeOut;
|
|
1152
|
+
const ir = itemR * Math.max(0, easeOut);
|
|
1153
|
+
|
|
1154
|
+
const s = this.layerAnim[0].scale;
|
|
1155
|
+
if (this.focusActive && Math.abs(s - 1) > 0.001) {
|
|
1156
|
+
mainCtx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom, this.focusX * (1 - s) + s * dpr * this.panX, this.focusY * (1 - s) + s * dpr * this.panY);
|
|
1157
|
+
} else {
|
|
1158
|
+
mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const tc = TYPE_COLORS[this.activeNode.type] || TYPE_COLORS.data;
|
|
1162
|
+
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
|
1163
|
+
const item = MENU_ITEMS[i];
|
|
1164
|
+
const angle = (i / MENU_ITEMS.length) * Math.PI * 2 - Math.PI / 2;
|
|
1165
|
+
const ix = apos.x + Math.cos(angle) * mr;
|
|
1166
|
+
const iy = apos.y + Math.sin(angle) * mr;
|
|
1167
|
+
|
|
1168
|
+
mainCtx.beginPath();
|
|
1169
|
+
mainCtx.arc(ix, iy, ir, 0, Math.PI * 2);
|
|
1170
|
+
mainCtx.fillStyle = item.danger ? `rgba(60, 20, 20, ${0.9 * easeOut})` : `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.9 * easeOut})`;
|
|
1171
|
+
mainCtx.fill();
|
|
1172
|
+
|
|
1173
|
+
mainCtx.save();
|
|
1174
|
+
const iconScale = (ir * 1.2) / 24;
|
|
1175
|
+
if (iconScale > 0) {
|
|
1176
|
+
mainCtx.translate(ix - 12 * iconScale, iy - 12 * iconScale);
|
|
1177
|
+
mainCtx.scale(iconScale, iconScale);
|
|
1178
|
+
const p = new Path2D(item.path);
|
|
1179
|
+
mainCtx.fillStyle = item.danger ? `rgba(255, 107, 107, ${easeOut})` : `rgba(${this._bgR}, ${this._bgG}, ${this._bgB}, ${easeOut})`;
|
|
1180
|
+
mainCtx.fill(p);
|
|
1181
|
+
}
|
|
1182
|
+
mainCtx.restore();
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Info panel — typewriter HUD to the right of active node
|
|
1188
|
+
this._drawInfoPanel(mainCtx, dpr, dragDeltaX, dragDeltaY, vcx, vcy);
|
|
1189
|
+
|
|
1190
|
+
// Idle detection: stop the loop when nothing is animating
|
|
1191
|
+
const zoomSettled = Math.abs(this._targetZoom - this.zoom) < 0.001;
|
|
1192
|
+
// Track focus movement rate (delta-of-delta), not absolute offset
|
|
1193
|
+
const prevDX = this._prevDragDeltaX || 0;
|
|
1194
|
+
const prevDY = this._prevDragDeltaY || 0;
|
|
1195
|
+
const focusMovement = Math.abs(dragDeltaX - prevDX) + Math.abs(dragDeltaY - prevDY);
|
|
1196
|
+
this._prevDragDeltaX = dragDeltaX;
|
|
1197
|
+
this._prevDragDeltaY = dragDeltaY;
|
|
1198
|
+
const focusSettled = focusMovement < 0.1;
|
|
1199
|
+
const layerSettled = this.layerAnim[0] && Math.abs(this.layerAnim[0].scale - (isIdle ? 1 : this.LAYER_TARGETS.scale[0])) < 0.005;
|
|
1200
|
+
const workerActive = this.lastAlpha > 0.001;
|
|
1201
|
+
const hasDrag = !!this.dragNode || this.isPanning;
|
|
1202
|
+
const hasActiveAnim = this.deactivating;
|
|
1203
|
+
const hasPanAnim = this._targetPanX !== null;
|
|
1204
|
+
|
|
1205
|
+
const infoPanelAnimating = this._infoPanel.opacity > 0.01 && (this._infoPanel.opacity < 0.99 || this._infoPanel.lines.some(l => l.revealed < l.text.length));
|
|
1206
|
+
if (zoomSettled && focusSettled && layerSettled && !workerActive && !hasDrag && !hasActiveAnim && !hasPanAnim && !infoPanelAnimating) {
|
|
1207
|
+
this._idleFrames++;
|
|
1208
|
+
} else {
|
|
1209
|
+
this._idleFrames = 0;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Allow 3 extra frames after convergence to flush final sub-pixel lerps
|
|
1213
|
+
if (this._idleFrames > 3) {
|
|
1214
|
+
this._loopRunning = false;
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
this._animationFrame = requestAnimationFrame(() => this.draw());
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Build metadata lines for the info panel from skeleton + node data
|
|
1223
|
+
* @param {object} node - graph node
|
|
1224
|
+
* @returns {string[]}
|
|
1225
|
+
*/
|
|
1226
|
+
_buildInfoLines(node) {
|
|
1227
|
+
const lines = [];
|
|
1228
|
+
lines.push(node.label);
|
|
1229
|
+
if (node.id !== node.label) lines.push(node.id);
|
|
1230
|
+
lines.push('');
|
|
1231
|
+
|
|
1232
|
+
const typeLabels = {
|
|
1233
|
+
data: 'Data',
|
|
1234
|
+
action: 'Action',
|
|
1235
|
+
output: 'Output',
|
|
1236
|
+
config: 'Config',
|
|
1237
|
+
external: 'External',
|
|
1238
|
+
style: 'Style',
|
|
1239
|
+
docs: 'Docs',
|
|
1240
|
+
asset: 'Asset',
|
|
1241
|
+
group: 'Directory'
|
|
1242
|
+
};
|
|
1243
|
+
lines.push(`Type: ${typeLabels[node.type] || node.type}`);
|
|
1244
|
+
|
|
1245
|
+
const conns = this.adjMap.get(node.id)?.size || 0;
|
|
1246
|
+
if (conns > 0) lines.push(`Connections: ${conns}`);
|
|
1247
|
+
|
|
1248
|
+
if (node.children?.length > 0) {
|
|
1249
|
+
lines.push(`Children: ${node.children.length}`);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (this._skeleton) {
|
|
1253
|
+
const X = this._skeleton.X || {};
|
|
1254
|
+
const exports = X[node.id];
|
|
1255
|
+
if (exports && exports.length > 0) {
|
|
1256
|
+
lines.push('');
|
|
1257
|
+
lines.push('Exports:');
|
|
1258
|
+
for (const exp of exports.slice(0, 8)) {
|
|
1259
|
+
lines.push(` ${exp}`);
|
|
1260
|
+
}
|
|
1261
|
+
if (exports.length > 8) lines.push(` ... +${exports.length - 8}`);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const L = this._skeleton.L || {};
|
|
1265
|
+
const loc = L[node.id];
|
|
1266
|
+
if (loc) lines.push(`Lines: ${loc}`);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return lines;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Draw info panel HUD to the right of the active node
|
|
1274
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
1275
|
+
* @param {number} dpr
|
|
1276
|
+
* @param {number} dragDeltaX
|
|
1277
|
+
* @param {number} dragDeltaY
|
|
1278
|
+
* @param {number} vcx
|
|
1279
|
+
* @param {number} vcy
|
|
1280
|
+
*/
|
|
1281
|
+
_drawInfoPanel(ctx, dpr, dragDeltaX, dragDeltaY, vcx, vcy) {
|
|
1282
|
+
const ip = this._infoPanel;
|
|
1283
|
+
const showPanel = this.activeNode && !this.dragNode && !this.deactivating;
|
|
1284
|
+
|
|
1285
|
+
if (showPanel && this.activeNode) {
|
|
1286
|
+
if (ip.nodeId !== this.activeNode.id) {
|
|
1287
|
+
ip.nodeId = this.activeNode.id;
|
|
1288
|
+
ip.lines = this._buildInfoLines(this.activeNode).map(text => ({ text, revealed: 0 }));
|
|
1289
|
+
ip.startTime = performance.now();
|
|
1290
|
+
ip.opacity = 0;
|
|
1291
|
+
}
|
|
1292
|
+
ip.opacity = Math.min(1, ip.opacity + 0.06);
|
|
1293
|
+
} else {
|
|
1294
|
+
ip.opacity = Math.max(0, ip.opacity - 0.12);
|
|
1295
|
+
if (ip.opacity <= 0) { ip.nodeId = null; ip.lines = []; ip.totalExtent = 0; ip.totalExtentY = 0; ip._centeredForNode = null; }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (ip.opacity <= 0.01 || ip.lines.length === 0) return;
|
|
1299
|
+
|
|
1300
|
+
const elapsed = performance.now() - ip.startTime;
|
|
1301
|
+
const CHAR_SPEED = 18;
|
|
1302
|
+
const LINE_DELAY = 60;
|
|
1303
|
+
let charBudget = Math.floor(elapsed / CHAR_SPEED);
|
|
1304
|
+
for (let i = 0; i < ip.lines.length; i++) {
|
|
1305
|
+
const line = ip.lines[i];
|
|
1306
|
+
const available = Math.max(0, charBudget - i * LINE_DELAY / CHAR_SPEED);
|
|
1307
|
+
line.revealed = Math.min(line.text.length, Math.floor(available));
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const apos = this.activeNode ? this.getSmooth(this.activeNode.id) : null;
|
|
1311
|
+
if (!apos) return;
|
|
1312
|
+
|
|
1313
|
+
// Apply depth-0 transform — panel lives in world-space, scales with nodes
|
|
1314
|
+
const s = this.layerAnim[0].scale;
|
|
1315
|
+
if (this.focusActive && Math.abs(s - 1) > 0.001) {
|
|
1316
|
+
ctx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom,
|
|
1317
|
+
this.focusX * (1 - s) + s * dpr * this.panX,
|
|
1318
|
+
this.focusY * (1 - s) + s * dpr * this.panY);
|
|
1319
|
+
} else {
|
|
1320
|
+
ctx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// All dimensions in world units
|
|
1324
|
+
const fontSize = 11;
|
|
1325
|
+
const smallFontSize = 9;
|
|
1326
|
+
const lineHeight = 15;
|
|
1327
|
+
const padX = 14;
|
|
1328
|
+
const padY = 10;
|
|
1329
|
+
|
|
1330
|
+
// Compute actual node radius to avoid overlap
|
|
1331
|
+
// Must account for: dot radius + glow + radial menu items
|
|
1332
|
+
const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
|
|
1333
|
+
const dotR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
|
|
1334
|
+
// Menu orbits at dotR + 14, each item has radius 6
|
|
1335
|
+
const menuExtent = dotR + 14 + 6;
|
|
1336
|
+
const panelGap = 10;
|
|
1337
|
+
const panelX = apos.x + menuExtent + panelGap;
|
|
1338
|
+
const panelY = apos.y - padY;
|
|
1339
|
+
|
|
1340
|
+
ctx.font = `600 ${fontSize}px 'Inter', 'SF Mono', system-ui, sans-serif`;
|
|
1341
|
+
|
|
1342
|
+
// Measure panel width from FULL text content (not just revealed)
|
|
1343
|
+
// This ensures totalExtent is stable from the first frame — no oscillation
|
|
1344
|
+
let maxW = 60;
|
|
1345
|
+
for (const line of ip.lines) {
|
|
1346
|
+
const w = ctx.measureText(line.text).width;
|
|
1347
|
+
if (w > maxW) maxW = w;
|
|
1348
|
+
}
|
|
1349
|
+
const panelW = maxW + padX * 2;
|
|
1350
|
+
const panelH = ip.lines.length * lineHeight + padY * 2;
|
|
1351
|
+
|
|
1352
|
+
// Store total extent for focus centering
|
|
1353
|
+
ip.totalExtent = menuExtent + panelGap + panelW;
|
|
1354
|
+
// Vertical: panel extends from (apos.y - padY) to (apos.y - padY + panelH + 16)
|
|
1355
|
+
// The offset from node center to the vertical midpoint of the panel
|
|
1356
|
+
ip.totalExtentY = (panelH + 16) / 2 - padY;
|
|
1357
|
+
|
|
1358
|
+
const tc = TYPE_COLORS[this.activeNode?.type] || TYPE_COLORS.data;
|
|
1359
|
+
const cornerR = 6;
|
|
1360
|
+
|
|
1361
|
+
ctx.save();
|
|
1362
|
+
ctx.globalAlpha = ip.opacity;
|
|
1363
|
+
|
|
1364
|
+
// Blurred backdrop
|
|
1365
|
+
ctx.filter = 'blur(16px)';
|
|
1366
|
+
ctx.beginPath();
|
|
1367
|
+
ctx.roundRect(panelX, panelY, panelW, panelH + 16, cornerR);
|
|
1368
|
+
ctx.fillStyle = `rgba(${this._bgR}, ${this._bgG}, ${this._bgB}, ${0.85 * ip.opacity})`;
|
|
1369
|
+
ctx.fill();
|
|
1370
|
+
ctx.filter = 'none';
|
|
1371
|
+
|
|
1372
|
+
// Border
|
|
1373
|
+
ctx.beginPath();
|
|
1374
|
+
ctx.roundRect(panelX, panelY, panelW, panelH + 16, cornerR);
|
|
1375
|
+
ctx.strokeStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.15 * ip.opacity})`;
|
|
1376
|
+
ctx.lineWidth = 0.8;
|
|
1377
|
+
ctx.stroke();
|
|
1378
|
+
|
|
1379
|
+
// Left accent
|
|
1380
|
+
ctx.beginPath();
|
|
1381
|
+
ctx.moveTo(panelX, panelY + cornerR);
|
|
1382
|
+
ctx.lineTo(panelX, panelY + panelH + 16 - cornerR);
|
|
1383
|
+
ctx.strokeStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.5 * ip.opacity})`;
|
|
1384
|
+
ctx.lineWidth = 1.5;
|
|
1385
|
+
ctx.stroke();
|
|
1386
|
+
|
|
1387
|
+
// Text lines
|
|
1388
|
+
let textY = panelY + padY + fontSize;
|
|
1389
|
+
for (let i = 0; i < ip.lines.length; i++) {
|
|
1390
|
+
const line = ip.lines[i];
|
|
1391
|
+
const text = line.text.substring(0, line.revealed);
|
|
1392
|
+
if (!text) { textY += lineHeight; continue; }
|
|
1393
|
+
|
|
1394
|
+
if (i === 0) {
|
|
1395
|
+
ctx.font = `700 ${fontSize}px 'Inter', 'SF Mono', system-ui, sans-serif`;
|
|
1396
|
+
ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${ip.opacity})`;
|
|
1397
|
+
} else if (i === 1 && this.activeNode?.id !== this.activeNode?.label) {
|
|
1398
|
+
ctx.font = `400 ${smallFontSize}px 'SF Mono', 'JetBrains Mono', monospace`;
|
|
1399
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${0.35 * ip.opacity})`;
|
|
1400
|
+
} else if (line.text.startsWith(' ')) {
|
|
1401
|
+
ctx.font = `400 ${smallFontSize}px 'SF Mono', 'JetBrains Mono', monospace`;
|
|
1402
|
+
ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.6 * ip.opacity})`;
|
|
1403
|
+
} else if (line.text.includes(':')) {
|
|
1404
|
+
ctx.font = `500 ${smallFontSize}px 'Inter', system-ui, sans-serif`;
|
|
1405
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${0.5 * ip.opacity})`;
|
|
1406
|
+
} else {
|
|
1407
|
+
ctx.font = `500 ${smallFontSize}px 'Inter', system-ui, sans-serif`;
|
|
1408
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${0.6 * ip.opacity})`;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
ctx.fillText(text, panelX + padX, textY);
|
|
1412
|
+
|
|
1413
|
+
if (line.revealed < line.text.length && line.revealed > 0) {
|
|
1414
|
+
const cursorX = panelX + padX + ctx.measureText(text).width + 2;
|
|
1415
|
+
if (Math.floor(performance.now() / 400) % 2 === 0) {
|
|
1416
|
+
ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.8 * ip.opacity})`;
|
|
1417
|
+
ctx.fillRect(cursorX, textY - fontSize + 2, 1.5, fontSize);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
textY += lineHeight;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
ctx.restore();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
screenToWorld(sx, sy) {
|
|
1427
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1428
|
+
return {
|
|
1429
|
+
x: (sx - rect.left - this.panX) / this.zoom,
|
|
1430
|
+
y: (sy - rect.top - this.panY) / this.zoom,
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
hitTest(wx, wy) {
|
|
1435
|
+
const inGroup = !!this.currentGroupId;
|
|
1436
|
+
const activeGroupId = this.currentGroupId;
|
|
1437
|
+
for (let i = this.nodes.length - 1; i >= 0; i--) {
|
|
1438
|
+
const node = this.nodes[i];
|
|
1439
|
+
if (inGroup && node.parentId !== activeGroupId && node.id !== activeGroupId) continue;
|
|
1440
|
+
const pos = this.getSmooth(node.id);
|
|
1441
|
+
if (!pos) continue;
|
|
1442
|
+
|
|
1443
|
+
if (this.renderMode === 'dots') {
|
|
1444
|
+
const dx = wx - pos.x, dy = wy - pos.y;
|
|
1445
|
+
const hitR = node.isGroup ? HIT_RADIUS * 1.5 : HIT_RADIUS;
|
|
1446
|
+
if (dx * dx + dy * dy <= hitR * hitR) return node;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
bindEvents() {
|
|
1453
|
+
this.canvas.addEventListener('pointerdown', (e) => {
|
|
1454
|
+
this._wakeLoop(); // User interaction — resume rendering
|
|
1455
|
+
const world = this.screenToWorld(e.clientX, e.clientY);
|
|
1456
|
+
|
|
1457
|
+
if (this.activeNode && !this.dragNode && this.menuAnim > 0.5) {
|
|
1458
|
+
const apos = this.getSmooth(this.activeNode.id);
|
|
1459
|
+
if (apos) {
|
|
1460
|
+
const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
|
|
1461
|
+
const nodeR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
|
|
1462
|
+
const menuDist = nodeR + 14;
|
|
1463
|
+
const itemR = 6;
|
|
1464
|
+
|
|
1465
|
+
for (let i = 0; i < MENU_ITEMS.length; i++) {
|
|
1466
|
+
const angle = (i / MENU_ITEMS.length) * Math.PI * 2 - Math.PI / 2;
|
|
1467
|
+
const ix = apos.x + Math.cos(angle) * menuDist;
|
|
1468
|
+
const iy = apos.y + Math.sin(angle) * menuDist;
|
|
1469
|
+
const dx = world.x - ix, dy = world.y - iy;
|
|
1470
|
+
if (dx * dx + dy * dy < itemR * itemR * 2) {
|
|
1471
|
+
const action = MENU_ITEMS[i].action;
|
|
1472
|
+
if (action === 'drill') {
|
|
1473
|
+
if (this.activeNode.isGroup) this.loadLevel(this.activeNode.id);
|
|
1474
|
+
} else {
|
|
1475
|
+
// Dispatch prod action
|
|
1476
|
+
this.dispatchEvent(new CustomEvent('toolbar-action', {
|
|
1477
|
+
detail: { action, nodeId: this.activeNode.id },
|
|
1478
|
+
bubbles: true,
|
|
1479
|
+
composed: true
|
|
1480
|
+
}));
|
|
1481
|
+
}
|
|
1482
|
+
e.preventDefault();
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const hit = this.hitTest(world.x, world.y);
|
|
1490
|
+
if (hit) {
|
|
1491
|
+
const vis = this.getSmooth(hit.id);
|
|
1492
|
+
const sim = this.nodePositions.get(hit.id);
|
|
1493
|
+
if (vis && sim) { sim.x = vis.x; sim.y = vis.y; }
|
|
1494
|
+
|
|
1495
|
+
let isNewActivation = false;
|
|
1496
|
+
if (this.activeNode && this.activeNode.id !== hit.id) {
|
|
1497
|
+
isNewActivation = true;
|
|
1498
|
+
if (this.currentGroupId) {
|
|
1499
|
+
// Instant switch inside group
|
|
1500
|
+
this.activeNode = hit;
|
|
1501
|
+
this.dragNode = hit;
|
|
1502
|
+
this.menuAnim = 0;
|
|
1503
|
+
this.updateInteractionDepths();
|
|
1504
|
+
} else {
|
|
1505
|
+
this.nextActiveNode = hit;
|
|
1506
|
+
this.deactivating = true;
|
|
1507
|
+
this.dragNode = hit;
|
|
1508
|
+
}
|
|
1509
|
+
} else {
|
|
1510
|
+
if (!this.activeNode) isNewActivation = true;
|
|
1511
|
+
this.activeNode = hit;
|
|
1512
|
+
this.dragNode = hit;
|
|
1513
|
+
this.deactivating = false;
|
|
1514
|
+
this.updateInteractionDepths();
|
|
1515
|
+
}
|
|
1516
|
+
this._nodeActivatedOnDown = isNewActivation;
|
|
1517
|
+
const pos = this.nodePositions.get(hit.id);
|
|
1518
|
+
this.dragOffset.x = world.x - pos.x;
|
|
1519
|
+
this.dragOffset.y = world.y - pos.y;
|
|
1520
|
+
this._dragStartX = e.clientX;
|
|
1521
|
+
this._dragStartY = e.clientY;
|
|
1522
|
+
this.canvas.style.cursor = 'grabbing';
|
|
1523
|
+
this.canvas.setPointerCapture(e.pointerId);
|
|
1524
|
+
this.worker.postMessage({ type: 'pin', id: hit.id, x: pos.x, y: pos.y });
|
|
1525
|
+
e.preventDefault();
|
|
1526
|
+
} else {
|
|
1527
|
+
// Start panning — cancel any fitView/flyToNode animation
|
|
1528
|
+
this._targetPanX = null;
|
|
1529
|
+
this._targetPanY = null;
|
|
1530
|
+
this.isPanning = true;
|
|
1531
|
+
this._dragStartX = e.clientX;
|
|
1532
|
+
this._dragStartY = e.clientY;
|
|
1533
|
+
this.panStart = { x: this.panX, y: this.panY, px: e.clientX, py: e.clientY };
|
|
1534
|
+
this.canvas.style.cursor = 'grabbing';
|
|
1535
|
+
this.canvas.setPointerCapture(e.pointerId);
|
|
1536
|
+
}
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
this.canvas.addEventListener('pointermove', (e) => {
|
|
1540
|
+
if (this.dragNode) {
|
|
1541
|
+
this._wakeLoop(); // Dragging node — resume rendering
|
|
1542
|
+
const world = this.screenToWorld(e.clientX, e.clientY);
|
|
1543
|
+
const newX = world.x - this.dragOffset.x;
|
|
1544
|
+
const newY = world.y - this.dragOffset.y;
|
|
1545
|
+
this.nodePositions.set(this.dragNode.id, { x: newX, y: newY });
|
|
1546
|
+
this.worker.postMessage({ type: 'pin', id: this.dragNode.id, x: newX, y: newY });
|
|
1547
|
+
this.hoverNode = null;
|
|
1548
|
+
} else if (this.isPanning) {
|
|
1549
|
+
this._wakeLoop(); // Panning — resume rendering
|
|
1550
|
+
this.panX = this.panStart.x + (e.clientX - this.panStart.px);
|
|
1551
|
+
this.panY = this.panStart.y + (e.clientY - this.panStart.py);
|
|
1552
|
+
this.hoverNode = null;
|
|
1553
|
+
} else {
|
|
1554
|
+
const world = this.screenToWorld(e.clientX, e.clientY);
|
|
1555
|
+
this.hoverNode = this.hitTest(world.x, world.y);
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
this.canvas.addEventListener('pointerup', (e) => {
|
|
1560
|
+
if (this.dragNode) {
|
|
1561
|
+
this.worker.postMessage({ type: 'unpin', id: this.dragNode.id });
|
|
1562
|
+
this.dragNode = null;
|
|
1563
|
+
}
|
|
1564
|
+
this.isPanning = false;
|
|
1565
|
+
this.canvas.style.cursor = 'default';
|
|
1566
|
+
|
|
1567
|
+
// Detect click vs drag: if pointer moved less than 5px, it's a click
|
|
1568
|
+
const dx = e.clientX - (this._dragStartX || 0);
|
|
1569
|
+
const dy = e.clientY - (this._dragStartY || 0);
|
|
1570
|
+
const wasClick = (dx * dx + dy * dy) < 25;
|
|
1571
|
+
|
|
1572
|
+
if (wasClick) {
|
|
1573
|
+
const world = this.screenToWorld(e.clientX, e.clientY);
|
|
1574
|
+
const node = this.hitTest(world.x, world.y);
|
|
1575
|
+
if (node) {
|
|
1576
|
+
if (node.isGroup) {
|
|
1577
|
+
const now = Date.now();
|
|
1578
|
+
if (now - this.lastClickTime < 300 && this.lastClickNode === node.id) {
|
|
1579
|
+
// Double click on group
|
|
1580
|
+
this.loadLevel(node.id);
|
|
1581
|
+
} else {
|
|
1582
|
+
// Single click on group
|
|
1583
|
+
this.dispatchEvent(new CustomEvent('group-selected', { detail: { path: node.id } }));
|
|
1584
|
+
}
|
|
1585
|
+
this.lastClickTime = now;
|
|
1586
|
+
this.lastClickNode = node.id;
|
|
1587
|
+
} else {
|
|
1588
|
+
// File node click
|
|
1589
|
+
this.dispatchEvent(new CustomEvent('file-selected', { detail: { path: node.id } }));
|
|
1590
|
+
}
|
|
1591
|
+
} else {
|
|
1592
|
+
// Click on empty space → deselect active node
|
|
1593
|
+
if (this.activeNode && !this.deactivating) {
|
|
1594
|
+
this.deactivating = true;
|
|
1595
|
+
this.dragNode = null;
|
|
1596
|
+
this.dispatchEvent(new CustomEvent('node-deselected'));
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
} else if (draggedNode && this._nodeActivatedOnDown) {
|
|
1600
|
+
// We dragged a node that was just activated on pointerdown.
|
|
1601
|
+
// Emit selection event so URL and UI synchronize.
|
|
1602
|
+
if (draggedNode.isGroup) {
|
|
1603
|
+
this.dispatchEvent(new CustomEvent('group-selected', { detail: { path: draggedNode.id } }));
|
|
1604
|
+
} else {
|
|
1605
|
+
this.dispatchEvent(new CustomEvent('file-selected', { detail: { path: draggedNode.id } }));
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
this._nodeActivatedOnDown = false;
|
|
1609
|
+
this._dragStartX = 0;
|
|
1610
|
+
this._dragStartY = 0;
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
this.canvas.addEventListener('wheel', (e) => {
|
|
1614
|
+
e.preventDefault();
|
|
1615
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1616
|
+
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
1617
|
+
const factor = e.deltaY > 0 ? 0.92 : 1.08;
|
|
1618
|
+
this._targetZoom = Math.max(0.02, Math.min(5, this._targetZoom * factor));
|
|
1619
|
+
this._zoomAnchor = { mx, my };
|
|
1620
|
+
this._wakeLoop(); // Zoom changed — resume rendering
|
|
1621
|
+
}, { passive: false });
|
|
1622
|
+
|
|
1623
|
+
this.canvas.addEventListener('dblclick', (e) => {
|
|
1624
|
+
// Check if we didn't hit a node
|
|
1625
|
+
const world = this.screenToWorld(e.clientX, e.clientY);
|
|
1626
|
+
if (!this.hitTest(world.x, world.y)) {
|
|
1627
|
+
if (!this.nodePositions.size) return;
|
|
1628
|
+
let sx = 0, sy = 0, count = 0;
|
|
1629
|
+
for (const pos of this.nodePositions.values()) { sx += pos.x; sy += pos.y; count++; }
|
|
1630
|
+
const cx = sx / count, cy = sy / count;
|
|
1631
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1632
|
+
this.panX = rect.width / 2 - cx * this.zoom;
|
|
1633
|
+
this.panY = rect.height / 2 - cy * this.zoom;
|
|
1634
|
+
this._wakeLoop(); // Double-click recenter — resume rendering
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
CanvasGraph.template = /*html*/`
|
|
1641
|
+
<style>
|
|
1642
|
+
:host {
|
|
1643
|
+
display: block;
|
|
1644
|
+
position: relative;
|
|
1645
|
+
width: 100%;
|
|
1646
|
+
height: 100%;
|
|
1647
|
+
overflow: hidden;
|
|
1648
|
+
background: #0f172a;
|
|
1649
|
+
}
|
|
1650
|
+
pg-canvas-graph > canvas {
|
|
1651
|
+
position: absolute;
|
|
1652
|
+
top: 0;
|
|
1653
|
+
left: 0;
|
|
1654
|
+
width: 100%;
|
|
1655
|
+
height: 100%;
|
|
1656
|
+
display: block;
|
|
1657
|
+
outline: none;
|
|
1658
|
+
user-select: none;
|
|
1659
|
+
cursor: default;
|
|
1660
|
+
}
|
|
1661
|
+
pg-canvas-graph > canvas.grabbing { cursor: grabbing; }
|
|
1662
|
+
</style>
|
|
1663
|
+
<graph-breadcrumb ref="breadcrumb" style="position: absolute; top: 16px; left: 16px; z-index: 10;"></graph-breadcrumb>
|
|
1664
|
+
`;
|
|
1665
|
+
|
|
1666
|
+
CanvasGraph.reg('pg-canvas-graph');
|