@tldiagram/core-ui 1.87.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.
Files changed (272) hide show
  1. package/dist/App.d.ts +1 -0
  2. package/dist/api/client.d.ts +143 -0
  3. package/dist/api/transport-vscode.d.ts +8 -0
  4. package/dist/api/transport.d.ts +1 -0
  5. package/dist/components/CodePreviewPanel-vscode.d.ts +7 -0
  6. package/dist/components/CodePreviewPanel.d.ts +9 -0
  7. package/dist/components/ConfirmDialog.d.ts +12 -0
  8. package/dist/components/ConnectorPanel.d.ts +21 -0
  9. package/dist/components/ContextBoundaryElement.d.ts +11 -0
  10. package/dist/components/ContextNeighborElement.d.ts +29 -0
  11. package/dist/components/ContextStraightConnector.d.ts +4 -0
  12. package/dist/components/CrossBranchControls.d.ts +9 -0
  13. package/dist/components/DependenciesOnboarding.d.ts +5 -0
  14. package/dist/components/DrawingCanvas.d.ts +39 -0
  15. package/dist/components/ElementLibrary-vscode.d.ts +7 -0
  16. package/dist/components/ElementLibrary.d.ts +22 -0
  17. package/dist/components/ElementNode.d.ts +36 -0
  18. package/dist/components/ElementPanel.d.ts +25 -0
  19. package/dist/components/ExploreOnboarding.d.ts +5 -0
  20. package/dist/components/ExplorePageOnboarding.d.ts +5 -0
  21. package/dist/components/ExportModal.d.ts +16 -0
  22. package/dist/components/FloatingEdge.d.ts +9 -0
  23. package/dist/components/GitSourceLinker.d.ts +8 -0
  24. package/dist/components/HeaderContext.d.ts +16 -0
  25. package/dist/components/Icons.d.ts +95 -0
  26. package/dist/components/ImportModal.d.ts +10 -0
  27. package/dist/components/InlineElementAdder.d.ts +17 -0
  28. package/dist/components/LayoutSection.d.ts +7 -0
  29. package/dist/components/LocalSourceLinker.d.ts +8 -0
  30. package/dist/components/MiniZoomOnboarding.d.ts +5 -0
  31. package/dist/components/NavBreadcrumb.d.ts +6 -0
  32. package/dist/components/NodeBody.d.ts +12 -0
  33. package/dist/components/NodeContainer.d.ts +8 -0
  34. package/dist/components/NodeHoverCard.d.ts +10 -0
  35. package/dist/components/PanelHeader.d.ts +8 -0
  36. package/dist/components/PanelUI.d.ts +3 -0
  37. package/dist/components/ProxyConnectorEdge.d.ts +4 -0
  38. package/dist/components/ProxyConnectorPanel.d.ts +9 -0
  39. package/dist/components/SafeBackground.d.ts +13 -0
  40. package/dist/components/ScrollIndicatorWrapper.d.ts +8 -0
  41. package/dist/components/SetChildModal.d.ts +10 -0
  42. package/dist/components/SetParentModal.d.ts +10 -0
  43. package/dist/components/SlidingPanel.d.ts +16 -0
  44. package/dist/components/TagUpsert.d.ts +8 -0
  45. package/dist/components/TopMenuBar.d.ts +8 -0
  46. package/dist/components/ViewBezierConnector.d.ts +4 -0
  47. package/dist/components/ViewDrawMenu.d.ts +22 -0
  48. package/dist/components/ViewEditorEdgeLabelLayout.d.ts +16 -0
  49. package/dist/components/ViewEditorOnboarding.d.ts +5 -0
  50. package/dist/components/ViewExplorer/TagManager/ColorPicker.d.ts +7 -0
  51. package/dist/components/ViewExplorer/TagManager/GroupNamingPopover.d.ts +10 -0
  52. package/dist/components/ViewExplorer/TagManager/LayerItem.d.ts +27 -0
  53. package/dist/components/ViewExplorer/TagManager/TagItem.d.ts +25 -0
  54. package/dist/components/ViewExplorer/TagManager/index.d.ts +21 -0
  55. package/dist/components/ViewExplorer/ViewNavigator.d.ts +11 -0
  56. package/dist/components/ViewExplorer/ViewSearch.d.ts +8 -0
  57. package/dist/components/ViewExplorer/ViewTree.d.ts +18 -0
  58. package/dist/components/ViewExplorer/index.d.ts +31 -0
  59. package/dist/components/ViewExplorer/types.d.ts +11 -0
  60. package/dist/components/ViewExplorer/utils.d.ts +6 -0
  61. package/dist/components/ViewExplorer-vscode.d.ts +6 -0
  62. package/dist/components/ViewFloatingMenu-vscode.d.ts +27 -0
  63. package/dist/components/ViewFloatingMenu.d.ts +39 -0
  64. package/dist/components/ViewGridNode.d.ts +29 -0
  65. package/dist/components/ViewHeaderButton.d.ts +11 -0
  66. package/dist/components/ViewPanel.d.ts +18 -0
  67. package/dist/components/ViewsGridOnboarding.d.ts +5 -0
  68. package/dist/components/ZUI/ZUICanvas.d.ts +18 -0
  69. package/dist/components/ZUI/index.d.ts +2 -0
  70. package/dist/components/ZUI/layout.d.ts +18 -0
  71. package/dist/components/ZUI/proxy.d.ts +25 -0
  72. package/dist/components/ZUI/renderer.d.ts +30 -0
  73. package/dist/components/ZUI/types.d.ts +140 -0
  74. package/dist/components/ZUI/useZUIInteraction.d.ts +21 -0
  75. package/dist/config/runtime-vscode.d.ts +22 -0
  76. package/dist/config/runtime.d.ts +5 -0
  77. package/dist/constants/colors.d.ts +27 -0
  78. package/dist/constants/diagramColors.d.ts +1 -0
  79. package/dist/context/ThemeContext.d.ts +27 -0
  80. package/dist/crossBranch/graph.d.ts +13 -0
  81. package/dist/crossBranch/resolve.d.ts +22 -0
  82. package/dist/crossBranch/settings.d.ts +6 -0
  83. package/dist/crossBranch/store.d.ts +11 -0
  84. package/dist/crossBranch/types.d.ts +96 -0
  85. package/dist/demo/DemoPage.d.ts +9 -0
  86. package/dist/demo/seed.d.ts +9 -0
  87. package/dist/demo/store.d.ts +137 -0
  88. package/dist/demo/viewEditor.d.ts +26 -0
  89. package/dist/favicon.svg +35 -0
  90. package/dist/hooks/useSafeFitView.d.ts +16 -0
  91. package/dist/index.css +1 -0
  92. package/dist/index.d.ts +115 -0
  93. package/dist/index.js +19966 -0
  94. package/dist/lib/vscodeBridge-vscode.d.ts +13 -0
  95. package/dist/lib/vscodeBridge.d.ts +5 -0
  96. package/dist/logo-120.png +0 -0
  97. package/dist/logo-bw.png +0 -0
  98. package/dist/logo-bw.svg +15 -0
  99. package/dist/logo-text.svg +51 -0
  100. package/dist/logo.svg +35 -0
  101. package/dist/pages/AppearanceSettings.d.ts +3 -0
  102. package/dist/pages/Dependencies.d.ts +1 -0
  103. package/dist/pages/InfiniteZoom.d.ts +7 -0
  104. package/dist/pages/Settings.d.ts +7 -0
  105. package/dist/pages/ViewEditor/components/EditorMenus.d.ts +24 -0
  106. package/dist/pages/ViewEditor/components/EditorOverlays.d.ts +30 -0
  107. package/dist/pages/ViewEditor/components/EmptyCanvasState.d.ts +7 -0
  108. package/dist/pages/ViewEditor/context.d.ts +13 -0
  109. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +201 -0
  110. package/dist/pages/ViewEditor/hooks/useDrawingEngine.d.ts +40 -0
  111. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +20 -0
  112. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +74 -0
  113. package/dist/pages/ViewEditor/index.d.ts +8 -0
  114. package/dist/pages/ViewEditor/utils.d.ts +14 -0
  115. package/dist/pages/Views.d.ts +6 -0
  116. package/dist/pages/ViewsGrid.d.ts +6 -0
  117. package/dist/pkg/importer/mermaid.d.ts +7 -0
  118. package/dist/pkg/importer/mermaid.test.d.ts +1 -0
  119. package/dist/platform/PlatformContext.d.ts +6 -0
  120. package/dist/platform/context.d.ts +3 -0
  121. package/dist/platform/local.d.ts +2 -0
  122. package/dist/platform/types.d.ts +17 -0
  123. package/dist/slots.d.ts +67 -0
  124. package/dist/theme.d.ts +2 -0
  125. package/dist/types/index.d.ts +193 -0
  126. package/dist/types/vscode-messages.d.ts +60 -0
  127. package/dist/utils/edgeDistribution.d.ts +34 -0
  128. package/dist/utils/githubApi.d.ts +4 -0
  129. package/dist/utils/githubCache.d.ts +17 -0
  130. package/dist/utils/ids.d.ts +2 -0
  131. package/dist/utils/technologyCatalog.d.ts +15 -0
  132. package/dist/utils/toast.d.ts +15 -0
  133. package/dist/utils/treesitter.d.ts +13 -0
  134. package/dist/utils/url.d.ts +12 -0
  135. package/package.json +159 -0
  136. package/src/App.tsx +141 -0
  137. package/src/api/client.ts +618 -0
  138. package/src/api/transport-vscode.ts +28 -0
  139. package/src/api/transport.ts +7 -0
  140. package/src/assets/logo-mark.svg +31 -0
  141. package/src/assets/logo-wordmark.svg +22 -0
  142. package/src/assets/logo.svg +35 -0
  143. package/src/components/CodePreviewPanel-vscode.tsx +85 -0
  144. package/src/components/CodePreviewPanel.tsx +384 -0
  145. package/src/components/ConfirmDialog.tsx +66 -0
  146. package/src/components/ConnectorPanel.tsx +403 -0
  147. package/src/components/ContextBoundaryElement.tsx +35 -0
  148. package/src/components/ContextNeighborElement.tsx +282 -0
  149. package/src/components/ContextStraightConnector.tsx +144 -0
  150. package/src/components/CrossBranchControls.tsx +105 -0
  151. package/src/components/DependenciesOnboarding.tsx +427 -0
  152. package/src/components/DrawingCanvas.tsx +391 -0
  153. package/src/components/ElementLibrary-vscode.tsx +9 -0
  154. package/src/components/ElementLibrary.tsx +512 -0
  155. package/src/components/ElementNode.tsx +1033 -0
  156. package/src/components/ElementPanel.tsx +928 -0
  157. package/src/components/ExploreOnboarding.tsx +347 -0
  158. package/src/components/ExplorePageOnboarding.tsx +383 -0
  159. package/src/components/ExportModal.tsx +132 -0
  160. package/src/components/FloatingEdge.tsx +115 -0
  161. package/src/components/GitSourceLinker.tsx +1053 -0
  162. package/src/components/HeaderContext.tsx +30 -0
  163. package/src/components/Icons.tsx +245 -0
  164. package/src/components/ImportModal.tsx +219 -0
  165. package/src/components/InlineElementAdder.tsx +216 -0
  166. package/src/components/LayoutSection.tsx +624 -0
  167. package/src/components/LocalSourceLinker.tsx +330 -0
  168. package/src/components/MiniZoomOnboarding.tsx +78 -0
  169. package/src/components/NavBreadcrumb.tsx +24 -0
  170. package/src/components/NodeBody.tsx +89 -0
  171. package/src/components/NodeContainer.tsx +58 -0
  172. package/src/components/NodeHoverCard.tsx +135 -0
  173. package/src/components/PanelHeader.tsx +36 -0
  174. package/src/components/PanelUI.tsx +24 -0
  175. package/src/components/ProxyConnectorEdge.tsx +169 -0
  176. package/src/components/ProxyConnectorPanel.tsx +130 -0
  177. package/src/components/SafeBackground.tsx +19 -0
  178. package/src/components/ScrollIndicatorWrapper.tsx +117 -0
  179. package/src/components/SetChildModal.tsx +191 -0
  180. package/src/components/SetParentModal.tsx +187 -0
  181. package/src/components/SlidingPanel.tsx +114 -0
  182. package/src/components/TagUpsert.tsx +142 -0
  183. package/src/components/TopMenuBar.tsx +380 -0
  184. package/src/components/ViewBezierConnector.tsx +143 -0
  185. package/src/components/ViewDrawMenu.tsx +270 -0
  186. package/src/components/ViewEditorEdgeLabelLayout.ts +189 -0
  187. package/src/components/ViewEditorOnboarding.tsx +445 -0
  188. package/src/components/ViewExplorer/TagManager/ColorPicker.tsx +49 -0
  189. package/src/components/ViewExplorer/TagManager/GroupNamingPopover.tsx +96 -0
  190. package/src/components/ViewExplorer/TagManager/LayerItem.tsx +228 -0
  191. package/src/components/ViewExplorer/TagManager/TagItem.tsx +242 -0
  192. package/src/components/ViewExplorer/TagManager/index.tsx +418 -0
  193. package/src/components/ViewExplorer/ViewNavigator.tsx +121 -0
  194. package/src/components/ViewExplorer/ViewSearch.tsx +33 -0
  195. package/src/components/ViewExplorer/ViewTree.tsx +98 -0
  196. package/src/components/ViewExplorer/index.tsx +384 -0
  197. package/src/components/ViewExplorer/types.ts +13 -0
  198. package/src/components/ViewExplorer/utils.ts +56 -0
  199. package/src/components/ViewExplorer-vscode.tsx +8 -0
  200. package/src/components/ViewFloatingMenu-vscode.tsx +248 -0
  201. package/src/components/ViewFloatingMenu.tsx +379 -0
  202. package/src/components/ViewGridNode.tsx +451 -0
  203. package/src/components/ViewHeaderButton.tsx +60 -0
  204. package/src/components/ViewPanel.tsx +162 -0
  205. package/src/components/ViewsGridOnboarding.tsx +400 -0
  206. package/src/components/ZUI/ZUICanvas.tsx +853 -0
  207. package/src/components/ZUI/index.ts +3 -0
  208. package/src/components/ZUI/layout.ts +323 -0
  209. package/src/components/ZUI/proxy.ts +278 -0
  210. package/src/components/ZUI/renderer.ts +1189 -0
  211. package/src/components/ZUI/types.ts +150 -0
  212. package/src/components/ZUI/useZUIInteraction.ts +720 -0
  213. package/src/config/runtime-vscode.ts +46 -0
  214. package/src/config/runtime.ts +30 -0
  215. package/src/constants/colors.ts +80 -0
  216. package/src/constants/diagramColors.ts +9 -0
  217. package/src/context/ThemeContext.tsx +158 -0
  218. package/src/crossBranch/graph.ts +207 -0
  219. package/src/crossBranch/resolve.ts +643 -0
  220. package/src/crossBranch/settings.ts +59 -0
  221. package/src/crossBranch/store.ts +71 -0
  222. package/src/crossBranch/types.ts +102 -0
  223. package/src/demo/DemoPage.tsx +184 -0
  224. package/src/demo/seed.ts +67 -0
  225. package/src/demo/store.ts +536 -0
  226. package/src/demo/viewEditor.ts +110 -0
  227. package/src/hooks/useSafeFitView.ts +60 -0
  228. package/src/index.css +309 -0
  229. package/src/index.ts +184 -0
  230. package/src/kafka-ss.png +0 -0
  231. package/src/lib/vscodeBridge-vscode.ts +27 -0
  232. package/src/lib/vscodeBridge.ts +7 -0
  233. package/src/main.tsx +46 -0
  234. package/src/pages/AppearanceSettings.tsx +135 -0
  235. package/src/pages/Dependencies.tsx +926 -0
  236. package/src/pages/InfiniteZoom.tsx +404 -0
  237. package/src/pages/Settings.tsx +91 -0
  238. package/src/pages/ViewEditor/EDGE_DISTRIBUTION.md +64 -0
  239. package/src/pages/ViewEditor/components/EditorMenus.tsx +112 -0
  240. package/src/pages/ViewEditor/components/EditorOverlays.tsx +172 -0
  241. package/src/pages/ViewEditor/components/EmptyCanvasState.tsx +42 -0
  242. package/src/pages/ViewEditor/context.tsx +21 -0
  243. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +1349 -0
  244. package/src/pages/ViewEditor/hooks/useDrawingEngine.ts +127 -0
  245. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +501 -0
  246. package/src/pages/ViewEditor/hooks/useViewData.ts +491 -0
  247. package/src/pages/ViewEditor/index.tsx +1366 -0
  248. package/src/pages/ViewEditor/utils.ts +88 -0
  249. package/src/pages/Views.tsx +171 -0
  250. package/src/pages/ViewsGrid.tsx +1310 -0
  251. package/src/pkg/importer/mermaid.test.ts +141 -0
  252. package/src/pkg/importer/mermaid.ts +76 -0
  253. package/src/platform/PlatformContext.tsx +17 -0
  254. package/src/platform/context.ts +9 -0
  255. package/src/platform/local.tsx +15 -0
  256. package/src/platform/types.ts +19 -0
  257. package/src/slots.ts +92 -0
  258. package/src/styles/editor-panels.css +66 -0
  259. package/src/styles/theme.css +56 -0
  260. package/src/theme.ts +336 -0
  261. package/src/types/index.ts +234 -0
  262. package/src/types/offline-ambient.d.ts +14 -0
  263. package/src/types/vscode-messages.ts +32 -0
  264. package/src/utils/edgeDistribution.ts +103 -0
  265. package/src/utils/githubApi.ts +121 -0
  266. package/src/utils/githubCache.ts +108 -0
  267. package/src/utils/ids.ts +9 -0
  268. package/src/utils/technologyCatalog.ts +143 -0
  269. package/src/utils/toast.ts +100 -0
  270. package/src/utils/treesitter.ts +147 -0
  271. package/src/utils/url.ts +72 -0
  272. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,1189 @@
