symbiote-ui 0.3.0-alpha.4

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.
Files changed (322) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +76 -0
  4. package/canvas/AutoLayout.js +731 -0
  5. package/canvas/Breadcrumb/Breadcrumb.css.js +75 -0
  6. package/canvas/Breadcrumb/Breadcrumb.js +96 -0
  7. package/canvas/Breadcrumb/Breadcrumb.tpl.js +7 -0
  8. package/canvas/CanvasConnectionRenderer.js +971 -0
  9. package/canvas/CanvasGraph/CanvasGraph.css.js +29 -0
  10. package/canvas/CanvasGraph/CanvasGraph.js +1697 -0
  11. package/canvas/CanvasGraph/CanvasGraphDrawState.js +280 -0
  12. package/canvas/CanvasGraph/CanvasGraphGeometry.js +194 -0
  13. package/canvas/CanvasViewport.js +550 -0
  14. package/canvas/ConnectionRenderer.js +1283 -0
  15. package/canvas/FlowSimulator.js +326 -0
  16. package/canvas/ForceLayout.js +226 -0
  17. package/canvas/ForceWorker.js +1303 -0
  18. package/canvas/FrameManager.js +223 -0
  19. package/canvas/GraphExplorerShell/GraphExplorerShell.css.js +136 -0
  20. package/canvas/GraphExplorerShell/GraphExplorerShell.js +129 -0
  21. package/canvas/GraphExplorerShell/GraphExplorerShell.tpl.js +12 -0
  22. package/canvas/GraphTabs/GraphTabs.css.js +101 -0
  23. package/canvas/GraphTabs/GraphTabs.js +189 -0
  24. package/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  25. package/canvas/LODManager.js +88 -0
  26. package/canvas/Minimap/Minimap.css.js +73 -0
  27. package/canvas/Minimap/Minimap.js +210 -0
  28. package/canvas/Minimap/Minimap.tpl.js +7 -0
  29. package/canvas/NodeCanvas/NodeCanvas.css.js +398 -0
  30. package/canvas/NodeCanvas/NodeCanvas.js +1499 -0
  31. package/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  32. package/canvas/NodeSearch/NodeSearch.css.js +97 -0
  33. package/canvas/NodeSearch/NodeSearch.js +140 -0
  34. package/canvas/NodeSearch/NodeSearch.tpl.js +25 -0
  35. package/canvas/NodeViewManager.js +748 -0
  36. package/canvas/PcbRouteDiagnostics.js +463 -0
  37. package/canvas/PcbRouter.js +1127 -0
  38. package/canvas/PinExpansion.js +134 -0
  39. package/canvas/PseudoConnection.js +84 -0
  40. package/canvas/SelectionSync.js +163 -0
  41. package/canvas/SubgraphManager.js +203 -0
  42. package/canvas/SubgraphRouter.js +452 -0
  43. package/canvas/ViewportActions.js +473 -0
  44. package/canvas/graph-explorer.js +339 -0
  45. package/canvas/graph-layout.js +148 -0
  46. package/canvas/graph-model.js +68 -0
  47. package/canvas/html-in-canvas.js +202 -0
  48. package/canvas/project-graph-builder.js +440 -0
  49. package/canvas/project-graph-model.js +183 -0
  50. package/chat/ChatComposer/ChatComposer.css.js +652 -0
  51. package/chat/ChatComposer/ChatComposer.js +304 -0
  52. package/chat/ChatList/ChatList.css.js +102 -0
  53. package/chat/ChatList/ChatList.js +99 -0
  54. package/chat/ChatList/ChatList.tpl.js +20 -0
  55. package/chat/ChatListItem/ChatListItem.css.js +117 -0
  56. package/chat/ChatListItem/ChatListItem.js +32 -0
  57. package/chat/ChatListItem/ChatListItem.tpl.js +17 -0
  58. package/chat/ChatMessageItem/ChatMessageItem.css.js +628 -0
  59. package/chat/ChatMessageItem/ChatMessageItem.js +156 -0
  60. package/chat/ChatSidebar/ChatSidebar.css.js +150 -0
  61. package/chat/ChatSidebar/ChatSidebar.js +230 -0
  62. package/chat/ChatSidebar/ChatSidebar.tpl.js +18 -0
  63. package/chat/ChatSidebar/constants.js +11 -0
  64. package/chat/ChatSidebarItem/ChatSidebarItem.css.js +445 -0
  65. package/chat/ChatSidebarItem/ChatSidebarItem.js +304 -0
  66. package/chat/ChatTranscript/ChatTranscript.css.js +90 -0
  67. package/chat/ChatTranscript/ChatTranscript.js +244 -0
  68. package/chat/chat-context.js +123 -0
  69. package/chat/message-model.js +156 -0
  70. package/cli.js +20 -0
  71. package/control/Button/Button.css.js +93 -0
  72. package/control/Button/Button.js +78 -0
  73. package/control/Button/Button.tpl.js +3 -0
  74. package/control/Field/Field.css.js +91 -0
  75. package/control/Field/Field.js +17 -0
  76. package/control/Field/Field.tpl.js +3 -0
  77. package/core/Connection.js +47 -0
  78. package/core/Editor.js +449 -0
  79. package/core/Frame.js +33 -0
  80. package/core/GraphMermaid.js +348 -0
  81. package/core/GraphText.js +228 -0
  82. package/core/Node.js +145 -0
  83. package/core/Portal.js +106 -0
  84. package/core/Socket.js +187 -0
  85. package/core/SubgraphNode.js +121 -0
  86. package/core/base-path.js +55 -0
  87. package/core/dom-utils.js +14 -0
  88. package/core/index.js +18 -0
  89. package/core/local-cache.js +26 -0
  90. package/core/state-sync.js +227 -0
  91. package/custom-elements.json +6380 -0
  92. package/discover.js +240 -0
  93. package/display/Badge/Badge.css.js +44 -0
  94. package/display/Badge/Badge.js +17 -0
  95. package/display/Badge/Badge.tpl.js +3 -0
  96. package/display/Banner/Banner.css.js +61 -0
  97. package/display/Banner/Banner.js +17 -0
  98. package/display/Banner/Banner.tpl.js +3 -0
  99. package/display/CodeBlock/CodeBlock.css.js +194 -0
  100. package/display/CodeBlock/CodeBlock.js +220 -0
  101. package/display/CodeBlock/CodeBlock.tpl.js +11 -0
  102. package/display/DataTable/DataTable.css.js +101 -0
  103. package/display/DataTable/DataTable.js +136 -0
  104. package/display/DataTable/DataTable.tpl.js +13 -0
  105. package/display/EmptyState/EmptyState.css.js +33 -0
  106. package/display/EmptyState/EmptyState.js +17 -0
  107. package/display/EmptyState/EmptyState.tpl.js +3 -0
  108. package/display/EventFeed/EventFeed.css.js +145 -0
  109. package/display/EventFeed/EventFeed.js +64 -0
  110. package/display/EventFeed/EventFeed.tpl.js +14 -0
  111. package/display/EventFeed/EventFeedItem.js +116 -0
  112. package/display/EventFeed/EventFeedItem.tpl.js +22 -0
  113. package/display/LoadingOverlay/LoadingOverlay.css.js +91 -0
  114. package/display/LoadingOverlay/LoadingOverlay.js +48 -0
  115. package/display/LoadingOverlay/LoadingOverlay.tpl.js +12 -0
  116. package/display/Metric/Metric.css.js +60 -0
  117. package/display/Metric/Metric.js +17 -0
  118. package/display/Metric/Metric.tpl.js +6 -0
  119. package/display/OutputGraphPreview/OutputGraphPreview.css.js +122 -0
  120. package/display/OutputGraphPreview/OutputGraphPreview.js +89 -0
  121. package/display/OutputGraphPreview/OutputGraphPreview.tpl.js +13 -0
  122. package/display/OutputListPreview/OutputListPreview.css.js +109 -0
  123. package/display/OutputListPreview/OutputListPreview.js +77 -0
  124. package/display/OutputListPreview/OutputListPreview.tpl.js +13 -0
  125. package/display/SourceEditor/SourceEditor.css.js +39 -0
  126. package/display/SourceEditor/SourceEditor.js +129 -0
  127. package/display/SourceEditor/SourceEditor.tpl.js +10 -0
  128. package/display/SourceViewer/SourceViewer.css.js +80 -0
  129. package/display/SourceViewer/SourceViewer.js +418 -0
  130. package/display/SourceViewer/SourceViewer.tpl.js +17 -0
  131. package/display/StatusRibbon/StatusRibbon.css.js +73 -0
  132. package/display/StatusRibbon/StatusRibbon.js +87 -0
  133. package/display/StatusRibbon/StatusRibbon.tpl.js +7 -0
  134. package/display/event-feed-adapter.js +72 -0
  135. package/display/format-utils.js +29 -0
  136. package/display/highlight.js +659 -0
  137. package/display/icons.js +37 -0
  138. package/display/markdown-formatter.js +60 -0
  139. package/display/network-approval-page.js +487 -0
  140. package/display/output-preview.js +261 -0
  141. package/effects/CellBg/CellBg.css.js +33 -0
  142. package/effects/CellBg/CellBg.js +410 -0
  143. package/effects/CellBg/CellBg.tpl.js +5 -0
  144. package/graph/canvas-adapter.js +223 -0
  145. package/graph/graph-algorithms.js +31 -0
  146. package/graph/index.js +46 -0
  147. package/graph/model.js +176 -0
  148. package/graph/project-graph-build.js +66 -0
  149. package/graph/project-graph-metadata.js +253 -0
  150. package/graph/project-package.js +128 -0
  151. package/graph/project-runtime.js +116 -0
  152. package/graph/project-transaction.js +284 -0
  153. package/graph/skeleton-utils.js +84 -0
  154. package/graph/theme-contract.js +36 -0
  155. package/graph/transaction-parser.js +56 -0
  156. package/icons/MaterialSymbols.js +69 -0
  157. package/icons/material-symbols-outlined-400.ttf +0 -0
  158. package/icons/material-symbols.css +24 -0
  159. package/index.js +95 -0
  160. package/inspector/InspectorPanel/InspectorPanel.css.js +375 -0
  161. package/inspector/InspectorPanel/InspectorPanel.js +368 -0
  162. package/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  163. package/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  164. package/inspector/TemplatePreview/TemplatePreview.js +145 -0
  165. package/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  166. package/interactions/ConnectFlow.js +304 -0
  167. package/interactions/Drag.js +104 -0
  168. package/interactions/Selector.js +133 -0
  169. package/interactions/SnapGrid.js +66 -0
  170. package/interactions/Zoom.js +139 -0
  171. package/layout/ActionZone/ActionZone.css.js +88 -0
  172. package/layout/ActionZone/ActionZone.js +261 -0
  173. package/layout/ActionZone/ActionZone.tpl.js +11 -0
  174. package/layout/CrossLayoutPortalBridge/CrossLayoutPortalBridge.js +255 -0
  175. package/layout/Layout/Layout.css.js +91 -0
  176. package/layout/Layout/Layout.js +637 -0
  177. package/layout/Layout/Layout.tpl.js +27 -0
  178. package/layout/LayoutNode/LayoutNode.css.js +302 -0
  179. package/layout/LayoutNode/LayoutNode.js +509 -0
  180. package/layout/LayoutNode/LayoutNode.tpl.js +39 -0
  181. package/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  182. package/layout/LayoutPreview/LayoutPreview.js +102 -0
  183. package/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  184. package/layout/LayoutRouter/LayoutRouter.js +274 -0
  185. package/layout/LayoutRouter/SectionRegistry.js +135 -0
  186. package/layout/LayoutRouter/routerSync.js +250 -0
  187. package/layout/LayoutSidebar/LayoutSidebar.css.js +411 -0
  188. package/layout/LayoutSidebar/LayoutSidebar.js +368 -0
  189. package/layout/LayoutSidebar/LayoutSidebar.tpl.js +26 -0
  190. package/layout/LayoutSidebar/SidebarSection.css.js +20 -0
  191. package/layout/LayoutSidebar/SidebarSection.js +184 -0
  192. package/layout/LayoutSidebar/SidebarSection.tpl.js +22 -0
  193. package/layout/LayoutTree.js +373 -0
  194. package/layout/PanelMenu/PanelMenu.css.js +43 -0
  195. package/layout/PanelMenu/PanelMenu.js +95 -0
  196. package/layout/PanelMenu/PanelMenu.tpl.js +17 -0
  197. package/layout/ProjectTabs/ProjectTabs.css.js +188 -0
  198. package/layout/ProjectTabs/ProjectTabs.js +77 -0
  199. package/layout/ProjectTabs/ProjectTabs.tpl.js +15 -0
  200. package/layout/index.js +40 -0
  201. package/list/ListDetailShell/ListDetailShell.css.js +128 -0
  202. package/list/ListDetailShell/ListDetailShell.js +72 -0
  203. package/list/ListDetailShell/ListDetailShell.tpl.js +36 -0
  204. package/list/ListItem/ListItem.css.js +111 -0
  205. package/list/ListItem/ListItem.js +66 -0
  206. package/list/ListItem/ListItem.tpl.js +18 -0
  207. package/locale/index.js +503 -0
  208. package/manifest/component-registry.js +2446 -0
  209. package/manifest/graph-schema.js +285 -0
  210. package/manifest/index.js +6 -0
  211. package/manifest/project-schema-catalog.js +246 -0
  212. package/manifest/rule-catalog.js +201 -0
  213. package/manifest/theme-catalog.js +2149 -0
  214. package/manifest/ui-schema-catalog.js +334 -0
  215. package/menu/ContextMenu/ContextMenu.css.js +61 -0
  216. package/menu/ContextMenu/ContextMenu.js +82 -0
  217. package/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  218. package/navigation/QuickOpen/QuickOpen.css.js +92 -0
  219. package/navigation/QuickOpen/QuickOpen.js +185 -0
  220. package/navigation/QuickOpen/QuickOpen.tpl.js +15 -0
  221. package/navigation/quick-open-utils.js +101 -0
  222. package/node/CtrlItem/CtrlItem.css.js +41 -0
  223. package/node/CtrlItem/CtrlItem.js +24 -0
  224. package/node/CtrlItem/CtrlItem.tpl.js +17 -0
  225. package/node/GraphFrame/GraphFrame.css.js +66 -0
  226. package/node/GraphFrame/GraphFrame.js +32 -0
  227. package/node/GraphFrame/GraphFrame.tpl.js +13 -0
  228. package/node/GraphNode/GraphNode.css.js +815 -0
  229. package/node/GraphNode/GraphNode.js +173 -0
  230. package/node/GraphNode/GraphNode.tpl.js +33 -0
  231. package/node/NodeCallout/NodeCallout.css.js +91 -0
  232. package/node/NodeCallout/NodeCallout.js +281 -0
  233. package/node/NodeCallout/NodeCallout.tpl.js +8 -0
  234. package/node/NodeSocket/NodeSocket.css.js +68 -0
  235. package/node/NodeSocket/NodeSocket.js +26 -0
  236. package/node/NodeSocket/NodeSocket.tpl.js +7 -0
  237. package/node/PortItem/PortItem.css.js +93 -0
  238. package/node/PortItem/PortItem.js +87 -0
  239. package/node/PortItem/PortItem.tpl.js +10 -0
  240. package/package.json +165 -0
  241. package/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  242. package/palette/PaletteBrowser/PaletteBrowser.js +152 -0
  243. package/palette/PaletteBrowser/PaletteBrowser.tpl.js +23 -0
  244. package/plugins/History.js +408 -0
  245. package/plugins/Readonly.js +60 -0
  246. package/rules/symbiote-3x.json +170 -0
  247. package/schemas/component-descriptor-v1.json +91 -0
  248. package/schemas/component-descriptor-v2.json +145 -0
  249. package/schemas/graph-model-v1.json +179 -0
  250. package/schemas/graph-v1.json +91 -0
  251. package/schemas/project-package-v1.json +102 -0
  252. package/schemas/project-transaction-v1.json +114 -0
  253. package/schemas/runtime-ui-v1.json +80 -0
  254. package/schemas/theme-rule-block-v1.json +73 -0
  255. package/shapes/CircleShape.js +79 -0
  256. package/shapes/CommentShape.js +35 -0
  257. package/shapes/DiamondShape.js +130 -0
  258. package/shapes/NodeShape.js +79 -0
  259. package/shapes/PillShape.js +91 -0
  260. package/shapes/RectShape.js +84 -0
  261. package/shapes/SVGShape.js +525 -0
  262. package/shapes/index.js +63 -0
  263. package/surface/Card/Card.css.js +57 -0
  264. package/surface/Card/Card.js +17 -0
  265. package/surface/Card/Card.tpl.js +3 -0
  266. package/themes/Palette.js +30 -0
  267. package/themes/Skin.js +113 -0
  268. package/themes/Theme.js +82 -0
  269. package/themes/carbon.js +135 -0
  270. package/themes/dark.js +140 -0
  271. package/themes/default-dark.js +714 -0
  272. package/themes/default-provider.css +635 -0
  273. package/themes/default-provider.js +718 -0
  274. package/themes/ebook.js +136 -0
  275. package/themes/grey.js +137 -0
  276. package/themes/light.js +139 -0
  277. package/themes/neon.js +138 -0
  278. package/themes/pcb.js +273 -0
  279. package/themes/synthwave.js +138 -0
  280. package/tokens/base.json +29 -0
  281. package/tokens/themes/carbon.json +11 -0
  282. package/tokens/themes/dark.json +12 -0
  283. package/tokens/themes/default-dark.json +1543 -0
  284. package/tokens/themes/default-provider.json +1543 -0
  285. package/tokens/themes/ebook.json +11 -0
  286. package/tokens/themes/grey.json +11 -0
  287. package/tokens/themes/light.json +12 -0
  288. package/tokens/themes/neon.json +11 -0
  289. package/tokens/themes/pcb.json +11 -0
  290. package/tokens/themes/synthwave.json +11 -0
  291. package/toolbar/QuickToolbar/QuickToolbar.css.js +152 -0
  292. package/toolbar/QuickToolbar/QuickToolbar.js +529 -0
  293. package/toolbar/QuickToolbar/QuickToolbar.tpl.js +34 -0
  294. package/tree/TreePanel/TreePanel.css.js +112 -0
  295. package/tree/TreePanel/TreePanel.js +147 -0
  296. package/tree/TreePanel/TreePanel.tpl.js +18 -0
  297. package/tree/TreeView/TreeView.css.js +122 -0
  298. package/tree/TreeView/TreeView.js +365 -0
  299. package/tree/TreeView/TreeView.tpl.js +10 -0
  300. package/ui/dialogs.js +221 -0
  301. package/ui/host-adapters.js +114 -0
  302. package/ui/index.js +660 -0
  303. package/ui/locale.js +50 -0
  304. package/ui/overlay-stack.js +89 -0
  305. package/ui/shared-styles.js +26 -0
  306. package/webmcp.js +37 -0
  307. package/xr/deep-graph.js +646 -0
  308. package/xr/emulation.js +198 -0
  309. package/xr/gesture.js +228 -0
  310. package/xr/html-canvas-renderer.js +472 -0
  311. package/xr/index.js +15 -0
  312. package/xr/layout-projection.js +1046 -0
  313. package/xr/panel-frame.js +128 -0
  314. package/xr/panel-host.js +267 -0
  315. package/xr/pointer.js +258 -0
  316. package/xr/scene-controller.js +242 -0
  317. package/xr/spatial-scene.js +212 -0
  318. package/xr/theme-bridge.js +105 -0
  319. package/xr/three-webxr-adapter.js +3439 -0
  320. package/xr/webgl-layer-renderer.js +419 -0
  321. package/xr/webxr.js +679 -0
  322. package/xr/workbench.js +516 -0