1
+ // src/components/ZUI/renderer.ts
2
+
3
+ import type { DiagramGroupLayout, LayoutNode, ZUIViewState } from './types'
4
+ import {
5
+ DEFAULT_SOURCE_HANDLE_SIDE,
6
+ DEFAULT_TARGET_HANDLE_SIDE,
7
+ getHandleFlowPosition,
8
+ getLogicalHandleId,
9
+ getVisualHandleIdForGroup,
10
+ } from '../../utils/edgeDistribution'
11
+
12
+ // ── Thresholds (screen pixels) ─────────────────────────────────────
13
+ // Responsive thresholds: smaller screens expand earlier.
14
+ export function getExpandThresholds(canvasW: number) {
15
+ return {
16
+ start: clamp(canvasW * 0.25, 80, 450),
17
+ end: clamp(canvasW * 0.4, 200, 640),
18
+ }
19
+ }
20
+
21
+ const MIN_LABEL_PX = 12 // below this screen width, skip label text
22
+ const MIN_DRAW_PX = 2 // below this screen width, skip node entirely
23
+ const BADGE_THRESHOLD = 100 // node width in screen pixels below which we hide type badge and zoom icon
24
+
25
+ // ── Screen-space font limits (px) ──────────────────────────────────
26
+ const MIN_FONT_NAME = 10
27
+ const MAX_FONT_NAME = 50
28
+ const MIN_FONT_BADGE = 12
29
+ const MAX_FONT_BADGE = 30
30
+ const MIN_FONT_HINT = 12
31
+ const MAX_FONT_HINT = 24
32
+
33
+ export interface ScreenRect {
34
+ left: number
35
+ top: number
36
+ right: number
37
+ bottom: number
38
+ }
39
+
40
+
41
+ /**
42
+ * Returns a world-space font size that, when multiplied by zoom,
43
+ * stays within [minScreenSize, maxScreenSize] screen pixels,
44
+ * while preferring baseWorldSize if possible.
45
+ */
46
+ function getClampedFontSize(baseWorldSize: number, minScreenSize: number, maxScreenSize: number, zoom: number): number {
47
+ return clamp(baseWorldSize, minScreenSize / zoom, maxScreenSize / zoom)
48
+ }
49
+
50
+ // ── Chakra v2 type palette - mirrors TYPE_COLORS in src/types/index.ts ─
51
+ // .400 variants: used for type badge text and border tint
52
+ const TYPE_COLOR_400: Record<string, string> = {
53
+ person: '#38b2ac', // teal.400
54
+ system: '#63b3ed', // blue.400
55
+ container: '#9f7aea', // purple.400
56
+ component: '#f6ad55', // orange.400
57
+ database: '#4fd1c5', // cyan.400
58
+ queue: '#f6e05e', // yellow.400
59
+ api: '#68d391', // green.400
60
+ service: '#f687b3', // pink.400
61
+ external: '#a0aec0', // gray.400
62
+ }
63
+
64
+ /** Border color: type .400 at 50% alpha - bold branded tint */
65
+ function typeBorderColor(type: string, alpha = 0.5): string {
66
+ const color = TYPE_COLOR_400[type]
67
+ const hex = typeof color === 'string' ? color : '#a0aec0'
68
+ const r = parseInt(hex.slice(1, 3), 16)
69
+ const g = parseInt(hex.slice(3, 5), 16)
70
+ const b = parseInt(hex.slice(5, 7), 16)
71
+ return `rgba(${r},${g},${b},${alpha})`
72
+ }
73
+
74
+ /** Read a CSS custom property value from :root (resolves color-mix, etc.). */
75
+ function readCSSVar(name: string, fallback: string): string {
76
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
77
+ return v || fallback
78
+ }
79
+
80
+ // ── Geometry helpers ───────────────────────────────────────────────
81
+
82
+ const imageCache = new Map<string, HTMLImageElement>()
83
+
84
+ let onImageLoadCallback: (() => void) | null = null
85
+ export function setOnImageLoadCallback(cb: (() => void) | null) {
86
+ onImageLoadCallback = cb
87
+ }
88
+
89
+ let currentHighlightedTags: Set<string> = new Set()
90
+ export function setHighlightedTags(tags: Set<string>): void {
91
+ currentHighlightedTags = tags
92
+ }
93
+
94
+ let currentHighlightColor = ''
95
+ export function setHighlightColor(color: string): void {
96
+ currentHighlightColor = color
97
+ }
98
+
99
+ let currentHiddenTags: Set<string> = new Set()
100
+ export function setHiddenTags(tags: Set<string>): void {
101
+ currentHiddenTags = tags
102
+ }
103
+
104
+ /**
105
+ * Get image from cache or start loading it.
106
+ * Returns the image if already loaded, null otherwise.
107
+ */
108
+ function getOrLoadImage(url: string | null): HTMLImageElement | null {
109
+ if (!url) return null
110
+ const cached = imageCache.get(url)
111
+ if (cached) {
112
+ return cached.complete && cached.naturalWidth > 0 ? cached : null
113
+ }
114
+
115
+ const img = new Image()
116
+ img.src = url
117
+ img.onload = () => {
118
+ if (onImageLoadCallback) onImageLoadCallback()
119
+ }
120
+ imageCache.set(url, img)
121
+ return null
122
+ }
123
+
124
+ function clamp(v: number, min: number, max: number): number {
125
+ return v < min ? min : v > max ? max : v
126
+ }
127
+
128
+ function transitionT(screenW: number, start: number, end: number): number {
129
+ return clamp((screenW - start) / (end - start), 0, 1)
130
+ }
131
+
132
+ function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
133
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
134
+ }
135
+
136
+ function worldToScreen(matrix: DOMMatrix, x: number, y: number) {
137
+ return new DOMPoint(x, y).matrixTransform(matrix)
138
+ }
139
+
140
+ function screenToWorld(matrix: DOMMatrix, x: number, y: number) {
141
+ return new DOMPoint(x, y).matrixTransform(matrix.inverse())
142
+ }
143
+
144
+ export function pickEdgeLabelPosition(
145
+ matrix: DOMMatrix,
146
+ midX: number,
147
+ midY: number,
148
+ textW: number,
149
+ textH: number,
150
+ dx: number,
151
+ dy: number,
152
+ occupiedLabelRects: ScreenRect[],
153
+ ) {
154
+ const screenMid = worldToScreen(matrix, midX, midY)
155
+ const screenTextW = Math.max(1, textW * matrix.a)
156
+ const screenTextH = Math.max(1, textH * matrix.d)
157
+ const gap = 6
158
+ const step = screenTextH + gap
159
+ const length = Math.hypot(dx, dy) || 1
160
+ const normalX = -dy / length
161
+ const normalY = dx / length
162
+ const tangentX = dx / length
163
+ const tangentY = dy / length
164
+ const candidateOffsets = [
165
+ { x: 0, y: 0 },
166
+ { x: normalX * step, y: normalY * step },
167
+ { x: -normalX * step, y: -normalY * step },
168
+ { x: normalX * step * 2, y: normalY * step * 2 },
169
+ { x: -normalX * step * 2, y: -normalY * step * 2 },
170
+ { x: tangentX * step, y: tangentY * step },
171
+ { x: -tangentX * step, y: -tangentY * step },
172
+ { x: tangentX * step + normalX * step, y: tangentY * step + normalY * step },
173
+ { x: -tangentX * step - normalX * step, y: -tangentY * step - normalY * step },
174
+ ]
175
+
176
+ for (const offset of candidateOffsets) {
177
+ const centerX = screenMid.x + offset.x
178
+ const centerY = screenMid.y + offset.y
179
+ const rect: ScreenRect = {
180
+ left: centerX - screenTextW / 2 - gap,
181
+ top: centerY - screenTextH / 2 - gap / 2,
182
+ right: centerX + screenTextW / 2 + gap,
183
+ bottom: centerY + screenTextH / 2 + gap / 2,
184
+ }
185
+ if (occupiedLabelRects.some((existing) => rectsOverlap(rect, existing))) continue
186
+ occupiedLabelRects.push(rect)
187
+ const worldPoint = screenToWorld(matrix, centerX, centerY)
188
+ return { x: worldPoint.x, y: worldPoint.y }
189
+ }
190
+
191
+ const fallbackRect: ScreenRect = {
192
+ left: screenMid.x - screenTextW / 2 - gap,
193
+ top: screenMid.y - screenTextH / 2 - gap / 2,
194
+ right: screenMid.x + screenTextW / 2 + gap,
195
+ bottom: screenMid.y + screenTextH / 2 + gap / 2,
196
+ }
197
+ occupiedLabelRects.push(fallbackRect)
198
+ return { x: midX, y: midY }
199
+ }
200
+
201
+ /** Is the rect (in world space) visible on screen? */
202
+ export function isVisible(
203
+ worldX: number, worldY: number, worldW: number, worldH: number,
204
+ view: ZUIViewState, canvasW: number, canvasH: number,
205
+ ): boolean {
206
+ const sx = worldX * view.zoom + view.x
207
+ const sy = worldY * view.zoom + view.y
208
+ const sw = worldW * view.zoom
209
+ const sh = worldH * view.zoom
210
+ return sx + sw > 0 && sy + sh > 0 && sx < canvasW && sy < canvasH
211
+ }
212
+
213
+ /** Is the rect (in world space) FULLY visible on screen? */
214
+ export function isFullyVisible(
215
+ worldX: number, worldY: number, worldW: number, worldH: number,
216
+ view: ZUIViewState, canvasW: number, canvasH: number,
217
+ ): boolean {
218
+ const sx = worldX * view.zoom + view.x
219
+ const sy = worldY * view.zoom + view.y
220
+ const sw = worldW * view.zoom
221
+ const sh = worldH * view.zoom
222
+ return sx >= 0 && sy >= 0 && sx + sw <= canvasW && sy + sh <= canvasH
223
+ }
224
+
225
+ /** Draw the ZoomIn magnifying glass icon. */
226
+ function drawZoomInIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number): void {
227
+ ctx.save()
228
+ ctx.translate(x, y)
229
+ const s = size / 24
230
+ ctx.scale(s, s)
231
+ ctx.beginPath()
232
+ // Magnifying glass circle: cx="11" cy="11" r="8"
233
+ ctx.arc(11, 11, 8, 0, Math.PI * 2)
234
+ // Handle: x1="21" y1="21" x2="16.65" y2="16.65"
235
+ ctx.moveTo(21, 21)
236
+ ctx.lineTo(16.65, 16.65)
237
+ // Plus vertical: x1="11" y1="8" x2="11" y2="14"
238
+ ctx.moveTo(11, 8)
239
+ ctx.lineTo(11, 14)
240
+ // Plus horizontal: x1="8" y1="11" x2="14" y2="11"
241
+ ctx.moveTo(8, 11)
242
+ ctx.lineTo(14, 11)
243
+ ctx.lineWidth = strokeWidth
244
+ ctx.lineCap = 'round'
245
+ ctx.lineJoin = 'round'
246
+ ctx.stroke()
247
+ ctx.restore()
248
+ }
249
+
250
+ /** Draw a portal arrow icon (↗) for portal nodes. */
251
+ function drawPortalIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number, color: string): void {
252
+ ctx.save()
253
+ ctx.strokeStyle = color
254
+ ctx.lineWidth = strokeWidth
255
+ ctx.lineCap = 'round'
256
+ ctx.lineJoin = 'round'
257
+ ctx.translate(x, y)
258
+ const s = size / 16
259
+ ctx.scale(s, s)
260
+ ctx.beginPath()
261
+ // Arrow shaft: (2,14) → (13,3)
262
+ ctx.moveTo(2, 14)
263
+ ctx.lineTo(13, 3)
264
+ // Arrow head
265
+ ctx.moveTo(5, 3)
266
+ ctx.lineTo(13, 3)
267
+ ctx.lineTo(13, 11)
268
+ ctx.stroke()
269
+ ctx.restore()
270
+ }
271
+
272
+ /** Draw a cycle icon (↺) for circular nodes. */
273
+ function drawCycleIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number, color: string): void {
274
+ ctx.save()
275
+ ctx.strokeStyle = color
276
+ ctx.lineWidth = strokeWidth
277
+ ctx.lineCap = 'round'
278
+ ctx.lineJoin = 'round'
279
+ ctx.translate(x, y)
280
+ const s = size / 24
281
+ ctx.scale(s, s)
282
+ ctx.beginPath()
283
+ // Circular arrow
284
+ ctx.arc(12, 12, 8, 0, Math.PI * 1.5)
285
+ ctx.moveTo(12, 4)
286
+ ctx.lineTo(16, 4)
287
+ ctx.lineTo(16, 0)
288
+ ctx.stroke()
289
+ ctx.restore()
290
+ }
291
+
292
+ /** Parse a hex CSS color (#rrggbb or #rgb) into { r, g, b } 0-255. */
293
+ function parseHex(hex: string): { r: number; g: number; b: number } {
294
+ hex = hex.trim().replace(/^#/, '')
295
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
296
+ return {
297
+ r: parseInt(hex.slice(0, 2), 16),
298
+ g: parseInt(hex.slice(2, 4), 16),
299
+ b: parseInt(hex.slice(4, 6), 16),
300
+ }
301
+ }
302
+
303
+ /** Derive a portal tint color from the accent: same hue, very low alpha. */
304
+ function portalTintColor(accent: string, alpha: number): string {
305
+ const { r, g, b } = parseHex(accent)
306
+ return `rgba(${r},${g},${b},${alpha})`
307
+ }
308
+
309
+ /** Draw a squiggly line from (x1, y1) to (x2, y2). */
310
+ function drawSquigglyLine(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, zoom: number): void {
311
+ ctx.save()
312
+ ctx.beginPath()
313
+ ctx.moveTo(x1, y1)
314
+ ctx.lineTo(x2, y2)
315
+ const dashLen = 6 / zoom
316
+ ctx.setLineDash([dashLen, dashLen * 1.5])
317
+ ctx.stroke()
318
+ ctx.restore()
319
+ }
320
+
321
+ /** Calculate coordinate for a named handle on a node. */
322
+ function getHandlePos(nodeX: number, nodeY: number, nodeW: number, nodeH: number, handleId: string | null, isSource: boolean): { x: number, y: number, pos: 'top' | 'bottom' | 'left' | 'right' } {
323
+ const fallback = isSource ? DEFAULT_SOURCE_HANDLE_SIDE : DEFAULT_TARGET_HANDLE_SIDE
324
+ const { x, y, side } = getHandleFlowPosition(nodeX, nodeY, nodeW, nodeH, handleId, fallback)
325
+ return { x, y, pos: side }
326
+ }
327
+
328
+ /** Draw a closed arrow head matching React Flow MarkerType.ArrowClosed. */
329
+ function drawArrowHead(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, size: number, color: string): void {
330
+ ctx.save()
331
+ ctx.translate(x, y)
332
+ ctx.rotate(angle)
333
+ ctx.beginPath()
334
+ // React Flow ArrowClosed is roughly a triangle
335
+ // size 14x14
336
+ ctx.moveTo(0, 0)
337
+ ctx.lineTo(-size, -size * 0.45)
338
+ ctx.lineTo(-size, size * 0.45)
339
+ ctx.closePath()
340
+ ctx.fillStyle = color
341
+ ctx.fill()
342
+ ctx.restore()
343
+ }
344
+
345
+ // ── Node drawing ───────────────────────────────────────────────────
346
+
347
+ /**
348
+ * Draw a single node.
349
+ *
350
+ * @param ctx Canvas 2D context (already in world-space transform)
351
+ * @param node The node to draw
352
+ * @param screenW Width of this node in screen pixels (worldW * zoom)
353
+ * @param alpha Outer opacity multiplier (from parent's childrenOpacity)
354
+ * @param zoom Current zoom (needed for font sizes)
355
+ * @param accent Resolved --accent CSS color (passed from renderFrame to avoid re-reading per node)
356
+ * @param labelBg Resolved label background color (passed through to avoid per-edge CSS reads)
357
+ * @param absX Absolute world-space X of this node (for child visibility culling)
358
+ * @param absY Absolute world-space Y of this node (for child visibility culling)
359
+ * @param absScale Accumulated product of ancestor childScale values (world-space scale factor).
360
+ * 1 for top-level nodes; multiplied by each parent's childScale going deeper.
361
+ * Required to correctly map child-local displacements to world-space for culling.
362
+ */
363
+ function drawNode(
364
+ ctx: CanvasRenderingContext2D,
365
+ node: LayoutNode,
366
+ screenW: number,
367
+ thresholds: { start: number; end: number },
368
+ alpha: number,
369
+ zoom: number,
370
+ nodeBg: string,
371
+ canvasBg: string,
372
+ view: ZUIViewState,
373
+ canvasW: number,
374
+ canvasH: number,
375
+ accent: string,
376
+ labelBg: string,
377
+ absX: number,
378
+ absY: number,
379
+ absScale: number,
380
+ occupiedLabelRects: ScreenRect[],
381
+ ): void {
382
+ if (screenW < MIN_DRAW_PX || alpha < 0.01) return
383
+
384
+ // Skip nodes whose tags are all hidden
385
+ if (currentHiddenTags.size > 0 && node.tags.length > 0 && node.tags.some(t => currentHiddenTags.has(t))) return
386
+
387
+ const x = node.worldX
388
+ const y = node.worldY
389
+ const w = node.worldW
390
+ const h = node.worldH
391
+
392
+ let drawZoom = zoom
393
+ let drawScreenW = screenW
394
+
395
+ const hasChildren = node.children && node.children.length > 0
396
+ const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
397
+
398
+ // ── Cap leaf nodes visually ──
399
+ if (!hasChildren && screenW > thresholds.end) {
400
+ const s = thresholds.end / screenW
401
+ drawZoom = zoom * s
402
+ drawScreenW = thresholds.end
403
+ ctx.save()
404
+ const cx = x + w / 2
405
+ const cy = y + h / 2
406
+ ctx.translate(cx, cy)
407
+ ctx.scale(s, s)
408
+ ctx.translate(-cx, -cy)
409
+ }
410
+
411
+ const parentAlpha = alpha * (1 - t)
412
+ const childAlpha = alpha * t
413
+ const r = 8 / drawZoom // matches Chakra rounded="lg" (8px)
414
+
415
+ const borderColor = typeBorderColor(node.type)
416
+
417
+ const traceShape = (ox = 0, oy = 0) => {
418
+ ctx.beginPath()
419
+ ctx.roundRect(x + ox, y + oy, w, h, r)
420
+ }
421
+
422
+ // ── Circular Link Overlay - subtle indicator ──────────────────────
423
+ if (node.isCircular && parentAlpha > 0.1) {
424
+ ctx.save()
425
+ ctx.globalAlpha = parentAlpha * 0.15
426
+ ctx.fillStyle = accent
427
+ traceShape()
428
+ ctx.fill()
429
+ ctx.restore()
430
+ }
431
+
432
+ // ── Zoomable Stack Signal - subtle card stack behind ───────────────
433
+ if (hasChildren && parentAlpha > 0.1 && t < 0.5) {
434
+ const stackT = 1 - (t / 0.5) // Fades out completely by t=0.5
435
+ ctx.save()
436
+ ctx.globalAlpha = parentAlpha * stackT * 0.4
437
+ ctx.fillStyle = nodeBg
438
+ ctx.strokeStyle = borderColor
439
+ ctx.lineWidth = 1 / drawZoom
440
+
441
+ const offset1 = 4 / drawZoom
442
+ const offset2 = 8 / drawZoom
443
+
444
+ // Draw two offset rectangles behind the node
445
+ // Rect 2 (deepest)
446
+ traceShape(offset2, offset2)
447
+ ctx.fill()
448
+ ctx.stroke()
449
+
450
+ // Rect 1
451
+ traceShape(offset1, offset1)
452
+ ctx.fill()
453
+ ctx.stroke()
454
+ ctx.restore()
455
+ }
456
+
457
+ // ── Background ───────────────────────────────────────────────────
458
+ // We draw two backgrounds:
459
+ // 1. A base background (canvasBg) that remains opaque (total 'alpha').
460
+ // This hides connectors from parent levels.
461
+ // 2. The node's branded background (nodeBg) that fades out as we zoom in ('parentAlpha').
462
+ // This makes the nested diagram appear on a clean canvas background.
463
+ if (alpha > 0.01) {
464
+ ctx.save()
465
+ traceShape()
466
+
467
+ // Base background (20% transparent to allow slight ghosting of connectors)
468
+ ctx.globalAlpha = alpha * 0.8
469
+ ctx.fillStyle = canvasBg
470
+ ctx.fill()
471
+
472
+ // Fading node background
473
+ if (parentAlpha > 0.01) {
474
+ ctx.globalAlpha = parentAlpha * 0.8
475
+ ctx.fillStyle = nodeBg
476
+ ctx.fill()
477
+
478
+ // Portal overlay: accent-tinted fill derived from --accent CSS var
479
+ if (node.isPortal) {
480
+ ctx.fillStyle = portalTintColor(accent, 0.10)
481
+ ctx.fill()
482
+ }
483
+ }
484
+
485
+ ctx.restore()
486
+ }
487
+
488
+ // ── Technology Icon - Top Center like ElementNode.tsx (no fade) ──────
489
+ // Hide when node is too small (drawScreenW < 60)
490
+ if (node.logoUrl && parentAlpha > 0.05 && drawScreenW > 60) {
491
+ const img = getOrLoadImage(node.logoUrl)
492
+ if (img) {
493
+ ctx.save()
494
+ ctx.globalAlpha = parentAlpha * 1
495
+
496
+ // Scale logoMaxDim and topOffset relative to node world height 'h'
497
+ // instead of fixed screen pixels.
498
+ const logoMaxDim = h * 0.35
499
+ const topOffset = h * 0.06
500
+
501
+ const aspect = img.width / img.height
502
+ let drawW = logoMaxDim
503
+ let drawH = drawW / aspect
504
+
505
+ if (drawH > logoMaxDim) {
506
+ drawH = logoMaxDim
507
+ drawW = drawH * aspect
508
+ }
509
+
510
+ // Center icon at top
511
+ const iconX = x + (w - drawW) / 2
512
+ const iconY = y + topOffset + (logoMaxDim - drawH) / 2
513
+
514
+ ctx.drawImage(img, iconX, iconY, drawW, drawH)
515
+ ctx.restore()
516
+ }
517
+ }
518
+ // ── Border - portal uses accent long-dash; others use type-tinted border ─
519
+ ctx.save()
520
+ ctx.globalAlpha = alpha
521
+ traceShape()
522
+ if (node.isPortal) {
523
+ // Solid accent border per latest request
524
+ ctx.strokeStyle = accent
525
+ ctx.lineWidth = 1 / drawZoom
526
+ ctx.setLineDash([])
527
+ } else {
528
+ ctx.strokeStyle = borderColor
529
+ ctx.lineWidth = 1.5 / drawZoom
530
+ if (t > 0.15) {
531
+ const dashLen = 6
532
+ ctx.setLineDash([dashLen, dashLen * 0.7])
533
+ } else {
534
+ ctx.setLineDash([])
535
+ }
536
+ }
537
+ ctx.stroke()
538
+ ctx.setLineDash([])
539
+ ctx.restore()
540
+
541
+ // ── Label - portal shows "PORTAL" badge in accent; otherwise type badge ─
542
+ if (screenW >= MIN_LABEL_PX && parentAlpha > 0.1) {
543
+ // Dynamic minimum: don't let font be larger than a fraction of node height on screen
544
+ const minName = Math.min(MIN_FONT_NAME, screenW * 0.35)
545
+ // w=200, so 0.10w = 20px (Chakra 'xl')
546
+ const nameFontSize = getClampedFontSize(w * 0.10, minName, MAX_FONT_NAME, drawZoom)
547
+ const screenFontSize = nameFontSize * drawZoom
548
+
549
+ if (screenFontSize >= 6) {
550
+ ctx.save()
551
+ ctx.globalAlpha = parentAlpha
552
+ ctx.font = `600 ${nameFontSize}px Inter, system-ui, sans-serif`
553
+ ctx.fillStyle = '#f7fafc' // gray.100
554
+ ctx.textAlign = 'center'
555
+ ctx.textBaseline = 'middle'
556
+
557
+ const worldPadding = w * 0.08
558
+ const maxW = w - worldPadding
559
+ let label = node.label
560
+ const totalW = ctx.measureText(label).width
561
+ if (totalW > maxW) {
562
+ const ratio = maxW / totalW
563
+ label = label.slice(0, Math.max(3, Math.floor(label.length * ratio)))
564
+ if (label.length < node.label.length) label += '…'
565
+ }
566
+
567
+ // If logo exists and is shown, push text down similar to ElementNode.tsx (pt=9/36px)
568
+ const showLogo = !!node.logoUrl && drawScreenW > 60
569
+ const baseOffset = showLogo ? 0.15 : 0
570
+ const nameY = drawScreenW > BADGE_THRESHOLD ? y + h * (0.42 + baseOffset) : y + h * (0.5 + baseOffset)
571
+ ctx.fillText(label, x + w / 2, nameY)
572
+
573
+ // Type badge - using regular element type display
574
+ if (drawScreenW > BADGE_THRESHOLD) {
575
+ const minBadge = Math.min(MIN_FONT_BADGE, screenW * 0.20)
576
+ // 0.05w = 10px (Chakra '2xs')
577
+ const badgeFontSize = getClampedFontSize(w * 0.05, minBadge, MAX_FONT_BADGE, drawZoom)
578
+ if (badgeFontSize * drawZoom >= 5) {
579
+ ctx.font = `${badgeFontSize}px Inter, system-ui, sans-serif`
580
+ const badgeColor = TYPE_COLOR_400[node.type]
581
+ ctx.fillStyle = typeof badgeColor === 'string' ? badgeColor : '#a0aec0'
582
+ const displayType = typeof node.type === 'string' ? node.type.toUpperCase() : 'UNKNOWN'
583
+ ctx.fillText(displayType, x + w / 2, y + h * (0.62 + baseOffset))
584
+ }
585
+ }
586
+ ctx.restore()
587
+ }
588
+ }
589
+
590
+ // ── Linked-diagram hint below node during transition ─────────────
591
+ if (node.linkedDiagramLabel && t > 0.05 && alpha > 0.05) {
592
+ const hintFontSize = getClampedFontSize(14, MIN_FONT_HINT, MAX_FONT_HINT, drawZoom)
593
+ const screenFontSize = hintFontSize * drawZoom
594
+
595
+ if (screenFontSize >= 6) {
596
+ let hintX = x + w / 2
597
+ let hintY = y + h + 10 // Fixed distance in world units
598
+
599
+ if (t > 0.8) {
600
+ // Sticky hint Y: stick to viewport bottom
601
+ const viewportBottomWorld = (canvasH - screenFontSize - view.y) / view.zoom
602
+ hintY = Math.min(hintY, viewportBottomWorld)
603
+ hintY = Math.max(hintY, y + h / 2) // avoid overlapping center
604
+
605
+ // Sticky hint X: stick to viewport sides
606
+ const vwL = -view.x / view.zoom
607
+ const vwR = (canvasW - view.x) / view.zoom
608
+
609
+ ctx.save()
610
+ ctx.font = `${hintFontSize}px Inter, system-ui, sans-serif`
611
+ const tw = ctx.measureText('⊞ ' + node.linkedDiagramLabel).width
612
+ ctx.restore()
613
+
614
+ const pad = 30 / view.zoom
615
+ hintX = Math.max(hintX, vwL + tw / 2 + pad)
616
+ hintX = Math.min(hintX, vwR - tw / 2 - pad)
617
+ // Ensure it stays within node boundaries (with some padding)
618
+ hintX = clamp(hintX, x + tw / 2 + 10, x + w - tw / 2 - 10)
619
+ }
620
+
621
+ ctx.save()
622
+ ctx.globalAlpha = alpha * 0.7
623
+ ctx.font = `${hintFontSize}px Inter, system-ui, sans-serif`
624
+ ctx.fillStyle = node.isCircular ? accent : '#718096' // accent for circular to draw attention
625
+ ctx.textAlign = 'center'
626
+ ctx.textBaseline = 'top'
627
+ const hintPrefix = node.isCircular ? '↺ ' : '⊞ '
628
+ const hintSuffix = node.isCircular ? ' (Circular)' : ''
629
+ ctx.fillText(hintPrefix + node.linkedDiagramLabel + hintSuffix, hintX, hintY)
630
+ ctx.restore()
631
+ }
632
+ }
633
+
634
+ // ── Children ─────────────────────────────────────────────────────
635
+ if (childAlpha > 0.01 && node.children.length > 0) {
636
+ ctx.save()
637
+ // Clip to the node's rect so children don't bleed out
638
+ traceShape()
639
+ ctx.clip()
640
+
641
+ // Transform into child-local space
642
+ ctx.translate(x, y)
643
+ ctx.scale(node.childScale, node.childScale)
644
+ ctx.translate(-node.childOffsetX, -node.childOffsetY)
645
+
646
+ const childZoom = zoom * node.childScale
647
+ const edgeZoom = drawZoom * node.childScale
648
+
649
+ // Recursive children's edges DRAWN FIRST (below nodes)
650
+ if (childAlpha > 0.2) {
651
+ drawEdges(ctx, node.children, childAlpha * 0.5, edgeZoom, thresholds, accent, labelBg, occupiedLabelRects)
652
+ }
653
+
654
+ const nextAbsScale = absScale * node.childScale
655
+ for (const child of node.children) {
656
+ const childAbsX = absX + (child.worldX - node.childOffsetX) * node.childScale * absScale
657
+ const childAbsY = absY + (child.worldY - node.childOffsetY) * node.childScale * absScale
658
+ const childAbsW = child.worldW * node.childScale * absScale
659
+ const childAbsH = child.worldH * node.childScale * absScale
660
+ if (!isVisible(childAbsX, childAbsY, childAbsW, childAbsH, view, canvasW, canvasH)) continue
661
+
662
+ const childScreenW = child.worldW * childZoom
663
+ drawNode(ctx, child, childScreenW, thresholds, childAlpha, childZoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, childAbsX, childAbsY, nextAbsScale, occupiedLabelRects)
664
+ }
665
+
666
+ ctx.restore()
667
+ }
668
+
669
+ // ── Zoomable indicator (top-right) ──────────────────────────────
670
+ if ((hasChildren || node.isCircular) && t < 0.9 && alpha > 0.2 && drawScreenW > BADGE_THRESHOLD) {
671
+ const iconSize = getClampedFontSize(12, 10, 16, drawZoom)
672
+ const padding = 8 / drawZoom
673
+
674
+ ctx.save()
675
+ // Noticeable but subtle: opacity fades as we zoom in (t increases)
676
+ ctx.globalAlpha = alpha * (1 - t) * 0.8
677
+ ctx.strokeStyle = accent
678
+ if (node.isCircular) {
679
+ drawCycleIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5, accent)
680
+ } else if (node.isPortal) {
681
+ // Portal: use arrow icon instead of magnifying glass
682
+ drawPortalIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5, accent)
683
+ } else {
684
+ drawZoomInIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5)
685
+ }
686
+ ctx.restore()
687
+ }
688
+
689
+ // ── Tag highlighting dim / glow ──────────────────────────────────
690
+ if (currentHighlightedTags.size > 0 && parentAlpha > 0.05) {
691
+ const isHighlighted = node.tags.length > 0 && node.tags.some(t => currentHighlightedTags.has(t))
692
+ if (!isHighlighted) {
693
+ ctx.save()
694
+ ctx.globalAlpha = parentAlpha * 0.82
695
+ ctx.fillStyle = canvasBg
696
+ traceShape()
697
+ ctx.fill()
698
+ ctx.restore()
699
+ } else {
700
+ const glowColor = currentHighlightColor || accent
701
+ ctx.save()
702
+ ctx.globalAlpha = parentAlpha
703
+ ctx.shadowColor = glowColor
704
+ ctx.shadowBlur = 8 / drawZoom
705
+ ctx.strokeStyle = glowColor
706
+ ctx.lineWidth = 2.5 / drawZoom
707
+ ctx.setLineDash([])
708
+ traceShape()
709
+ ctx.stroke()
710
+ ctx.shadowBlur = 0
711
+ ctx.restore()
712
+ }
713
+ }
714
+
715
+ if (!hasChildren && screenW > thresholds.end) {
716
+ ctx.restore()
717
+ }
718
+ }
719
+
720
+ // ── Edge drawing ───────────────────────────────────────────────────
721
+
722
+ function drawEdges(
723
+ ctx: CanvasRenderingContext2D,
724
+ nodes: LayoutNode[],
725
+ alpha: number,
726
+ zoom: number,
727
+ thresholds: { start: number; end: number },
728
+ accent: string,
729
+ labelBg: string,
730
+ occupiedLabelRects: ScreenRect[],
731
+ ): void {
732
+ if (alpha < 0.05) return
733
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]))
734
+ const handleUsage: Record<string, { edgeKey: string; type: 'source' | 'target'; otherNodeCoord: number }[]> = {}
735
+
736
+ nodes.forEach((node) => {
737
+ node.edgesOut.forEach((edge, edgeIndex) => {
738
+ const target = nodeMap.get(edge.targetId)
739
+ if (!target) return
740
+
741
+ const edgeKey = `${node.id}:${edgeIndex}`
742
+ const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
743
+ const targetSide = getLogicalHandleId(edge.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
744
+
745
+ const srcKey = `${node.id}-${sourceSide}`
746
+ handleUsage[srcKey] ??= []
747
+ handleUsage[srcKey].push({
748
+ edgeKey,
749
+ type: 'source',
750
+ otherNodeCoord: sourceSide === 'left' || sourceSide === 'right'
751
+ ? target.worldY + target.worldH / 2
752
+ : target.worldX + target.worldW / 2,
753
+ })
754
+
755
+ const tgtKey = `${target.id}-${targetSide}`
756
+ handleUsage[tgtKey] ??= []
757
+ handleUsage[tgtKey].push({
758
+ edgeKey,
759
+ type: 'target',
760
+ otherNodeCoord: targetSide === 'left' || targetSide === 'right'
761
+ ? node.worldY + node.worldH / 2
762
+ : node.worldX + node.worldW / 2,
763
+ })
764
+ })
765
+ })
766
+
767
+ Object.values(handleUsage).forEach((usages) => {
768
+ usages.sort((a, b) => a.otherNodeCoord - b.otherNodeCoord)
769
+ })
770
+
771
+ for (const node of nodes) {
772
+ for (const [edgeIndex, edge] of node.edgesOut.entries()) {
773
+ const target = nodeMap.get(edge.targetId)
774
+ if (!target) continue
775
+
776
+ // Skip edge if either endpoint is hidden by tag filter
777
+ if (currentHiddenTags.size > 0) {
778
+ const srcHidden = node.tags.length > 0 && node.tags.some(t => currentHiddenTags.has(t))
779
+ const tgtHidden = target.tags.length > 0 && target.tags.some(t => currentHiddenTags.has(t))
780
+ if (srcHidden || tgtHidden) continue
781
+ }
782
+
783
+ const dir = edge.direction ?? 'forward'
784
+ const type = edge.type || 'bezier'
785
+
786
+ // ── Effective visual dimensions (handles capping) ─────────────
787
+ const hasSourceChildren = node.children && node.children.length > 0
788
+ const sourceScreenW = node.worldW * zoom
789
+ const sSource = (!hasSourceChildren && sourceScreenW > thresholds.end) ? thresholds.end / sourceScreenW : 1
790
+ const effWSource = node.worldW * sSource
791
+ const effHSource = node.worldH * sSource
792
+ const cxSource = node.worldX + node.worldW / 2
793
+ const cySource = node.worldY + node.worldH / 2
794
+ const effXSource = cxSource - effWSource / 2
795
+ const effYSource = cySource - effHSource / 2
796
+
797
+ const hasTargetChildren = target.children && target.children.length > 0
798
+ const targetScreenW = target.worldW * zoom
799
+ const sTarget = (!hasTargetChildren && targetScreenW > thresholds.end) ? thresholds.end / targetScreenW : 1
800
+ const effWTarget = target.worldW * sTarget
801
+ const effHTarget = target.worldH * sTarget
802
+ const cxTarget = target.worldX + target.worldW / 2
803
+ const cyTarget = target.worldY + target.worldH / 2
804
+ const effXTarget = cxTarget - effWTarget / 2
805
+ const effYTarget = cyTarget - effHTarget / 2
806
+
807
+ const edgeKey = `${node.id}:${edgeIndex}`
808
+ const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
809
+ const targetSide = getLogicalHandleId(edge.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
810
+ const srcGroup = handleUsage[`${node.id}-${sourceSide}`] ?? []
811
+ const tgtGroup = handleUsage[`${target.id}-${targetSide}`] ?? []
812
+ const sourceGroupIndex = srcGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'source')
813
+ const targetGroupIndex = tgtGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'target')
814
+
815
+ const sH = getHandlePos(
816
+ effXSource,
817
+ effYSource,
818
+ effWSource,
819
+ effHSource,
820
+ getVisualHandleIdForGroup(sourceSide, sourceGroupIndex, Math.max(srcGroup.length, 1)),
821
+ true,
822
+ )
823
+ const tH = getHandlePos(
824
+ effXTarget,
825
+ effYTarget,
826
+ effWTarget,
827
+ effHTarget,
828
+ getVisualHandleIdForGroup(targetSide, targetGroupIndex, Math.max(tgtGroup.length, 1)),
829
+ false,
830
+ )
831
+
832
+ ctx.save()
833
+ ctx.globalAlpha = alpha * 0.8
834
+ ctx.strokeStyle = accent
835
+ ctx.lineWidth = 2 / zoom
836
+
837
+ let midX = (sH.x + tH.x) / 2
838
+ let midY = (sH.y + tH.y) / 2
839
+ let finalAngleS = 0
840
+ let finalAngleT = 0
841
+
842
+ if (type === 'bezier') {
843
+ const curvature = 0.5
844
+ let cp1x = sH.x, cp1y = sH.y, cp2x = tH.x, cp2y = tH.y
845
+ const dx = Math.abs(tH.x - sH.x)
846
+ const dy = Math.abs(tH.y - sH.y)
847
+
848
+ // Minimum stem: control point must extend at least half the node's
849
+ // dimension along the handle's exit axis. This prevents the curve
850
+ // from taking a sharp turn when dx or dy is small relative to the node.
851
+ const minStemSH = (sH.pos === 'left' || sH.pos === 'right') ? effWSource * 0.5 : effHSource * 0.5
852
+ const minStemTH = (tH.pos === 'left' || tH.pos === 'right') ? effWTarget * 0.5 : effHTarget * 0.5
853
+
854
+ if (sH.pos === 'left' || sH.pos === 'right') {
855
+ const stem = Math.max(dx * curvature, minStemSH)
856
+ cp1x += sH.pos === 'left' ? -stem : stem
857
+ } else {
858
+ const stem = Math.max(dy * curvature, minStemSH)
859
+ cp1y += sH.pos === 'top' ? -stem : stem
860
+ }
861
+
862
+ if (tH.pos === 'left' || tH.pos === 'right') {
863
+ const stem = Math.max(dx * curvature, minStemTH)
864
+ cp2x += tH.pos === 'left' ? -stem : stem
865
+ } else {
866
+ const stem = Math.max(dy * curvature, minStemTH)
867
+ cp2y += tH.pos === 'top' ? -stem : stem
868
+ }
869
+
870
+ ctx.beginPath()
871
+ ctx.moveTo(sH.x, sH.y)
872
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, tH.x, tH.y)
873
+ ctx.stroke()
874
+
875
+ midX = 0.125 * sH.x + 0.375 * cp1x + 0.375 * cp2x + 0.125 * tH.x
876
+ midY = 0.125 * sH.y + 0.375 * cp1y + 0.375 * cp2y + 0.125 * tH.y
877
+ finalAngleT = Math.atan2(tH.y - cp2y, tH.x - cp2x)
878
+ finalAngleS = Math.atan2(sH.y - cp1y, sH.x - cp1x)
879
+
880
+ } else if (type === 'straight') {
881
+ ctx.beginPath()
882
+ ctx.moveTo(sH.x, sH.y)
883
+ ctx.lineTo(tH.x, tH.y)
884
+ ctx.stroke()
885
+ finalAngleT = Math.atan2(tH.y - sH.y, tH.x - sH.x)
886
+ finalAngleS = Math.atan2(sH.y - tH.y, sH.x - tH.x)
887
+
888
+ } else if (type === 'step' || type === 'smoothstep') {
889
+ const borderRadius = type === 'smoothstep' ? 6 / zoom : 0
890
+
891
+ const points: Array<{ x: number, y: number }> = [{ x: sH.x, y: sH.y }]
892
+ const sOrth = sH.pos === 'left' || sH.pos === 'right' ? 'h' : 'v'
893
+ const tOrth = tH.pos === 'left' || tH.pos === 'right' ? 'h' : 'v'
894
+
895
+ if (sOrth === 'h' && tOrth === 'h') {
896
+ // Both horizontal: exit H, then V turn, then enter H
897
+ points.push({ x: midX, y: sH.y })
898
+ points.push({ x: midX, y: tH.y })
899
+ } else if (sOrth === 'v' && tOrth === 'v') {
900
+ // Both vertical: exit V, then H turn, then enter V
901
+ points.push({ x: sH.x, y: midY })
902
+ points.push({ x: tH.x, y: midY })
903
+ } else if (sOrth === 'h' && tOrth === 'v') {
904
+ // Mixed: exit H, turn V, enter V
905
+ points.push({ x: tH.x, y: sH.y })
906
+ } else if (sOrth === 'v' && tOrth === 'h') {
907
+ // Mixed: exit V, turn H, enter H
908
+ points.push({ x: sH.x, y: tH.y })
909
+ }
910
+ points.push({ x: tH.x, y: tH.y })
911
+
912
+ // Calculate label midpoint along the orthogonal segments
913
+ if (points.length === 4) {
914
+ // H-H or V-V: put label in the middle of the middle segment
915
+ midX = (points[1].x + points[2].x) / 2
916
+ midY = (points[1].y + points[2].y) / 2
917
+ } else if (points.length === 3) {
918
+ // Mixed H-V or V-H: put label in the middle of the longer segment
919
+ const d1 = Math.abs(points[1].x - points[0].x) + Math.abs(points[1].y - points[0].y)
920
+ const d2 = Math.abs(points[2].x - points[1].x) + Math.abs(points[2].y - points[1].y)
921
+ if (d1 > d2) {
922
+ midX = (points[0].x + points[1].x) / 2
923
+ midY = (points[0].y + points[1].y) / 2
924
+ } else {
925
+ midX = (points[1].x + points[2].x) / 2
926
+ midY = (points[1].y + points[2].y) / 2
927
+ }
928
+ }
929
+
930
+ ctx.beginPath()
931
+ ctx.moveTo(points[0].x, points[0].y)
932
+
933
+ for (let i = 1; i < points.length; i++) {
934
+ const curr = points[i]
935
+ const prev = points[i - 1]
936
+ const next = points[i + 1]
937
+
938
+ if (borderRadius > 0 && next) {
939
+ // Draw line to start of corner
940
+ const dPrevX = curr.x - prev.x
941
+ const dPrevY = curr.y - prev.y
942
+ const dPrevLen = Math.sqrt(dPrevX * dPrevX + dPrevY * dPrevY)
943
+ const r = Math.min(borderRadius, dPrevLen / 2)
944
+
945
+ ctx.lineTo(curr.x - (dPrevX / dPrevLen) * r, curr.y - (dPrevY / dPrevLen) * r)
946
+
947
+ // Draw arc
948
+ const dNextX = next.x - curr.x
949
+ const dNextY = next.y - curr.y
950
+ const dNextLen = Math.sqrt(dNextX * dNextX + dNextY * dNextY)
951
+ const rNext = Math.min(borderRadius, dNextLen / 2)
952
+
953
+ ctx.arcTo(curr.x, curr.y, curr.x + (dNextX / dNextLen) * rNext, curr.y + (dNextY / dNextLen) * rNext, r)
954
+ } else {
955
+ ctx.lineTo(curr.x, curr.y)
956
+ }
957
+ }
958
+ ctx.stroke()
959
+
960
+ // Arrows for step/smoothstep should align with final segment
961
+ const last = points[points.length - 1]
962
+ const prev = points[points.length - 2]
963
+ finalAngleT = Math.atan2(last.y - prev.y, last.x - prev.x)
964
+
965
+ const first = points[0]
966
+ const firstNext = points[1]
967
+ finalAngleS = Math.atan2(first.y - firstNext.y, first.x - firstNext.x)
968
+ }
969
+
970
+ // ── Arrow heads ───────────────────────────────────────────────
971
+ const visualTargetScreenW = effWTarget * zoom
972
+ const visualSourceScreenW = effWSource * zoom
973
+
974
+ // Scale arrow with node size, but cap it at 14px
975
+ // And hide if node is too small
976
+ const ARROW_SIZE_BASE = 10
977
+ const MIN_NODE_W_FOR_ARROW = 120
978
+
979
+ if (dir === 'forward' || dir === 'both' || dir === 'bidirectional') {
980
+ if (visualTargetScreenW > MIN_NODE_W_FOR_ARROW) {
981
+ const arrowScreenSize = Math.min(ARROW_SIZE_BASE, visualTargetScreenW * 0.2)
982
+ drawArrowHead(ctx, tH.x, tH.y, finalAngleT, arrowScreenSize / zoom, accent)
983
+ }
984
+ }
985
+ if (dir === 'backward' || dir === 'both' || dir === 'bidirectional') {
986
+ if (visualSourceScreenW > MIN_NODE_W_FOR_ARROW) {
987
+ const arrowScreenSize = Math.min(ARROW_SIZE_BASE, visualSourceScreenW * 0.2)
988
+ drawArrowHead(ctx, sH.x, sH.y, finalAngleS, arrowScreenSize / zoom, accent)
989
+ }
990
+ }
991
+
992
+ // ── Edge Label ───────────────────────────────────────────
993
+ if (edge.label && zoom * 11 > 4) {
994
+ const fontSize = 11 / zoom
995
+ ctx.font = `${fontSize}px Inter, system-ui, sans-serif`
996
+ const textMetrics = ctx.measureText(edge.label)
997
+ const textW = textMetrics.width
998
+ const textH = fontSize
999
+ const labelPos = pickEdgeLabelPosition(
1000
+ ctx.getTransform(),
1001
+ midX,
1002
+ midY,
1003
+ textW,
1004
+ textH,
1005
+ tH.x - sH.x,
1006
+ tH.y - sH.y,
1007
+ occupiedLabelRects,
1008
+ )
1009
+
1010
+ ctx.save()
1011
+ ctx.globalAlpha = alpha * 0.95
1012
+ ctx.fillStyle = labelBg
1013
+ const px = 4 / zoom, py = 2 / zoom
1014
+ ctx.beginPath()
1015
+ ctx.roundRect(labelPos.x - textW / 2 - px, labelPos.y - textH / 2 - py, textW + px * 2, textH + py * 2, 4 / zoom)
1016
+ ctx.fill()
1017
+ ctx.restore()
1018
+
1019
+ ctx.fillStyle = accent
1020
+ ctx.textAlign = 'center'
1021
+ ctx.textBaseline = 'middle'
1022
+ ctx.fillText(edge.label, labelPos.x, labelPos.y)
1023
+ }
1024
+
1025
+ ctx.restore()
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ // ── Diagram group label ────────────────────────────────────────────
1031
+
1032
+ function drawGroupLabel(
1033
+ ctx: CanvasRenderingContext2D,
1034
+ group: DiagramGroupLayout,
1035
+ view: ZUIViewState,
1036
+ canvasW: number,
1037
+ canvasH: number,
1038
+ accent: string,
1039
+ ): void {
1040
+ const screenW = group.worldW * view.zoom
1041
+ if (screenW < 30) return
1042
+
1043
+ const fontSize = clamp(13 / view.zoom, 3 / view.zoom, 24 / view.zoom)
1044
+ const labelX = group.worldX + group.diagramX + group.diagramW / 2
1045
+ const labelY = group.worldY + group.diagramY - 22 / view.zoom
1046
+
1047
+ // Ensure label is within viewport
1048
+ const screenY = labelY * view.zoom + view.y
1049
+ if (screenY < -20 || screenY > canvasH + 20) return
1050
+
1051
+ ctx.save()
1052
+ const levelText = group.levelLabel || `Level ${group.level}`
1053
+
1054
+ // ── Level indicator (e.g. "Level 1" or "System Context")
1055
+ const levelFontSize = fontSize * 0.8
1056
+ ctx.font = `600 ${levelFontSize}px Inter, system-ui, sans-serif`
1057
+ ctx.fillStyle = accent
1058
+ ctx.globalAlpha = 0.8
1059
+ ctx.textAlign = 'center'
1060
+ ctx.textBaseline = 'bottom'
1061
+ ctx.fillText(levelText.toUpperCase(), labelX, group.worldY + group.diagramY - 30 / view.zoom)
1062
+
1063
+ // ── Diagram Name
1064
+ ctx.font = `600 ${fontSize}px Inter, system-ui, sans-serif`
1065
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.95)'
1066
+ const nameText = group.nodes.length === 0 ? `${group.label} (Empty)` : group.label
1067
+ ctx.fillText(nameText, labelX, group.worldY + group.diagramY - 10 / view.zoom)
1068
+
1069
+ // ── Empty State Indicator inside the box
1070
+ if (group.nodes.length === 0 && view.zoom * group.diagramW > 100) {
1071
+ ctx.save()
1072
+ ctx.globalAlpha = 0.3
1073
+ ctx.font = `${fontSize * 0.7}px Inter, system-ui, sans-serif`
1074
+ ctx.textAlign = 'center'
1075
+ ctx.textBaseline = 'middle'
1076
+ ctx.fillText('This diagram has no elements.', labelX, group.worldY + group.diagramY + group.diagramH / 2)
1077
+ ctx.restore()
1078
+ }
1079
+ ctx.restore()
1080
+ }
1081
+
1082
+
1083
+ // ── Public: render one frame ───────────────────────────────────────
1084
+
1085
+ /**
1086
+ * Render a complete frame onto `ctx`.
1087
+ * Call this from a `requestAnimationFrame` loop.
1088
+ * The caller must set `ctx.setTransform(dpr,0,0,dpr,0,0)` before calling;
1089
+ * `canvasW/canvasH` are CSS-pixel dimensions (the transform handles HiDPI).
1090
+ */
1091
+ export function renderFrame(
1092
+ ctx: CanvasRenderingContext2D,
1093
+ groups: DiagramGroupLayout[],
1094
+ view: ZUIViewState,
1095
+ canvasW: number,
1096
+ canvasH: number,
1097
+ ): ScreenRect[] {
1098
+ // Read user-customisable CSS vars once per frame
1099
+ const canvasBg = readCSSVar('--bg-main', '#0d121e')
1100
+ const nodeBg = readCSSVar('--bg-element', '#2d3748')
1101
+ const accent = readCSSVar('--accent', '#63b3ed')
1102
+ const labelBg = readCSSVar('--chakra-colors-gray-900', '#171923')
1103
+
1104
+ ctx.clearRect(0, 0, canvasW, canvasH)
1105
+
1106
+ // Background matches the app's --bg-main
1107
+ ctx.fillStyle = canvasBg
1108
+ ctx.fillRect(0, 0, canvasW, canvasH)
1109
+
1110
+
1111
+ // Apply world transform
1112
+ ctx.save()
1113
+ ctx.translate(view.x, view.y)
1114
+ ctx.scale(view.zoom, view.zoom)
1115
+
1116
+ const thresholds = getExpandThresholds(canvasW)
1117
+ const occupiedLabelRects: ScreenRect[] = []
1118
+
1119
+ for (const group of groups) {
1120
+ if (!isVisible(group.worldX, group.worldY, group.worldW, group.worldH, view, canvasW, canvasH)) {
1121
+ continue
1122
+ }
1123
+
1124
+ drawGroupLabel(ctx, group, view, canvasW, canvasH, accent)
1125
+
1126
+ // ── Group box (diagram elements container) ──────────────────────────
1127
+ const borderAlpha = clamp(0.5 - view.zoom * 0.05, 0.15, 0.5)
1128
+
1129
+ ctx.save()
1130
+ ctx.globalAlpha = borderAlpha
1131
+ ctx.strokeStyle = accent
1132
+ ctx.lineWidth = 2 / view.zoom
1133
+ ctx.setLineDash([2, 2])
1134
+ // Only draw the border around the diagram part (not portals)
1135
+ ctx.strokeRect(group.worldX + group.diagramX, group.worldY + group.diagramY, group.diagramW, group.diagramH)
1136
+ ctx.setLineDash([])
1137
+ ctx.restore()
1138
+
1139
+ // ── Squiggly edges to portal nodes ────────────────────────────────
1140
+ ctx.save()
1141
+ ctx.strokeStyle = accent
1142
+ ctx.setLineDash([])
1143
+ ctx.lineWidth = 2 / view.zoom
1144
+ ctx.globalAlpha = 0.6
1145
+ for (const node of group.nodes) {
1146
+ if (node.isPortal) {
1147
+ // Draw squiggle/dash from diagram box boundary to portal box boundary
1148
+ const cx = group.worldX + group.diagramX + group.diagramW / 2
1149
+ const cy = group.worldY + group.diagramY + group.diagramH / 2
1150
+ const px = node.worldX + node.worldW / 2
1151
+ const py = node.worldY + node.worldH / 2
1152
+
1153
+ const dx = px - cx
1154
+ const dy = py - cy
1155
+
1156
+ const getBBoxIntersection = (boxW: number, boxH: number, targetDX: number, targetDY: number) => {
1157
+ const hw = boxW / 2 + 10 // pad
1158
+ const hh = boxH / 2 + 10 // pad
1159
+ if (Math.abs(targetDX * hh) > Math.abs(targetDY * hw)) {
1160
+ return { x: Math.sign(targetDX) * hw, y: targetDY * (hw / Math.abs(targetDX)) }
1161
+ } else {
1162
+ return { x: targetDX * (hh / Math.abs(targetDY)), y: Math.sign(targetDY) * hh }
1163
+ }
1164
+ }
1165
+
1166
+ const start = getBBoxIntersection(group.diagramW, group.diagramH, dx, dy)
1167
+ const end = getBBoxIntersection(node.worldW, node.worldH, -dx, -dy)
1168
+
1169
+ drawSquigglyLine(ctx, cx + start.x, cy + start.y, px + end.x, py + end.y, view.zoom)
1170
+ }
1171
+ }
1172
+ ctx.restore()
1173
+
1174
+ // Edges in this group
1175
+ drawEdges(ctx, group.nodes, 0.7, view.zoom, thresholds, accent, labelBg, occupiedLabelRects)
1176
+
1177
+ // Nodes in this group
1178
+ for (const node of group.nodes) {
1179
+ if (!isVisible(node.worldX, node.worldY, node.worldW, node.worldH, view, canvasW, canvasH)) {
1180
+ continue
1181
+ }
1182
+ const screenW = node.worldW * view.zoom
1183
+ drawNode(ctx, node, screenW, thresholds, 1, view.zoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, node.worldX, node.worldY, 1, occupiedLabelRects)
1184
+ }
1185
+ }
1186
+
1187
+ ctx.restore()
1188
+ return occupiedLabelRects
1189
+ }