@@ -0,0 +1,1697 @@
1
+ import Symbiote from '@symbiotejs/symbiote';
2
+ import { ForceLayout } from '../ForceLayout.js';
3
+ import { createCanvasGraphStore } from '../graph-model.js';
4
+ import css from './CanvasGraph.css.js';
5
+ import {
6
+ DOT_RADIUS,
7
+ HIT_RADIUS,
8
+ getCanvasNodeScreenHit,
9
+ getGroupOrbitMetrics,
10
+ getLayerTransform,
11
+ getNodeHitRadius,
12
+ getNodeColor,
13
+ getNodeRadius,
14
+ getRadialMenuHit,
15
+ getRadialMenuLayout,
16
+ } from './CanvasGraphGeometry.js';
17
+ import { GRAPH_TYPE_COLOR_TOKENS } from '../../graph/theme-contract.js';
18
+ import {
19
+ getDepthGroupsFrame,
20
+ getLayerAnimationFrame,
21
+ getNextPulseQueue,
22
+ resolveGroupOrbitRotationFrame,
23
+ resolveDeactivationFrame,
24
+ resolveFocusFrame,
25
+ resolveIdleFrame,
26
+ resolveViewportAnimation,
27
+ } from './CanvasGraphDrawState.js';
28
+
29
+ const INIT_NODE_COUNT = 40;
30
+ const EDGE_RATIO = 1.2;
31
+
32
+ const NODE_TYPES = ['data', 'action', 'output', 'config', 'external', 'style', 'docs', 'asset'];
33
+
34
+ const DEFAULT_EVENT_NAMES = Object.freeze({
35
+ fileSelected: 'file-selected',
36
+ groupSelected: 'group-selected',
37
+ layoutDone: 'layout-done',
38
+ layoutSnapshot: 'layout-snapshot',
39
+ layoutTick: 'layout-tick',
40
+ nodeDeselected: 'node-deselected',
41
+ pathChanged: 'path-changed',
42
+ toolbarAction: 'toolbar-action',
43
+ });
44
+
45
+ const DEFAULT_MENU_ITEMS = Object.freeze([
46
+ { action: 'drill', label: 'Enter Group', path: 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z' },
47
+ { 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' },
48
+ { 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' },
49
+ ]);
50
+
51
+ function toRgba(rgb, alpha = 1) {
52
+ return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
53
+ }
54
+
55
+ function parseCssRgb(value) {
56
+ let match = String(value || '').match(/rgba?\(([^)]+)\)/i);
57
+ if (!match) return null;
58
+ let parts = match[1].split(',').slice(0, 3).map((part) => Number.parseFloat(part));
59
+ return parts.every(Number.isFinite) ? parts.map((part) => Math.max(0, Math.min(255, Math.round(part)))) : null;
60
+ }
61
+
62
+ function resolveCanvasColor(value, fallback) {
63
+ if (!value || typeof document === 'undefined') return fallback;
64
+ let ctx = resolveCanvasColor.ctx || document.createElement('canvas').getContext('2d');
65
+ resolveCanvasColor.ctx = ctx;
66
+ ctx.fillStyle = 'rgba(0, 0, 0, 0)';
67
+ ctx.fillStyle = value;
68
+ let normalized = ctx.fillStyle;
69
+ if (normalized.startsWith('#')) {
70
+ let hex = normalized.slice(1);
71
+ if (hex.length === 3) hex = hex.split('').map((part) => part + part).join('');
72
+ if (/^[0-9a-f]{6}$/i.test(hex)) {
73
+ return [
74
+ Number.parseInt(hex.slice(0, 2), 16),
75
+ Number.parseInt(hex.slice(2, 4), 16),
76
+ Number.parseInt(hex.slice(4, 6), 16),
77
+ ];
78
+ }
79
+ }
80
+ return parseCssRgb(normalized) || fallback;
81
+ }
82
+
83
+ function resolveCssVars(source, value, seen = new Set()) {
84
+ return String(value || '').replace(/var\(\s*(--[\w-]+)\s*\)/g, (_, token) => {
85
+ if (seen.has(token)) return '';
86
+ seen.add(token);
87
+ let nextValue = getComputedStyle(source).getPropertyValue(token).trim();
88
+ return resolveCssVars(source, nextValue, seen);
89
+ });
90
+ }
91
+
92
+ function readThemeRgb(source, token, fallback) {
93
+ let value = resolveCssVars(source, getComputedStyle(source).getPropertyValue(token).trim());
94
+ return resolveCanvasColor(value, fallback);
95
+ }
96
+
97
+ export class CanvasGraph extends Symbiote {
98
+ init$ = {
99
+ // These defaults will be updated from external controller if needed
100
+ chargeStrength: -150,
101
+ linkDistance: 150,
102
+ linkStrength: 0.25,
103
+ centerStrength: 0,
104
+ velocityDecay: 0.92,
105
+ collideStrength: 1.0,
106
+ alphaDecay: 0.015,
107
+ theta: 0.7,
108
+ alphaFloor: 0.0001,
109
+ alphaTarget: 0.0001,
110
+ brownian: 0,
111
+ brownianThresh: 0.001,
112
+ pinReheat: 0.02,
113
+ pinCap: 0.08,
114
+ wellStrength: 0.8,
115
+ centerPull: 0.3,
116
+ wellRepulsion: 5.0,
117
+ crossLinkScale: 0.2,
118
+ };
119
+
120
+ _bgR = 15;
121
+ _bgG = 23;
122
+ _bgB = 42;
123
+ _bgRgb = [26, 26, 26];
124
+ _edgeRgb = [74, 158, 255];
125
+ _pulseRgb = [76, 139, 245];
126
+ _dangerRgb = [244, 67, 54];
127
+ _textRgb = [240, 240, 240];
128
+ _textDimRgb = [153, 153, 153];
129
+ _typeColorRgb = {};
130
+ _ghostColor = 'rgb(51,51,51)';
131
+
132
+ initCallback() {
133
+ this.eventNames = { ...DEFAULT_EVENT_NAMES, ...this.eventNames };
134
+ this.actionItems = Array.isArray(this.actionItems) ? this.actionItems : [...DEFAULT_MENU_ITEMS];
135
+ this.semanticPathPrefix = typeof this.semanticPathPrefix === 'string' ? this.semanticPathPrefix : 'cluster:';
136
+
137
+ this.nodes = [];
138
+ this.edges = [];
139
+ this.nodeMap = new Map();
140
+ this.adjMap = new Map();
141
+ this.interactionDepths = new Map();
142
+ this.nodePositions = new Map();
143
+
144
+ this.worker = null;
145
+ this.paused = false;
146
+ this.dragNode = null;
147
+ this.activeNode = null;
148
+ this.hoverNode = null;
149
+ this.nextActiveNode = null;
150
+ this.deactivating = false;
151
+ this.menuAnim = 0;
152
+ this.dragOffset = { x: 0, y: 0 };
153
+ this.renderMode = 'dots';
154
+
155
+ this.focusX = 0;
156
+ this.focusY = 0;
157
+ this.focusActive = false;
158
+
159
+ this.panX = 0;
160
+ this.panY = 0;
161
+ this.zoom = 0.5;
162
+ this._targetZoom = 0.5;
163
+ this._targetPanX = null; // null = no animation target
164
+ this._targetPanY = null;
165
+ this._zoomAnchor = null; // {mx, my} — screen point to keep stable during zoom
166
+ this.isPanning = false;
167
+ this.panStart = { x: 0, y: 0, px: 0, py: 0 };
168
+
169
+ this.frameCount = 0;
170
+ this.tickCount = 0;
171
+ this.lastFpsTime = performance.now();
172
+ this.lastAlpha = 0;
173
+
174
+ this.smoothPositions = new Map();
175
+ this.prevPositions = new Map();
176
+ this.smoothing = 0.99;
177
+
178
+ this.graphDB = { nodes: new Map(), edges: [], rootNodes: [] };
179
+ this.currentGroupId = null;
180
+ this._loopRunning = false; // Whether the rAF draw loop is active
181
+ this._idleFrames = 0; // Count consecutive frames with no visual change
182
+ this._prevDragDeltaX = 0; // Previous frame's focus drag delta X
183
+ this._prevDragDeltaY = 0; // Previous frame's focus drag delta Y
184
+ this._visualDragDeltaX = 0;
185
+ this._visualDragDeltaY = 0;
186
+ this._dragWorldTransform = null;
187
+ this._layoutSnapshot = null;
188
+
189
+ // Info panel state (typewriter HUD to the right of active node)
190
+ this._infoPanel = {
191
+ nodeId: null,
192
+ lines: [],
193
+ opacity: 0,
194
+ startTime: 0,
195
+ totalExtent: 0,
196
+ totalExtentY: 0,
197
+ _centeredForNode: null, // Track which node we've centered for
198
+ };
199
+
200
+ this.breadcrumb = document.createElement('graph-breadcrumb');
201
+ this.appendChild(this.breadcrumb);
202
+
203
+ this.canvas = document.createElement('canvas');
204
+ this.appendChild(this.canvas);
205
+ this.ctx = this.canvas.getContext('2d');
206
+
207
+ this.offscreenCanvases = {};
208
+ for (let i = 1; i <= 4; i++) {
209
+ const oc = document.createElement('canvas');
210
+ this.offscreenCanvases[i] = { canvas: oc, ctx: oc.getContext('2d', { alpha: true }) };
211
+ }
212
+
213
+ this.layerAnim = {
214
+ 0: { scale: 1, opacity: 1, parallax: 0 },
215
+ 1: { scale: 1, opacity: 1, parallax: 0 },
216
+ 2: { scale: 1, opacity: 1, parallax: 0 },
217
+ 3: { scale: 1, opacity: 1, parallax: 0 },
218
+ 4: { scale: 1, opacity: 1, parallax: 0 }
219
+ };
220
+
221
+ this.LAYER_TARGETS = {
222
+ scale: [1.12, 1.0, 0.95, 0.88, 0.78],
223
+ opacity: [1.0, 0.9, 0.55, 0.06, 0.03],
224
+ blur: [0, 0, 1, 3, 5],
225
+ parallax: [0, 0, 0.02, 0.04, 0.07]
226
+ };
227
+
228
+ this.depthGroups = {
229
+ 0: { edges: [], nodes: [] },
230
+ 1: { edges: [], nodes: [] },
231
+ 2: { edges: [], nodes: [] },
232
+ 3: { edges: [], nodes: [] },
233
+ 4: { edges: [], nodes: [] }
234
+ };
235
+
236
+ const resizeObserver = new ResizeObserver(() => this.resizeCanvas());
237
+ resizeObserver.observe(this);
238
+ this.resizeCanvas();
239
+
240
+ this.bindEvents();
241
+
242
+ this._wakeLoop();
243
+
244
+ // Bind graph-breadcrumb from symbiote-node
245
+ if (this.breadcrumb?.onNavigate) {
246
+ this.breadcrumb.onNavigate((levelStr) => {
247
+ // levelStr is the path string we passed into 'level' property
248
+ this.setPath(levelStr || null);
249
+ });
250
+ }
251
+
252
+ setTimeout(() => this.syncCanvasTheme(), 100);
253
+ }
254
+
255
+ disconnectedCallback() {
256
+ this._loopRunning = false;
257
+ if (this._animationFrame) cancelAnimationFrame(this._animationFrame);
258
+ if (this.worker) this.worker.stop();
259
+ }
260
+
261
+ /**
262
+ * Ensure the rAF draw loop is running. Safe to call repeatedly.
263
+ * Called by all state-changing entry points (interaction, worker, resize).
264
+ */
265
+ _wakeLoop() {
266
+ if (this._loopRunning) return;
267
+ this._loopRunning = true;
268
+ this._idleFrames = 0;
269
+ this._animationFrame = requestAnimationFrame(() => this.draw());
270
+ }
271
+
272
+ resizeCanvas() {
273
+ const dpr = window.devicePixelRatio || 1;
274
+ const rect = this.getBoundingClientRect();
275
+ this._wakeLoop(); // Dimensions changed — redraw
276
+ if (rect.width === 0) return;
277
+ this.canvas.style.width = rect.width + 'px';
278
+ this.canvas.style.height = rect.height + 'px';
279
+ this.canvas.width = rect.width * dpr;
280
+ this.canvas.height = rect.height * dpr;
281
+ }
282
+
283
+ resetView() {
284
+ this.fitView();
285
+ }
286
+
287
+ fitView(padding = 60, animate = true) {
288
+ if (!this.nodePositions.size) return;
289
+ const rect = this.canvas.getBoundingClientRect();
290
+ if (rect.width === 0 || rect.height === 0) return;
291
+
292
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
293
+ for (const pos of this.nodePositions.values()) {
294
+ if (pos.x < minX) minX = pos.x;
295
+ if (pos.y < minY) minY = pos.y;
296
+ if (pos.x > maxX) maxX = pos.x;
297
+ if (pos.y > maxY) maxY = pos.y;
298
+ }
299
+
300
+ const graphW = maxX - minX || 1;
301
+ const graphH = maxY - minY || 1;
302
+ const cx = (minX + maxX) / 2;
303
+ const cy = (minY + maxY) / 2;
304
+
305
+ const newZoom = Math.max(0.02, Math.min(
306
+ (rect.width - padding * 2) / graphW,
307
+ (rect.height - padding * 2) / graphH,
308
+ 2.0
309
+ ));
310
+ const newPanX = rect.width / 2 - cx * newZoom;
311
+ const newPanY = rect.height / 2 - cy * newZoom;
312
+
313
+ if (animate) {
314
+ this._targetZoom = newZoom;
315
+ this._targetPanX = newPanX;
316
+ this._targetPanY = newPanY;
317
+ this._zoomAnchor = null;
318
+ } else {
319
+ this.zoom = newZoom;
320
+ this._targetZoom = newZoom;
321
+ this.panX = newPanX;
322
+ this.panY = newPanY;
323
+ this._targetPanX = null;
324
+ this._targetPanY = null;
325
+ }
326
+ this.needsDraw = true;
327
+ this._wakeLoop();
328
+ }
329
+
330
+ pulseNode(nodeId, durationMs = 1500) {
331
+ this._pulses = getNextPulseQueue({
332
+ pulses: this._pulses || [],
333
+ nodeId,
334
+ startTime: performance.now(),
335
+ duration: durationMs,
336
+ });
337
+ this.needsDraw = true;
338
+ this._wakeLoop();
339
+ }
340
+
341
+ flyToNode(nodeId, options = {}) {
342
+ const node = this.graphDB?.nodes.get(nodeId);
343
+ if (node && node.parentId) {
344
+ if (node.parentId !== this.currentGroupId) {
345
+ this.loadLevel(node.parentId, { enterSemanticCluster: true });
346
+ setTimeout(() => this.flyToNode(nodeId, options), 500);
347
+ return;
348
+ }
349
+ }
350
+
351
+ const pos = this.getSmooth(nodeId) || this.nodePositions.get(nodeId);
352
+ if (!pos) return;
353
+
354
+ const rect = this.canvas.getBoundingClientRect();
355
+ if (rect.width === 0) return;
356
+
357
+ // Set zoom target: use provided zoom level, or force a comfortable minimum for focus
358
+ const targetZoom = options.zoom || Math.max(1.2, Math.min(2.0, this.zoom));
359
+ this._targetZoom = targetZoom;
360
+ this._targetPanX = rect.width / 2 - pos.x * targetZoom;
361
+ this._targetPanY = rect.height / 2 - pos.y * targetZoom;
362
+ this._zoomAnchor = null;
363
+
364
+ // Activate the node
365
+ const foundNode = this.nodeMap?.get(nodeId);
366
+ if (foundNode) {
367
+ this.activeNode = foundNode;
368
+ this.updateInteractionDepths();
369
+ }
370
+ this.needsDraw = true;
371
+ this._wakeLoop();
372
+ }
373
+
374
+ focusSemanticCluster(nodeId) {
375
+ const node = this.graphDB?.nodes.get(nodeId);
376
+ if (!node?.isSemanticCluster) return;
377
+ if (this.currentGroupId) {
378
+ this.loadLevel(null);
379
+ }
380
+ this.pulseNode(nodeId, 1800);
381
+ requestAnimationFrame(() => {
382
+ this.flyToNode(nodeId, { zoom: 1.1 });
383
+ });
384
+ }
385
+
386
+ setLayoutSnapshot(snapshot) {
387
+ if (!snapshot || typeof snapshot !== 'object') {
388
+ this._layoutSnapshot = null;
389
+ return;
390
+ }
391
+ this._layoutSnapshot = {
392
+ positions: snapshot.positions && typeof snapshot.positions === 'object' ? snapshot.positions : {},
393
+ viewport: snapshot.viewport && typeof snapshot.viewport === 'object' ? snapshot.viewport : null,
394
+ };
395
+ }
396
+
397
+ getLayoutSnapshot() {
398
+ const positions = {};
399
+ for (const [id, pos] of this.nodePositions.entries()) {
400
+ if (!this.graphDB?.nodes?.has(id)) continue;
401
+ if (!Number.isFinite(pos?.x) || !Number.isFinite(pos?.y)) continue;
402
+ positions[id] = { x: Math.round(pos.x * 100) / 100, y: Math.round(pos.y * 100) / 100 };
403
+ }
404
+ return {
405
+ version: 1,
406
+ groupId: this.currentGroupId || '',
407
+ viewport: {
408
+ panX: Math.round(this.panX * 100) / 100,
409
+ panY: Math.round(this.panY * 100) / 100,
410
+ zoom: Math.round(this.zoom * 1000) / 1000,
411
+ },
412
+ positions,
413
+ };
414
+ }
415
+
416
+ _emitLayoutSnapshot() {
417
+ this._emitGraphEvent('layoutSnapshot', this.getLayoutSnapshot());
418
+ }
419
+
420
+ setEventNames(eventNames = {}) {
421
+ this.eventNames = { ...DEFAULT_EVENT_NAMES, ...eventNames };
422
+ }
423
+
424
+ setActionItems(items) {
425
+ this.actionItems = Array.isArray(items) ? [...items] : [...DEFAULT_MENU_ITEMS];
426
+ }
427
+
428
+ getActionItems() {
429
+ return this.actionItems || [...DEFAULT_MENU_ITEMS];
430
+ }
431
+
432
+ setSemanticPathPrefix(prefix) {
433
+ this.semanticPathPrefix = typeof prefix === 'string' ? prefix : 'cluster:';
434
+ }
435
+
436
+ _isSemanticPath(path) {
437
+ return Boolean(this.semanticPathPrefix && typeof path === 'string' && path.startsWith(this.semanticPathPrefix));
438
+ }
439
+
440
+ _emitGraphEvent(name, detail = {}, options = {}) {
441
+ const type = this.eventNames?.[name] || DEFAULT_EVENT_NAMES[name] || name;
442
+ return this.dispatchEvent(new CustomEvent(type, { detail, ...options }));
443
+ }
444
+
445
+ setPath(pathStr) {
446
+ if (!pathStr) {
447
+ if (this.currentGroupId) this.loadLevel(null);
448
+ return;
449
+ }
450
+
451
+ if (this._isSemanticPath(pathStr)) {
452
+ this.focusSemanticCluster(pathStr);
453
+ return;
454
+ }
455
+
456
+ if (pathStr !== this.currentGroupId) {
457
+ this.loadLevel(pathStr);
458
+ }
459
+ }
460
+
461
+ setGraphModel(model) {
462
+ this.graphDB = createCanvasGraphStore(model);
463
+
464
+ // Center viewport BEFORE worker starts — prevents nodes flashing at top-left
465
+ const rect = this.canvas.getBoundingClientRect();
466
+ if (rect.width > 0) {
467
+ this.panX = rect.width / 2;
468
+ this.panY = rect.height / 2;
469
+ }
470
+
471
+ this.loadLevel(null);
472
+ }
473
+
474
+ rebuildNodeMap() { this.nodeMap = new Map(this.nodes.map(n => [n.id, n])); }
475
+
476
+ rebuildAdjMap() {
477
+ this.adjMap.clear();
478
+ for (const n of this.nodes) this.adjMap.set(n.id, new Set());
479
+ for (const e of this.edges) {
480
+ if (this.adjMap.has(e.from)) this.adjMap.get(e.from).add(e.to);
481
+ if (this.adjMap.has(e.to)) this.adjMap.get(e.to).add(e.from);
482
+ }
483
+ }
484
+
485
+ updateInteractionDepths() {
486
+ this.interactionDepths.clear();
487
+ const activeGroupId = this.currentGroupId;
488
+ const focusNode = this.activeNode || this.dragNode;
489
+
490
+ // Establish baseline target depths for all nodes
491
+ for (const node of this.nodes) {
492
+ if (activeGroupId) {
493
+ if (node.parentId === activeGroupId) node.targetDepth = focusNode ? 3 : 0;
494
+ else if (node.id === activeGroupId) node.targetDepth = 4; // Hide the container group itself
495
+ else node.targetDepth = 4; // Other nodes hidden when inside a group
496
+ } else {
497
+ node.targetDepth = focusNode ? 3 : 0; // Dim to 3 if focused, 0 otherwise
498
+ }
499
+ }
500
+
501
+ for (const edge of this.edges) { edge.targetDepth = 4; edge.minTargetDepth = 4; }
502
+
503
+ if (!focusNode) {
504
+ for (const edge of this.edges) {
505
+ const d1 = this.nodeMap.get(edge.from)?.targetDepth ?? 4;
506
+ const d2 = this.nodeMap.get(edge.to)?.targetDepth ?? 4;
507
+ edge.targetDepth = Math.max(d1, d2);
508
+ edge.minTargetDepth = Math.min(d1, d2);
509
+ }
510
+ return;
511
+ }
512
+
513
+ // BFS from focusNode
514
+ const queue = [[focusNode.id, 0]];
515
+ const visited = new Set([focusNode.id]);
516
+ this.interactionDepths.set(focusNode.id, 0);
517
+
518
+ while (queue.length > 0) {
519
+ const [curr, depth] = queue.shift();
520
+ const currNode = this.nodeMap.get(curr);
521
+ if (currNode) currNode.targetDepth = depth;
522
+
523
+ if (depth >= 3) continue;
524
+ const neighbors = this.adjMap.get(curr) || new Set();
525
+ for (const n of neighbors) {
526
+ if (!visited.has(n)) {
527
+ visited.add(n);
528
+ this.interactionDepths.set(n, depth + 1);
529
+ queue.push([n, depth + 1]);
530
+ }
531
+ }
532
+ }
533
+
534
+ for (const edge of this.edges) {
535
+ const d1 = this.interactionDepths.has(edge.from) ? this.interactionDepths.get(edge.from) : 4;
536
+ const d2 = this.interactionDepths.has(edge.to) ? this.interactionDepths.get(edge.to) : 4;
537
+ edge.targetDepth = Math.max(d1, d2);
538
+ edge.minTargetDepth = Math.min(d1, d2);
539
+ }
540
+ }
541
+
542
+ loadLevel(groupId = null, levelOptions = {}) {
543
+ const requestedGroup = groupId ? this.graphDB.nodes.get(groupId) : null;
544
+ if (requestedGroup?.isSemanticCluster && !levelOptions.enterSemanticCluster) {
545
+ this.focusSemanticCluster(groupId);
546
+ return;
547
+ }
548
+
549
+ this._wakeLoop(); // View changed — resume rendering
550
+ this.activeNode = null;
551
+ this.dragNode = null;
552
+ this.hoverNode = null;
553
+ this.menuAnim = 0;
554
+ this.deactivating = false;
555
+
556
+ for (const node of this.graphDB.nodes.values()) {
557
+ if (node.isGroup) {
558
+ const groupR = getNodeRadius(node, 0);
559
+ node.w = groupR * 2;
560
+ node.h = groupR * 2;
561
+ }
562
+ }
563
+
564
+ let activeIds = [...this.graphDB.rootNodes];
565
+
566
+ if (!groupId) {
567
+ this.currentGroupId = null;
568
+ if (this.breadcrumb?.setPath) this.breadcrumb.setPath([]);
569
+ } else {
570
+ const group = this.graphDB.nodes.get(groupId);
571
+ if (group) {
572
+ this.currentGroupId = groupId;
573
+ if (!activeIds.includes(groupId)) activeIds.push(groupId);
574
+ activeIds.push(...group.children);
575
+
576
+ const childR = DOT_RADIUS * 1.5;
577
+ const dynamicSize = Math.sqrt(group.children.length) * childR * 3 + childR * 4;
578
+ group.w = dynamicSize;
579
+ group.h = dynamicSize;
580
+
581
+ // Render existing symbiote-node breadcrumbs
582
+ if (this.breadcrumb?.setPath) {
583
+ const parts = groupId.split('/');
584
+ const pathArr = [{ label: 'Root', level: '' }];
585
+ let acc = '';
586
+ for (let i = 0; i < parts.length; i++) {
587
+ if (!parts[i]) continue;
588
+ acc += (acc ? '/' : '') + parts[i];
589
+ pathArr.push({ label: parts[i], level: acc });
590
+ }
591
+ this.breadcrumb.setPath(pathArr);
592
+ }
593
+
594
+ } else {
595
+ // Fallback to root if group not found
596
+ this.currentGroupId = null;
597
+ if (this.breadcrumb?.setPath) this.breadcrumb.setPath([]);
598
+ }
599
+ }
600
+
601
+ this.nodes = activeIds.map(id => this.graphDB.nodes.get(id)).filter(Boolean);
602
+
603
+ for (const n of this.nodes) {
604
+ if (n.parentId && n.parentId === groupId) {
605
+ n.w = this.renderMode === 'dots' ? DOT_RADIUS * 1.5 : 160 * 0.6;
606
+ n.h = this.renderMode === 'dots' ? DOT_RADIUS * 1.5 : 40 * 0.6;
607
+ }
608
+ }
609
+
610
+ const activeSet = new Set(activeIds);
611
+ this.edges = this.graphDB.edges.filter(e => activeSet.has(e.from) || activeSet.has(e.to));
612
+
613
+ this.rebuildNodeMap();
614
+ this.rebuildAdjMap();
615
+ this.updateInteractionDepths();
616
+
617
+ const options = {
618
+ chargeStrength: this.$.chargeStrength,
619
+ linkDistance: this.$.linkDistance,
620
+ linkStrength: this.$.linkStrength,
621
+ centerStrength: this.$.centerStrength,
622
+ velocityDecay: this.$.velocityDecay,
623
+ collideStrength: this.$.collideStrength,
624
+ alphaDecay: this.$.alphaDecay,
625
+ theta: this.$.theta,
626
+ nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
627
+ nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
628
+ mode: 'continuous',
629
+ activeGroupId: this.currentGroupId,
630
+ boundaryRadius: this.currentGroupId ? this.graphDB.nodes.get(this.currentGroupId).w / 2 : null,
631
+ attractors: null,
632
+ };
633
+
634
+ this.startWorker(options);
635
+
636
+ this._emitGraphEvent('pathChanged', { path: this.currentGroupId || '' });
637
+ }
638
+
639
+ startWorker(customOptions = null) {
640
+ if (this.worker) this.worker.stop();
641
+ this.worker = new ForceLayout(ForceLayout.defaultWorkerUrl());
642
+
643
+ this.worker.onTick = (positions, meta = {}) => {
644
+ const draggedId = this.dragNode ? this.dragNode.id : null;
645
+ for (const [id, p] of Object.entries(positions || {})) {
646
+ if (id === draggedId) continue;
647
+ const pos = this.nodePositions.get(id);
648
+ if (pos) {
649
+ pos.x = p.x;
650
+ pos.y = p.y;
651
+ } else {
652
+ this.nodePositions.set(id, p);
653
+ }
654
+ }
655
+ this.lastAlpha = meta.alpha || 0;
656
+ this.tickCount++;
657
+ this.frameCount++;
658
+ this._wakeLoop();
659
+ this._emitGraphEvent('layoutTick', { alpha: this.lastAlpha });
660
+ };
661
+
662
+ this.worker.onDone = (positions) => {
663
+ if (positions) {
664
+ for (const [id, pos] of Object.entries(positions)) this.nodePositions.set(id, pos);
665
+ }
666
+ this._emitGraphEvent('layoutDone');
667
+ this._emitLayoutSnapshot();
668
+ };
669
+
670
+ const options = customOptions || {
671
+ chargeStrength: this.$.chargeStrength,
672
+ linkDistance: this.$.linkDistance,
673
+ linkStrength: this.$.linkStrength,
674
+ centerStrength: this.$.centerStrength,
675
+ velocityDecay: this.$.velocityDecay,
676
+ collideStrength: this.$.collideStrength,
677
+ alphaDecay: this.$.alphaDecay,
678
+ theta: this.$.theta,
679
+ wellStrength: this.$.wellStrength,
680
+ centerPull: this.$.centerPull,
681
+ wellRepulsion: this.$.wellRepulsion,
682
+ crossLinkScale: this.$.crossLinkScale,
683
+ nodeWidth: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 160,
684
+ nodeHeight: this.renderMode === 'dots' ? DOT_RADIUS * 2 : 40,
685
+ mode: 'continuous',
686
+ };
687
+
688
+ this.worker.start({
689
+ nodes: this.nodes.map(n => {
690
+ const restoredPos = this._layoutSnapshot?.positions?.[n.id];
691
+ const pos = this.smoothPositions.get(n.id) || this.nodePositions.get(n.id) || restoredPos;
692
+ if (restoredPos && !this.nodePositions.has(n.id)) {
693
+ this.nodePositions.set(n.id, { x: restoredPos.x, y: restoredPos.y });
694
+ }
695
+ let finalW = n.w, finalH = n.h;
696
+ if (this.renderMode === 'dots') {
697
+ const conns = this.adjMap.get(n.id)?.size || 0;
698
+ const r = getNodeRadius(n, conns);
699
+ finalW = finalH = r * 2;
700
+ }
701
+ return {
702
+ id: n.id, type: n.type, parentId: n.parentId, isGroup: !!n.isGroup,
703
+ children: n.children || [], x: pos?.x, y: pos?.y, w: finalW, h: finalH,
704
+ };
705
+ }),
706
+ edges: this.edges.filter(e => this.nodeMap.has(e.from) && this.nodeMap.has(e.to)),
707
+ groups: {}, options
708
+ });
709
+
710
+ this.worker.updateConfig({
711
+ contAlphaFloor: this.$.alphaFloor, contAlphaTarget: this.$.alphaTarget,
712
+ brownian: this.$.brownian, brownianThresh: this.$.brownianThresh,
713
+ pinReheat: this.$.pinReheat, pinCap: this.$.pinCap,
714
+ });
715
+
716
+ this.smoothPositions.clear();
717
+ const viewport = this._layoutSnapshot?.viewport;
718
+ if (viewport && Number.isFinite(viewport.panX) && Number.isFinite(viewport.panY) && Number.isFinite(viewport.zoom)) {
719
+ this.panX = viewport.panX;
720
+ this.panY = viewport.panY;
721
+ this.zoom = viewport.zoom;
722
+ this._targetPanX = null;
723
+ this._targetPanY = null;
724
+ this._targetZoom = viewport.zoom;
725
+ }
726
+ this.paused = false;
727
+ }
728
+
729
+ getSmooth(id) { return this.smoothPositions.get(id) || this.nodePositions.get(id); }
730
+
731
+ nodeCenter(id) {
732
+ const pos = this.getSmooth(id);
733
+ if (!pos) return null;
734
+ if (this.renderMode === 'dots') return { x: pos.x, y: pos.y };
735
+ const node = this.nodeMap.get(id);
736
+ if (!node) return { x: pos.x, y: pos.y };
737
+ return { x: pos.x + node.w / 2, y: pos.y + node.h / 2 };
738
+ }
739
+
740
+ resizeOffscreenCanvases() {
741
+ const dpr = window.devicePixelRatio || 1;
742
+ for (let i = 1; i <= 4; i++) {
743
+ const oc = this.offscreenCanvases[i].canvas;
744
+ if (oc.width !== this.canvas.width || oc.height !== this.canvas.height) {
745
+ oc.width = this.canvas.width;
746
+ oc.height = this.canvas.height;
747
+ }
748
+ }
749
+ }
750
+
751
+ blendBg(r, g, b, alpha) {
752
+ const br = this._bgR, bg = this._bgG, bb = this._bgB;
753
+ const rr = (r * alpha + br * (1 - alpha)) | 0;
754
+ const gg = (g * alpha + bg * (1 - alpha)) | 0;
755
+ const bbb = (b * alpha + bb * (1 - alpha)) | 0;
756
+ return `rgb(${rr},${gg},${bbb})`;
757
+ }
758
+
759
+ syncCanvasTheme() {
760
+ this._bgRgb = readThemeRgb(this, '--sn-bg', this._bgRgb);
761
+ this._edgeRgb = readThemeRgb(this, '--sn-conn-color', this._edgeRgb);
762
+ this._pulseRgb = readThemeRgb(this, '--sn-node-selected', this._pulseRgb);
763
+ this._dangerRgb = readThemeRgb(this, '--sn-danger-color', this._dangerRgb);
764
+ this._textRgb = readThemeRgb(this, '--sn-text', this._textRgb);
765
+ this._textDimRgb = readThemeRgb(this, '--sn-text-dim', this._textDimRgb);
766
+ for (let [type, token] of Object.entries(GRAPH_TYPE_COLOR_TOKENS)) {
767
+ this._typeColorRgb[type] = readThemeRgb(this, token, this._typeColorRgb[type] || this._edgeRgb);
768
+ }
769
+
770
+ [this._bgR, this._bgG, this._bgB] = this._bgRgb;
771
+ let boost = 25;
772
+ this._ghostColor = `rgb(${Math.min(255, this._bgR + boost)},${Math.min(255, this._bgG + boost)},${Math.min(255, this._bgB + boost)})`;
773
+ }
774
+
775
+ draw() {
776
+ if (!this.canvas) return;
777
+ const dpr = window.devicePixelRatio || 1;
778
+
779
+ let viewport = resolveViewportAnimation({
780
+ zoom: this.zoom,
781
+ targetZoom: this._targetZoom,
782
+ panX: this.panX,
783
+ panY: this.panY,
784
+ targetPanX: this._targetPanX,
785
+ targetPanY: this._targetPanY,
786
+ zoomAnchor: this._zoomAnchor,
787
+ });
788
+ this.zoom = viewport.zoom;
789
+ this.panX = viewport.panX;
790
+ this.panY = viewport.panY;
791
+ this._targetPanX = viewport.targetPanX;
792
+ this._targetPanY = viewport.targetPanY;
793
+
794
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0);
795
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
796
+
797
+ this.resizeOffscreenCanvases();
798
+ const mainCtx = this.ctx;
799
+ const isIdle = (!this.activeNode && !this.currentGroupId) || this.deactivating;
800
+
801
+ let deactivation = resolveDeactivationFrame({
802
+ deactivating: this.deactivating,
803
+ activeNode: this.activeNode,
804
+ nextActiveNode: this.nextActiveNode,
805
+ layerAnim: this.layerAnim,
806
+ });
807
+ this.activeNode = deactivation.activeNode;
808
+ this.nextActiveNode = deactivation.nextActiveNode;
809
+ this.deactivating = deactivation.deactivating;
810
+ if (deactivation.deselected) {
811
+ this._emitGraphEvent('nodeDeselected');
812
+ }
813
+ if (deactivation.interactionDepthsChanged) {
814
+ this.updateInteractionDepths();
815
+ }
816
+
817
+ const inGroupMode = !!this.currentGroupId;
818
+ this.layerAnim = getLayerAnimationFrame({
819
+ layerAnim: this.layerAnim,
820
+ layerTargets: this.LAYER_TARGETS,
821
+ isIdle,
822
+ inGroupMode,
823
+ });
824
+
825
+ const vcx = this.canvas.width / 2;
826
+ const vcy = this.canvas.height / 2;
827
+ let dragDeltaX = 0, dragDeltaY = 0;
828
+
829
+ let activePosition = this.activeNode ? this.nodePositions.get(this.activeNode.id) : null;
830
+ let shouldCenterFocus = this.activeNode
831
+ && !this.deactivating
832
+ && this._infoPanel._centeredForNode !== this.activeNode.id
833
+ && this._infoPanel.totalExtent > 0;
834
+ let focus = resolveFocusFrame({
835
+ activeNode: this.activeNode,
836
+ deactivating: this.deactivating,
837
+ activePosition,
838
+ infoPanel: this._infoPanel,
839
+ canvasRect: shouldCenterFocus ? this.canvas.getBoundingClientRect() : null,
840
+ dpr,
841
+ zoom: this.zoom,
842
+ panX: this.panX,
843
+ panY: this.panY,
844
+ focusX: this.focusX,
845
+ focusY: this.focusY,
846
+ focusActive: this.focusActive,
847
+ vcx,
848
+ vcy,
849
+ });
850
+ this.focusX = focus.focusX;
851
+ this.focusY = focus.focusY;
852
+ this.focusActive = focus.focusActive;
853
+ dragDeltaX = focus.dragDeltaX;
854
+ dragDeltaY = focus.dragDeltaY;
855
+ this._visualDragDeltaX = dragDeltaX;
856
+ this._visualDragDeltaY = dragDeltaY;
857
+ this._infoPanel._centeredForNode = focus.centeredForNode;
858
+ if (focus.targetPanX !== null) {
859
+ this._targetPanX = focus.targetPanX;
860
+ this._targetPanY = focus.targetPanY;
861
+ }
862
+
863
+ for (let i = 1; i <= 4; i++) {
864
+ const octx = this.offscreenCanvases[i].ctx;
865
+ const la = this.layerAnim[i];
866
+ const s = la.scale;
867
+ const pOffX = -la.parallax * dragDeltaX;
868
+ const pOffY = -la.parallax * dragDeltaY;
869
+
870
+ octx.setTransform(1, 0, 0, 1, 0, 0);
871
+ octx.clearRect(0, 0, this.canvas.width, this.canvas.height);
872
+ octx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom,
873
+ s * dpr * this.panX + vcx * (1 - s) + pOffX,
874
+ s * dpr * this.panY + vcy * (1 - s) + pOffY);
875
+ }
876
+
877
+ const t = 1 - this.smoothing;
878
+ for (const [id, raw] of this.nodePositions) {
879
+ const prev = this.smoothPositions.get(id);
880
+ if (!prev) {
881
+ this.smoothPositions.set(id, { x: raw.x, y: raw.y });
882
+ } else {
883
+ if (this.dragNode && this.dragNode.id === id) {
884
+ prev.x = raw.x; prev.y = raw.y;
885
+ } else {
886
+ prev.x += (raw.x - prev.x) * t;
887
+ prev.y += (raw.y - prev.y) * t;
888
+ }
889
+ }
890
+ }
891
+
892
+ this.depthGroups = getDepthGroupsFrame({
893
+ edges: this.edges,
894
+ nodes: this.nodes,
895
+ activeNode: this.activeNode,
896
+ dragNode: this.dragNode,
897
+ hoverNode: this.hoverNode,
898
+ });
899
+
900
+ const resolveLayerTransform = (d) => {
901
+ return getLayerTransform({
902
+ depth: d,
903
+ layerAnim: this.layerAnim,
904
+ dpr,
905
+ zoom: this.zoom,
906
+ panX: this.panX,
907
+ panY: this.panY,
908
+ vcx,
909
+ vcy,
910
+ focusActive: this.focusActive,
911
+ focusX: this.focusX,
912
+ focusY: this.focusY,
913
+ dragDeltaX,
914
+ dragDeltaY,
915
+ });
916
+ };
917
+
918
+ const drawDepth = (d, currentCtx) => {
919
+ const la = this.layerAnim[d];
920
+ const layerOpacity = la.opacity;
921
+ const isGhost = inGroupMode && d >= 3;
922
+ const GHOST_COLOR = this._ghostColor;
923
+ const tCurrent = resolveLayerTransform(d);
924
+
925
+ const mapPosToEdgeLayer = (pos, nodeDepth) => {
926
+ if (!pos || nodeDepth === d) return pos;
927
+ const tNode = resolveLayerTransform(nodeDepth);
928
+ const screenX = tNode.A * pos.x + tNode.E;
929
+ const screenY = tNode.A * pos.y + tNode.F;
930
+ return { x: (screenX - tCurrent.E) / tCurrent.A, y: (screenY - tCurrent.F) / tCurrent.A };
931
+ };
932
+
933
+ currentCtx.strokeStyle = toRgba(this._edgeRgb, 0.25);
934
+ currentCtx.lineWidth = 1.5;
935
+
936
+ // Edges
937
+ for (const edge of this.depthGroups[d].edges) {
938
+ let from = this.nodeCenter(edge.from);
939
+ let to = this.nodeCenter(edge.to);
940
+
941
+ if ((!from || !to) && this.currentGroupId) {
942
+ const activeId = this.currentGroupId;
943
+ const activePos = this.smoothPositions.get(activeId);
944
+ const activeNode = this.graphDB.nodes.get(activeId);
945
+ if (activePos && activeNode) {
946
+ const radius = activeNode.w / 2;
947
+ if (!from && to) {
948
+ const angle = parseInt(edge.from.slice(-1), 16) || 0;
949
+ from = { x: activePos.x + Math.cos(angle) * radius, y: activePos.y + Math.sin(angle) * radius };
950
+ } else if (from && !to) {
951
+ const angle = parseInt(edge.to.slice(-1), 16) || 0;
952
+ to = { x: activePos.x + Math.cos(angle) * radius, y: activePos.y + Math.sin(angle) * radius };
953
+ }
954
+ }
955
+ }
956
+
957
+ if (!from || !to) continue;
958
+
959
+ let tAlpha = 0.5, tWidth = 1.5;
960
+ if (this.dragNode) {
961
+ const minD = edge.minTargetDepth;
962
+ if (minD === 0) { tAlpha = 1; tWidth = 3.0; }
963
+ else if (minD === 1) { tAlpha = 0.8; tWidth = 2.0; }
964
+ else if (minD === 2) { tAlpha = 0.4; tWidth = 1.5; }
965
+ else { tAlpha = 0.05; tWidth = 1.0; }
966
+ }
967
+
968
+ const edgeOpacity = tAlpha * layerOpacity;
969
+ edge.aAlpha = edge.aAlpha !== undefined ? edge.aAlpha : 0.5;
970
+ edge.aWidth = edge.aWidth || 1.5;
971
+ edge.aAlpha += (edgeOpacity - edge.aAlpha) * 0.1;
972
+ edge.aWidth += (tWidth - edge.aWidth) * 0.1;
973
+
974
+ const nodeFrom = this.nodeMap ? this.nodeMap.get(edge.from) : null;
975
+ const nodeTo = this.nodeMap ? this.nodeMap.get(edge.to) : null;
976
+ const fromDepth = nodeFrom?.targetDepth ?? 4;
977
+ const toDepth = nodeTo?.targetDepth ?? 4;
978
+
979
+ from = mapPosToEdgeLayer(from, fromDepth);
980
+ to = mapPosToEdgeLayer(to, toDepth);
981
+
982
+ const zoomFactor = this.zoom * (this.layerAnim[d]?.scale || 1);
983
+ const wFrom = (edge.aWidth * 2.0) / zoomFactor, wTo = wFrom;
984
+ const dx = to.x - from.x, dy = to.y - from.y;
985
+ const len = Math.sqrt(dx * dx + dy * dy);
986
+ if (len < 0.1) continue;
987
+
988
+ const nx = -dy / len, ny = dx / len;
989
+
990
+ let fillStyle;
991
+ if (isGhost) {
992
+ fillStyle = GHOST_COLOR;
993
+ } else if (this.dragNode || this.activeNode) {
994
+ const fromOpacity = this.layerAnim[fromDepth].opacity;
995
+ const toOpacity = this.layerAnim[toDepth].opacity;
996
+ const fromTC = getNodeColor(nodeFrom || {}, this._typeColorRgb);
997
+ const toTC = getNodeColor(nodeTo || {}, this._typeColorRgb);
998
+ const grad = currentCtx.createLinearGradient(from.x, from.y, to.x, to.y);
999
+ grad.addColorStop(0, this.blendBg(fromTC[0], fromTC[1], fromTC[2], fromOpacity * 0.7));
1000
+ grad.addColorStop(1, this.blendBg(toTC[0], toTC[1], toTC[2], toOpacity * 0.7));
1001
+ fillStyle = grad;
1002
+ } else {
1003
+ const fromTC = getNodeColor(nodeFrom || {}, this._typeColorRgb);
1004
+ fillStyle = this.blendBg(fromTC[0], fromTC[1], fromTC[2], 0.35);
1005
+ }
1006
+
1007
+ currentCtx.fillStyle = fillStyle;
1008
+ currentCtx.beginPath();
1009
+ const midX = from.x + dx * 0.5, midY = from.y + dy * 0.5;
1010
+ const pinchRatio = Math.max(0.001, Math.pow(20 / Math.max(20, len), 2.8));
1011
+ const pinchW = Math.min(wFrom, wTo) * pinchRatio;
1012
+ const ang = Math.atan2(dy, dx);
1013
+
1014
+ currentCtx.moveTo(from.x + nx * wFrom, from.y + ny * wFrom);
1015
+ currentCtx.quadraticCurveTo(midX + nx * pinchW, midY + ny * pinchW, to.x + nx * wTo, to.y + ny * wTo);
1016
+ currentCtx.arc(to.x, to.y, wTo, ang + Math.PI/2, ang - Math.PI/2, true);
1017
+ currentCtx.quadraticCurveTo(midX - nx * pinchW, midY - ny * pinchW, from.x - nx * wFrom, from.y - ny * wFrom);
1018
+ currentCtx.arc(from.x, from.y, wFrom, ang - Math.PI/2, ang - Math.PI * 1.5, true);
1019
+ currentCtx.closePath();
1020
+ currentCtx.fill();
1021
+ }
1022
+
1023
+ // Nodes
1024
+ for (const node of this.depthGroups[d].nodes) {
1025
+ if (this.currentGroupId && node.id === this.currentGroupId) continue;
1026
+ const pos = this.getSmooth(node.id);
1027
+ if (!pos) continue;
1028
+ const isActive = this.activeNode && this.activeNode.id === node.id;
1029
+ const tc = getNodeColor(node, this._typeColorRgb);
1030
+ const conns = this.adjMap.get(node.id)?.size || 0;
1031
+
1032
+ const targetScale = isActive ? 1.5 : 1;
1033
+ node.aScale = node.aScale !== undefined ? node.aScale : 1;
1034
+ node.aScale += (targetScale - node.aScale) * 0.12;
1035
+
1036
+ node.aGlow = node.aGlow !== undefined ? node.aGlow : 0;
1037
+ node.aGlow += ((isActive ? 1 : 0) - node.aGlow) * 0.1;
1038
+
1039
+ if (this.renderMode === 'dots') {
1040
+ let r = getNodeRadius(node, conns, { scale: node.aScale });
1041
+
1042
+ if (isGhost) {
1043
+ currentCtx.beginPath();
1044
+ currentCtx.arc(pos.x, pos.y, r * 0.7, 0, Math.PI * 2);
1045
+ currentCtx.fillStyle = GHOST_COLOR;
1046
+ currentCtx.fill();
1047
+ } else if (node.isGroup) {
1048
+ const ringW = r * 0.12;
1049
+ currentCtx.beginPath();
1050
+ currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1051
+ currentCtx.fillStyle = toRgba(this._bgRgb, layerOpacity);
1052
+ currentCtx.fill();
1053
+
1054
+ currentCtx.beginPath();
1055
+ currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1056
+ currentCtx.arc(pos.x, pos.y, r - ringW, 0, Math.PI * 2, true);
1057
+ currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
1058
+ currentCtx.fill();
1059
+
1060
+ const { childCount, innerR, orbitR } = getGroupOrbitMetrics(node, conns, {
1061
+ scale: node.aScale || 1,
1062
+ });
1063
+ const isHovered = this.hoverNode && this.hoverNode.id === node.id;
1064
+ const isDragged = this.dragNode && this.dragNode.id === node.id;
1065
+ node.aRotSpeed = node.aRotSpeed || 0;
1066
+ const rotation = resolveGroupOrbitRotationFrame({
1067
+ rotation: node.aRot || 0,
1068
+ rotationSpeed: node.aRotSpeed,
1069
+ hovered: isHovered,
1070
+ dragged: isDragged,
1071
+ });
1072
+ node.aRotSpeed = rotation.rotationSpeed;
1073
+ node.aRot = rotation.rotation;
1074
+
1075
+ for (let k = 0; k < childCount; k++) {
1076
+ const angle = (k * Math.PI * 2 / childCount) - Math.PI / 2 + node.aRot;
1077
+ const cx = pos.x + Math.cos(angle) * orbitR;
1078
+ const cy = pos.y + Math.sin(angle) * orbitR;
1079
+ currentCtx.beginPath();
1080
+ currentCtx.arc(cx, cy, innerR, 0, Math.PI * 2);
1081
+ currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity * 0.7);
1082
+ currentCtx.fill();
1083
+ }
1084
+ if (node.aGlow > 0.01) {
1085
+ currentCtx.strokeStyle = `rgba(${tc[0]},${tc[1]},${tc[2]},${layerOpacity * 0.6 * node.aGlow})`;
1086
+ currentCtx.lineWidth = 2 * node.aGlow;
1087
+ currentCtx.beginPath();
1088
+ currentCtx.arc(pos.x, pos.y, r + 4 * node.aGlow, 0, Math.PI * 2);
1089
+ currentCtx.stroke();
1090
+ }
1091
+ } else {
1092
+ currentCtx.beginPath();
1093
+ currentCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1094
+ currentCtx.fillStyle = this.blendBg(tc[0], tc[1], tc[2], layerOpacity);
1095
+ currentCtx.fill();
1096
+ if (node.aGlow > 0.01) {
1097
+ currentCtx.strokeStyle = `rgba(${tc[0]},${tc[1]},${tc[2]},${layerOpacity * 0.6 * node.aGlow})`;
1098
+ currentCtx.lineWidth = 2 * node.aGlow;
1099
+ currentCtx.beginPath();
1100
+ currentCtx.arc(pos.x, pos.y, r + 4 * node.aGlow, 0, Math.PI * 2);
1101
+ currentCtx.stroke();
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+ };
1107
+
1108
+ for (let d = 4; d >= 1; d--) drawDepth(d, this.offscreenCanvases[d].ctx);
1109
+
1110
+ mainCtx.setTransform(1, 0, 0, 1, 0, 0);
1111
+ for (let d = 4; d >= 1; d--) {
1112
+ const blurPx = this.LAYER_TARGETS.blur[d];
1113
+ const blurIntensity = Math.abs(1 - this.layerAnim[d].scale) * blurPx * 8;
1114
+ mainCtx.filter = blurIntensity > 0.3 ? `blur(${blurIntensity.toFixed(1)}px)` : 'none';
1115
+ mainCtx.drawImage(this.offscreenCanvases[d].canvas, 0, 0);
1116
+ }
1117
+ mainCtx.filter = 'none';
1118
+
1119
+ {
1120
+ const s = this.layerAnim[0].scale;
1121
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
1122
+ 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);
1123
+ } else {
1124
+ mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1125
+ }
1126
+ drawDepth(0, mainCtx);
1127
+
1128
+ if (this._pulses && this._pulses.length > 0) {
1129
+ const now = performance.now();
1130
+ this._pulses = this._pulses.filter(p => {
1131
+ const elapsed = now - p.startTime;
1132
+ if (elapsed > p.duration) return false;
1133
+ const pos = this.getSmooth(p.id) || this.nodePositions.get(p.id);
1134
+ if (!pos) return false;
1135
+ const progress = elapsed / p.duration;
1136
+ const pulsePhase = (progress * 3) % 1;
1137
+ const r = 20 + (pulsePhase * 80);
1138
+ const opacity = 1 - pulsePhase;
1139
+ mainCtx.beginPath();
1140
+ mainCtx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
1141
+ mainCtx.fillStyle = toRgba(this._pulseRgb, opacity * 0.4);
1142
+ mainCtx.fill();
1143
+ mainCtx.lineWidth = 2;
1144
+ mainCtx.strokeStyle = toRgba(this._pulseRgb, opacity * 0.8);
1145
+ mainCtx.stroke();
1146
+ this.needsDraw = true;
1147
+ return true;
1148
+ });
1149
+ }
1150
+ }
1151
+
1152
+ const showMenu = this.activeNode && !this.dragNode && !this.deactivating;
1153
+ if (showMenu) {
1154
+ this.menuAnim = Math.min(1, this.menuAnim + 0.08);
1155
+ } else {
1156
+ this.menuAnim = Math.max(0, this.menuAnim - 0.15);
1157
+ }
1158
+
1159
+ if (this.menuAnim > 0.01 && this.activeNode) {
1160
+ const apos = this.getSmooth(this.activeNode.id);
1161
+ if (apos) {
1162
+ const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
1163
+ const menuLayout = getRadialMenuLayout({
1164
+ activeNode: this.activeNode,
1165
+ activePosition: apos,
1166
+ connectionCount: conns,
1167
+ menuItems: this.getActionItems(),
1168
+ menuAnim: this.menuAnim,
1169
+ });
1170
+ const easeOut = menuLayout.easeOut;
1171
+ const ir = menuLayout.itemRadius;
1172
+
1173
+ const s = this.layerAnim[0].scale;
1174
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
1175
+ 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);
1176
+ } else {
1177
+ mainCtx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1178
+ }
1179
+
1180
+ const tc = getNodeColor(this.activeNode, this._typeColorRgb);
1181
+ for (const entry of menuLayout.items) {
1182
+ const item = entry.item;
1183
+
1184
+ mainCtx.beginPath();
1185
+ mainCtx.arc(entry.x, entry.y, ir, 0, Math.PI * 2);
1186
+ mainCtx.fillStyle = item.danger
1187
+ ? toRgba(this._dangerRgb, 0.25 * easeOut)
1188
+ : toRgba(tc, 0.9 * easeOut);
1189
+ mainCtx.fill();
1190
+
1191
+ mainCtx.save();
1192
+ const iconScale = (ir * 1.2) / 24;
1193
+ if (iconScale > 0) {
1194
+ mainCtx.translate(entry.x - 12 * iconScale, entry.y - 12 * iconScale);
1195
+ mainCtx.scale(iconScale, iconScale);
1196
+ const p = new Path2D(item.path);
1197
+ mainCtx.fillStyle = item.danger
1198
+ ? toRgba(this._dangerRgb, easeOut)
1199
+ : toRgba(this._bgRgb, easeOut);
1200
+ mainCtx.fill(p);
1201
+ }
1202
+ mainCtx.restore();
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ // Info panel — typewriter HUD to the right of active node
1208
+ this._drawInfoPanel(mainCtx, dpr, dragDeltaX, dragDeltaY, vcx, vcy);
1209
+
1210
+ let idle = resolveIdleFrame({
1211
+ targetZoom: this._targetZoom,
1212
+ zoom: this.zoom,
1213
+ dragDeltaX,
1214
+ dragDeltaY,
1215
+ prevDragDeltaX: this._prevDragDeltaX || 0,
1216
+ prevDragDeltaY: this._prevDragDeltaY || 0,
1217
+ layerAnim: this.layerAnim,
1218
+ isIdle,
1219
+ layerTargets: this.LAYER_TARGETS,
1220
+ lastAlpha: this.lastAlpha,
1221
+ dragNode: this.dragNode,
1222
+ isPanning: this.isPanning,
1223
+ deactivating: this.deactivating,
1224
+ targetPanX: this._targetPanX,
1225
+ infoPanel: this._infoPanel,
1226
+ idleFrames: this._idleFrames,
1227
+ });
1228
+ this._prevDragDeltaX = idle.prevDragDeltaX;
1229
+ this._prevDragDeltaY = idle.prevDragDeltaY;
1230
+ this._idleFrames = idle.idleFrames;
1231
+
1232
+ // Allow 3 extra frames after convergence to flush final sub-pixel lerps
1233
+ if (idle.shouldStop) {
1234
+ this._loopRunning = false;
1235
+ return;
1236
+ }
1237
+
1238
+ this._animationFrame = requestAnimationFrame(() => this.draw());
1239
+ }
1240
+
1241
+ /**
1242
+ * Build metadata lines for the info panel from skeleton + node data
1243
+ * @param {object} node - graph node
1244
+ * @returns {string[]}
1245
+ */
1246
+ _buildInfoLines(node) {
1247
+ const lines = [];
1248
+ lines.push(node.label);
1249
+ if (node.id !== node.label) lines.push(node.id);
1250
+ lines.push('');
1251
+
1252
+ const typeLabels = {
1253
+ data: 'Data',
1254
+ action: 'Action',
1255
+ output: 'Output',
1256
+ config: 'Config',
1257
+ external: 'External',
1258
+ style: 'Style',
1259
+ docs: 'Docs',
1260
+ asset: 'Asset',
1261
+ group: 'Directory'
1262
+ };
1263
+ lines.push(`Type: ${typeLabels[node.type] || node.type}`);
1264
+
1265
+ const conns = this.adjMap.get(node.id)?.size || 0;
1266
+ if (conns > 0) lines.push(`Connections: ${conns}`);
1267
+
1268
+ if (node.children?.length > 0) {
1269
+ lines.push(`Children: ${node.children.length}`);
1270
+ }
1271
+
1272
+ if (Array.isArray(node.exports) && node.exports.length > 0) {
1273
+ lines.push('');
1274
+ lines.push('Exports:');
1275
+ for (const exp of node.exports.slice(0, 8)) {
1276
+ lines.push(` ${exp}`);
1277
+ }
1278
+ if (node.exports.length > 8) lines.push(` ... +${node.exports.length - 8}`);
1279
+ }
1280
+
1281
+ if (node.lines) lines.push(`Lines: ${node.lines}`);
1282
+
1283
+ return lines;
1284
+ }
1285
+
1286
+ /**
1287
+ * Draw info panel HUD to the right of the active node
1288
+ * @param {CanvasRenderingContext2D} ctx
1289
+ * @param {number} dpr
1290
+ * @param {number} dragDeltaX
1291
+ * @param {number} dragDeltaY
1292
+ * @param {number} vcx
1293
+ * @param {number} vcy
1294
+ */
1295
+ _drawInfoPanel(ctx, dpr, dragDeltaX, dragDeltaY, vcx, vcy) {
1296
+ const ip = this._infoPanel;
1297
+ const showPanel = this.activeNode && !this.dragNode && !this.deactivating;
1298
+
1299
+ if (showPanel && this.activeNode) {
1300
+ if (ip.nodeId !== this.activeNode.id) {
1301
+ ip.nodeId = this.activeNode.id;
1302
+ ip.lines = this._buildInfoLines(this.activeNode).map(text => ({ text, revealed: 0 }));
1303
+ ip.startTime = performance.now();
1304
+ ip.opacity = 0;
1305
+ }
1306
+ ip.opacity = Math.min(1, ip.opacity + 0.06);
1307
+ } else {
1308
+ ip.opacity = Math.max(0, ip.opacity - 0.12);
1309
+ if (ip.opacity <= 0) { ip.nodeId = null; ip.lines = []; ip.totalExtent = 0; ip.totalExtentY = 0; ip._centeredForNode = null; }
1310
+ }
1311
+
1312
+ if (ip.opacity <= 0.01 || ip.lines.length === 0) return;
1313
+
1314
+ const elapsed = performance.now() - ip.startTime;
1315
+ const CHAR_SPEED = 18;
1316
+ const LINE_DELAY = 60;
1317
+ let charBudget = Math.floor(elapsed / CHAR_SPEED);
1318
+ for (let i = 0; i < ip.lines.length; i++) {
1319
+ const line = ip.lines[i];
1320
+ const available = Math.max(0, charBudget - i * LINE_DELAY / CHAR_SPEED);
1321
+ line.revealed = Math.min(line.text.length, Math.floor(available));
1322
+ }
1323
+
1324
+ const apos = this.activeNode ? this.getSmooth(this.activeNode.id) : null;
1325
+ if (!apos) return;
1326
+
1327
+ // Apply depth-0 transform — panel lives in world-space, scales with nodes
1328
+ const s = this.layerAnim[0].scale;
1329
+ if (this.focusActive && Math.abs(s - 1) > 0.001) {
1330
+ ctx.setTransform(s * dpr * this.zoom, 0, 0, s * dpr * this.zoom,
1331
+ this.focusX * (1 - s) + s * dpr * this.panX,
1332
+ this.focusY * (1 - s) + s * dpr * this.panY);
1333
+ } else {
1334
+ ctx.setTransform(dpr * this.zoom, 0, 0, dpr * this.zoom, dpr * this.panX, dpr * this.panY);
1335
+ }
1336
+
1337
+ // All dimensions in world units
1338
+ const fontSize = 11;
1339
+ const smallFontSize = 9;
1340
+ const lineHeight = 15;
1341
+ const padX = 14;
1342
+ const padY = 10;
1343
+
1344
+ // Compute actual node radius to avoid overlap
1345
+ // Must account for: dot radius + glow + radial menu items
1346
+ const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
1347
+ const dotR = getNodeRadius(this.activeNode, conns, { scale: this.activeNode.aScale || 1.5 });
1348
+ // Menu orbits at dotR + 14, each item has radius 6
1349
+ const menuExtent = dotR + 14 + 6;
1350
+ const panelGap = 10;
1351
+ const panelX = apos.x + menuExtent + panelGap;
1352
+ const panelY = apos.y - padY;
1353
+
1354
+ ctx.font = `600 ${fontSize}px 'Inter', 'SF Mono', system-ui, sans-serif`;
1355
+
1356
+ // Measure panel width from FULL text content (not just revealed)
1357
+ // This ensures totalExtent is stable from the first frame — no oscillation
1358
+ let maxW = 60;
1359
+ for (const line of ip.lines) {
1360
+ const w = ctx.measureText(line.text).width;
1361
+ if (w > maxW) maxW = w;
1362
+ }
1363
+ const panelW = maxW + padX * 2;
1364
+ const panelH = ip.lines.length * lineHeight + padY * 2;
1365
+
1366
+ // Store total extent for focus centering
1367
+ ip.totalExtent = menuExtent + panelGap + panelW;
1368
+ // Vertical: panel extends from (apos.y - padY) to (apos.y - padY + panelH + 16)
1369
+ // The offset from node center to the vertical midpoint of the panel
1370
+ ip.totalExtentY = (panelH + 16) / 2 - padY;
1371
+
1372
+ const tc = getNodeColor(this.activeNode || {}, this._typeColorRgb);
1373
+ const cornerR = 6;
1374
+
1375
+ ctx.save();
1376
+ ctx.globalAlpha = ip.opacity;
1377
+
1378
+ // Blurred backdrop
1379
+ ctx.filter = 'blur(16px)';
1380
+ ctx.beginPath();
1381
+ ctx.roundRect(panelX, panelY, panelW, panelH + 16, cornerR);
1382
+ ctx.fillStyle = toRgba(this._bgRgb, 0.85 * ip.opacity);
1383
+ ctx.fill();
1384
+ ctx.filter = 'none';
1385
+
1386
+ // Border
1387
+ ctx.beginPath();
1388
+ ctx.roundRect(panelX, panelY, panelW, panelH + 16, cornerR);
1389
+ ctx.strokeStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.15 * ip.opacity})`;
1390
+ ctx.lineWidth = 0.8;
1391
+ ctx.stroke();
1392
+
1393
+ // Left accent
1394
+ ctx.beginPath();
1395
+ ctx.moveTo(panelX, panelY + cornerR);
1396
+ ctx.lineTo(panelX, panelY + panelH + 16 - cornerR);
1397
+ ctx.strokeStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.5 * ip.opacity})`;
1398
+ ctx.lineWidth = 1.5;
1399
+ ctx.stroke();
1400
+
1401
+ // Text lines
1402
+ let textY = panelY + padY + fontSize;
1403
+ for (let i = 0; i < ip.lines.length; i++) {
1404
+ const line = ip.lines[i];
1405
+ const text = line.text.substring(0, line.revealed);
1406
+ if (!text) { textY += lineHeight; continue; }
1407
+
1408
+ if (i === 0) {
1409
+ ctx.font = `700 ${fontSize}px 'Inter', 'SF Mono', system-ui, sans-serif`;
1410
+ ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${ip.opacity})`;
1411
+ } else if (i === 1 && this.activeNode?.id !== this.activeNode?.label) {
1412
+ ctx.font = `400 ${smallFontSize}px 'SF Mono', 'JetBrains Mono', monospace`;
1413
+ ctx.fillStyle = toRgba(this._textDimRgb, 0.35 * ip.opacity);
1414
+ } else if (line.text.startsWith(' ')) {
1415
+ ctx.font = `400 ${smallFontSize}px 'SF Mono', 'JetBrains Mono', monospace`;
1416
+ ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.6 * ip.opacity})`;
1417
+ } else if (line.text.includes(':')) {
1418
+ ctx.font = `500 ${smallFontSize}px 'Inter', system-ui, sans-serif`;
1419
+ ctx.fillStyle = toRgba(this._textRgb, 0.5 * ip.opacity);
1420
+ } else {
1421
+ ctx.font = `500 ${smallFontSize}px 'Inter', system-ui, sans-serif`;
1422
+ ctx.fillStyle = toRgba(this._textRgb, 0.6 * ip.opacity);
1423
+ }
1424
+
1425
+ ctx.fillText(text, panelX + padX, textY);
1426
+
1427
+ if (line.revealed < line.text.length && line.revealed > 0) {
1428
+ const cursorX = panelX + padX + ctx.measureText(text).width + 2;
1429
+ if (Math.floor(performance.now() / 400) % 2 === 0) {
1430
+ ctx.fillStyle = `rgba(${tc[0]}, ${tc[1]}, ${tc[2]}, ${0.8 * ip.opacity})`;
1431
+ ctx.fillRect(cursorX, textY - fontSize + 2, 1.5, fontSize);
1432
+ }
1433
+ }
1434
+ textY += lineHeight;
1435
+ }
1436
+
1437
+ ctx.restore();
1438
+ }
1439
+
1440
+ getVisualLayerTransform(depth = 0) {
1441
+ let dpr = window.devicePixelRatio || 1;
1442
+ return getLayerTransform({
1443
+ depth,
1444
+ layerAnim: this.layerAnim,
1445
+ dpr,
1446
+ zoom: this.zoom,
1447
+ panX: this.panX,
1448
+ panY: this.panY,
1449
+ vcx: this.canvas.width / 2,
1450
+ vcy: this.canvas.height / 2,
1451
+ focusActive: this.focusActive,
1452
+ focusX: this.focusX,
1453
+ focusY: this.focusY,
1454
+ dragDeltaX: this._visualDragDeltaX || 0,
1455
+ dragDeltaY: this._visualDragDeltaY || 0,
1456
+ });
1457
+ }
1458
+
1459
+ screenToWorld(sx, sy, depth = 0, transform = null) {
1460
+ const rect = this.canvas.getBoundingClientRect();
1461
+ const dpr = window.devicePixelRatio || 1;
1462
+ transform ||= this.getVisualLayerTransform(depth);
1463
+ return {
1464
+ x: ((sx - rect.left) * dpr - transform.E) / transform.A,
1465
+ y: ((sy - rect.top) * dpr - transform.F) / transform.A,
1466
+ };
1467
+ }
1468
+
1469
+ hitTest(wx, wy) {
1470
+ const inGroup = !!this.currentGroupId;
1471
+ const activeGroupId = this.currentGroupId;
1472
+ for (let i = this.nodes.length - 1; i >= 0; i--) {
1473
+ const node = this.nodes[i];
1474
+ if (inGroup && node.parentId !== activeGroupId && node.id !== activeGroupId) continue;
1475
+ const pos = this.getSmooth(node.id);
1476
+ if (!pos) continue;
1477
+
1478
+ if (this.renderMode === 'dots') {
1479
+ const dx = wx - pos.x, dy = wy - pos.y;
1480
+ const hitR = node.isGroup ? HIT_RADIUS * 1.5 : HIT_RADIUS;
1481
+ if (dx * dx + dy * dy <= hitR * hitR) return node;
1482
+ }
1483
+ }
1484
+ return null;
1485
+ }
1486
+
1487
+ hitTestScreen(sx, sy) {
1488
+ const inGroup = !!this.currentGroupId;
1489
+ const activeGroupId = this.currentGroupId;
1490
+ const rect = this.canvas.getBoundingClientRect();
1491
+ const dpr = window.devicePixelRatio || 1;
1492
+
1493
+ for (let i = this.nodes.length - 1; i >= 0; i--) {
1494
+ const node = this.nodes[i];
1495
+ if (inGroup && node.parentId !== activeGroupId && node.id !== activeGroupId) continue;
1496
+ const pos = this.getSmooth(node.id);
1497
+ if (!pos) continue;
1498
+
1499
+ if (this.renderMode === 'dots') {
1500
+ const depth = node.targetDepth ?? 0;
1501
+ const hit = getCanvasNodeScreenHit({
1502
+ clientX: sx,
1503
+ clientY: sy,
1504
+ canvasRect: rect,
1505
+ node,
1506
+ position: pos,
1507
+ transform: this.getVisualLayerTransform(depth),
1508
+ dpr,
1509
+ hitRadius: getNodeHitRadius(node, HIT_RADIUS),
1510
+ });
1511
+ if (hit?.hit) return node;
1512
+ }
1513
+ }
1514
+ return null;
1515
+ }
1516
+
1517
+ bindEvents() {
1518
+ this.canvas.addEventListener('pointerdown', (e) => {
1519
+ this._wakeLoop(); // User interaction — resume rendering
1520
+ const world = this.screenToWorld(e.clientX, e.clientY, 0);
1521
+
1522
+ if (this.activeNode && !this.dragNode && this.menuAnim > 0.5) {
1523
+ const apos = this.getSmooth(this.activeNode.id);
1524
+ if (apos) {
1525
+ const conns = this.adjMap.get(this.activeNode.id)?.size || 0;
1526
+ const menuItems = this.getActionItems();
1527
+ const hitItem = getRadialMenuHit({
1528
+ world,
1529
+ activeNode: this.activeNode,
1530
+ activePosition: apos,
1531
+ connectionCount: conns,
1532
+ menuItems,
1533
+ });
1534
+ if (hitItem) {
1535
+ const action = hitItem.action;
1536
+ if (action === 'drill') {
1537
+ if (this.activeNode.isGroup && !this.activeNode.isSemanticCluster) {
1538
+ this.loadLevel(this.activeNode.id);
1539
+ }
1540
+ } else {
1541
+ this._emitGraphEvent('toolbarAction', { action, nodeId: this.activeNode.id }, {
1542
+ bubbles: true,
1543
+ composed: true,
1544
+ });
1545
+ }
1546
+ e.preventDefault();
1547
+ return;
1548
+ }
1549
+ }
1550
+ }
1551
+
1552
+ const hit = this.hitTestScreen(e.clientX, e.clientY);
1553
+ if (hit) {
1554
+ const vis = this.getSmooth(hit.id);
1555
+ const sim = this.nodePositions.get(hit.id);
1556
+ if (vis && sim) { sim.x = vis.x; sim.y = vis.y; }
1557
+
1558
+ let isNewActivation = !this.activeNode || this.activeNode.id !== hit.id;
1559
+ this.activeNode = hit;
1560
+ this.nextActiveNode = null;
1561
+ this.deactivating = false;
1562
+ this.dragNode = hit;
1563
+ if (isNewActivation) this.menuAnim = 0;
1564
+ this.updateInteractionDepths();
1565
+ this._nodeActivatedOnDown = isNewActivation;
1566
+ const pos = this.nodePositions.get(hit.id);
1567
+ const hitDepth = hit.targetDepth ?? 0;
1568
+ this._dragWorldTransform = this.getVisualLayerTransform(hitDepth);
1569
+ const dragWorld = this.screenToWorld(e.clientX, e.clientY, 0, this._dragWorldTransform);
1570
+ this.dragOffset.x = dragWorld.x - pos.x;
1571
+ this.dragOffset.y = dragWorld.y - pos.y;
1572
+ this._dragStartX = e.clientX;
1573
+ this._dragStartY = e.clientY;
1574
+ this.canvas.style.cursor = 'grabbing';
1575
+ this.canvas.setPointerCapture(e.pointerId);
1576
+ this.worker?.pin(hit.id, pos.x, pos.y);
1577
+ e.preventDefault();
1578
+ } else {
1579
+ // Start panning — cancel any fitView/flyToNode animation
1580
+ this._targetPanX = null;
1581
+ this._targetPanY = null;
1582
+ this.isPanning = true;
1583
+ this._dragStartX = e.clientX;
1584
+ this._dragStartY = e.clientY;
1585
+ this.panStart = { x: this.panX, y: this.panY, px: e.clientX, py: e.clientY };
1586
+ this.canvas.style.cursor = 'grabbing';
1587
+ this.canvas.setPointerCapture(e.pointerId);
1588
+ }
1589
+ });
1590
+
1591
+ this.canvas.addEventListener('pointermove', (e) => {
1592
+ if (this.dragNode) {
1593
+ this._wakeLoop(); // Dragging node — resume rendering
1594
+ const world = this.screenToWorld(e.clientX, e.clientY, 0, this._dragWorldTransform);
1595
+ const newX = world.x - this.dragOffset.x;
1596
+ const newY = world.y - this.dragOffset.y;
1597
+ this.nodePositions.set(this.dragNode.id, { x: newX, y: newY });
1598
+ this.worker?.pin(this.dragNode.id, newX, newY);
1599
+ this.hoverNode = null;
1600
+ } else if (this.isPanning) {
1601
+ this._wakeLoop(); // Panning — resume rendering
1602
+ this.panX = this.panStart.x + (e.clientX - this.panStart.px);
1603
+ this.panY = this.panStart.y + (e.clientY - this.panStart.py);
1604
+ this.hoverNode = null;
1605
+ } else {
1606
+ this.hoverNode = this.hitTestScreen(e.clientX, e.clientY);
1607
+ }
1608
+ });
1609
+
1610
+ this.canvas.addEventListener('pointerup', (e) => {
1611
+ const draggedNode = this.dragNode;
1612
+ if (this.dragNode) {
1613
+ this.worker?.unpin(this.dragNode.id);
1614
+ this.dragNode = null;
1615
+ }
1616
+ this._dragWorldTransform = null;
1617
+ this.isPanning = false;
1618
+ this.canvas.style.cursor = 'default';
1619
+
1620
+ // Detect click vs drag: if pointer moved less than 5px, it's a click
1621
+ const dx = e.clientX - (this._dragStartX || 0);
1622
+ const dy = e.clientY - (this._dragStartY || 0);
1623
+ const wasClick = (dx * dx + dy * dy) < 25;
1624
+
1625
+ if (wasClick) {
1626
+ const node = draggedNode || this.hitTestScreen(e.clientX, e.clientY);
1627
+ if (node) {
1628
+ if (node.isGroup) {
1629
+ const now = Date.now();
1630
+ if (now - this.lastClickTime < 300 && this.lastClickNode === node.id) {
1631
+ // Double click on group
1632
+ if (node.isSemanticCluster) {
1633
+ this.focusSemanticCluster(node.id);
1634
+ } else {
1635
+ this.loadLevel(node.id);
1636
+ }
1637
+ } else {
1638
+ // Single click on group
1639
+ this._emitGraphEvent('groupSelected', { path: node.id });
1640
+ }
1641
+ this.lastClickTime = now;
1642
+ this.lastClickNode = node.id;
1643
+ } else {
1644
+ // File node click
1645
+ this._emitGraphEvent('fileSelected', { path: node.id });
1646
+ }
1647
+ } else {
1648
+ // Click on empty space → deselect active node
1649
+ if (this.activeNode && !this.deactivating) {
1650
+ this.deactivating = true;
1651
+ this.dragNode = null;
1652
+ this._emitGraphEvent('nodeDeselected');
1653
+ }
1654
+ }
1655
+ } else if (draggedNode && this._nodeActivatedOnDown) {
1656
+ // We dragged a node that was just activated on pointerdown.
1657
+ // Emit selection event so URL and UI synchronize.
1658
+ if (draggedNode.isGroup) {
1659
+ this._emitGraphEvent('groupSelected', { path: draggedNode.id });
1660
+ } else {
1661
+ this._emitGraphEvent('fileSelected', { path: draggedNode.id });
1662
+ }
1663
+ }
1664
+ if (draggedNode) this._emitLayoutSnapshot();
1665
+ this._nodeActivatedOnDown = false;
1666
+ this._dragStartX = 0;
1667
+ this._dragStartY = 0;
1668
+ });
1669
+
1670
+ this.canvas.addEventListener('wheel', (e) => {
1671
+ e.preventDefault();
1672
+ const rect = this.canvas.getBoundingClientRect();
1673
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
1674
+ const factor = e.deltaY > 0 ? 0.92 : 1.08;
1675
+ this._targetZoom = Math.max(0.02, Math.min(5, this._targetZoom * factor));
1676
+ this._zoomAnchor = { mx, my };
1677
+ this._wakeLoop(); // Zoom changed — resume rendering
1678
+ }, { passive: false });
1679
+
1680
+ this.canvas.addEventListener('dblclick', (e) => {
1681
+ // Check if we didn't hit a node
1682
+ if (!this.hitTestScreen(e.clientX, e.clientY)) {
1683
+ if (!this.nodePositions.size) return;
1684
+ let sx = 0, sy = 0, count = 0;
1685
+ for (const pos of this.nodePositions.values()) { sx += pos.x; sy += pos.y; count++; }
1686
+ const cx = sx / count, cy = sy / count;
1687
+ const rect = this.canvas.getBoundingClientRect();
1688
+ this.panX = rect.width / 2 - cx * this.zoom;
1689
+ this.panY = rect.height / 2 - cy * this.zoom;
1690
+ this._wakeLoop(); // Double-click recenter — resume rendering
1691
+ }
1692
+ });
1693
+ }
1694
+ }
1695
+
1696
+ CanvasGraph.rootStyles = css;
1697
+ CanvasGraph.reg('canvas-graph');